非主线程架构可以显著提高应用的可靠性和用户体验。
在过去 20 年中,Web 从仅包含少量样式和图片的静态文档,演变为复杂的动态应用。不过,有一件事基本没有变化:每个浏览器标签页只有一个线程(存在一些例外情况),用于呈现网站和运行 JavaScript。
因此,主线程变得非常繁忙。随着 Web 应用变得越来越复杂,主线程会成为性能的重大瓶颈。更糟糕的是,在主线程上针对给定用户运行代码所需的时间几乎完全不可预测,因为设备功能对性能有很大影响。随着用户使用越来越多样化的设备(从功能极其受限的功能手机到高性能、高刷新率的旗舰设备)访问网络,这种不可预测性只会越来越大。
如果我们希望复杂的 Web 应用能够可靠地满足性能准则(例如基于人类感知和心理学的实证数据的核心 Web 指标),就需要找到在主线程之外 (OMT) 执行代码的方法。
为什么要使用 Web Worker?
JavaScript 默认是单线程语言,在主线程上运行任务。不过,Web Worker 允许开发者创建单独的线程来处理主线程之外的工作,从而提供了一种从主线程逃逸的方法。虽然 Web Worker 的范围有限,并且不提供对 DOM 的直接访问权限,但如果需要执行大量工作,而这些工作会使主线程过载,Web Worker 会非常有用。
从 Core Web Vitals 的角度来看,在主线程之外运行工作可能会有益。具体而言,将工作从主线程分流到 Web Worker 可以减少对主线程的争用,从而改进网页的 Interaction to Next Paint (INP) 响应能力指标。当主线程要处理的工作量较少时,它可以更快地响应用户互动。
减少主线程工作(尤其是在启动期间)还可以减少长任务,从而对 Largest Contentful Paint (LCP) 有潜在益处。渲染 LCP 元素需要主线程时间,无论是渲染文本还是图片(这两者都是常见的 LCP 元素),都需要主线程时间。通过总体减少主线程工作,您可以确保网页的 LCP 元素不太可能被 Web Worker 可以处理的高成本工作所阻塞。
使用网页工作器进行线程处理
其他平台通常支持并行工作,方法是允许您向线程提供一个函数,该函数会与程序的其余部分并行运行。您可以从两个线程访问相同的变量,并且可以使用互斥量和信号量同步对这些共享资源的访问,以防止出现竞争条件。
在 JavaScript 中,我们可以通过 Web Worker 获得大致类似的功能。Web Worker 自 2007 年起就已存在,自 2012 年起,所有主要浏览器都支持 Web Worker。Web Worker 与主线程并行运行,但与操作系统线程不同,它们无法共享变量。
如需创建 Web 工作器,请将文件传递给 worker 构造函数,该构造函数会在单独的线程中开始运行该文件:
const worker = new Worker("./worker.js");
使用 postMessage
API 发送消息,与 Web Worker 进行通信。在 postMessage
调用中将消息值作为参数传递,然后向 worker 添加消息事件监听器:
main.js
const worker = new Worker('./worker.js');
worker.postMessage([40, 2]);
worker.js
addEventListener('message', event => {
const [a, b] = event.data;
// Do stuff with the message
// ...
});
如需将消息发回主线程,请在 Web Worker 中使用相同的 postMessage
API,并在主线程上设置事件监听器:
main.js
const worker = new Worker('./worker.js');
worker.postMessage([40, 2]);
worker.addEventListener('message', event => {
console.log(event.data);
});
worker.js
addEventListener('message', event => {
const [a, b] = event.data;
// Do stuff with the message
postMessage(a + b);
});
诚然,这种方法有些局限性。从历史上看,Web Worker 主要用于将单个繁重工作从主线程移出。尝试使用单个 Web 工作器处理多项操作很快就会变得难以处理:您不仅需要对消息中的参数进行编码,还需要对操作进行编码,并且您还必须进行记账以将响应与请求进行匹配。这种复杂性可能是 Web Worker 未得到更广泛采用的原因。
不过,如果我们能够消除主线程与 Web Worker 之间通信的一些难点,这种模型就非常适合许多用例。幸运的是,有一个库可以做到这一点!
Comlink:让 Web Worker 的工作量减少
Comlink 是一个库,旨在让您无需考虑 postMessage
的细节即可使用 Web Worker。借助 Comlink,您可以在 Web Worker 和主线程之间共享变量,这与支持线程的其他编程语言非常相似。
您可以通过在 Web Worker 中导入 Comlink 并定义一组要公开给主线程的函数来设置 Comlink。然后,您可以在主线程上导入 Comlink,封装 worker,并获取对公开函数的访问权限:
worker.js
import {expose} from 'comlink';
const api = {
someMethod() {
// ...
}
}
expose(api);
main.js
import {wrap} from 'comlink';
const worker = new Worker('./worker.js');
const api = wrap(worker);
主线程上的 api
变量的行为与 Web Worker 中的 api
变量相同,但每个函数都会返回一个值的 Promise,而不是值本身。
您应该将哪些代码移至 Web Worker?
Web 工作器无法访问 DOM 和许多 API(例如 WebUSB、WebRTC 或 Web Audio),因此您无法将依赖于此类访问权限的应用部分放入工作器中。不过,将每一段小代码移至工作器,都可以为主线程上必须执行的操作(例如更新界面)留出更多空间。
Web 开发者面临的一个问题是,大多数 Web 应用都依赖于 Vue 或 React 等界面框架来协调应用中的所有内容;所有内容都是框架的组件,因此与 DOM 有内在关联。这似乎会使迁移到 OMT 架构变得困难。
不过,如果我们改用将界面问题与其他问题(例如状态管理)分离开的模型,即使在基于框架的应用中,Web Worker 也非常有用。PROXX 正是采用了这种方法。
PROXX:OMT 案例研究
Google Chrome 团队开发了 PROXX,这是一个符合渐进式 Web 应用要求(包括离线工作和提供富有吸引力的用户体验)的扫雷游戏克隆。遗憾的是,游戏的早期版本在功能机等受限设备上运行效果不佳,这让团队意识到主线程是瓶颈。
该团队决定使用 Web Worker 将游戏的视觉状态与其逻辑分离:
- 主线程负责处理动画和过渡的渲染。
- Web Worker 用于处理纯计算性的游戏逻辑。
OMT 对 PROXX 的功能手机性能产生了有趣的影响。在非 OMT 版本中,界面会在用户与其互动后冻结 6 秒。没有任何反馈,用户必须等待完整的 6 秒钟才能执行其他操作。
不过,在 OMT 版本中,游戏需要 十二 秒才能完成界面更新。虽然这似乎会降低性能,但实际上会增加向用户提供的反馈。之所以会出现速度变慢的问题,是因为应用发送的帧数比非 OMT 版本多,后者根本不发送任何帧。因此,用户知道正在发生某些事情,并且可以在界面更新时继续玩游戏,从而获得更出色的游戏体验。
这是有意为之的权衡:我们会为受限设备的用户提供感觉更好的体验,同时不会惩罚高端设备的用户。
OMT 架构的影响
如 PROXX 示例所示,OMT 可让您的应用在更多类型的设备上可靠运行,但不会加快应用的运行速度:
- 您只是将工作从主线程移出,而不是减少工作量。
- Web Worker 和主线程之间的额外通信开销有时可能会导致速度略微变慢。
权衡利弊
由于主线程在 JavaScript 运行时可以自由处理滚动等用户互动,因此丢帧次数会减少,即使总等待时间可能会略微延长也是如此。让用户稍等一会比丢帧更可取,因为丢帧的误差更小:丢帧发生在几毫秒内,而用户感知到等待时间需要 几百毫秒。
由于不同设备上的性能不可预测,OMT 架构的目标实际上是降低风险(让应用在面对高度不稳定的运行时条件时更为稳健),而不是追求并行处理带来的性能优势。提高弹性和改进用户体验的价值远远大于速度上的任何小小权衡。
关于工具的说明
Web Worker 尚未成为主流,因此大多数模块工具(例如 webpack 和 Rollup)都不支持 Web Worker。(不过 Parcel 可以!)幸运的是,有插件可以让 Web Worker 与 webpack 和 Rollup 协同工作:
- 适用于 webpack 的 worker-plugin
- 适用于汇总的 rollup-plugin-off-main-thread
总结
为了确保我们的应用尽可能可靠且易于访问,尤其是在日益全球化的市场中,我们需要支持受限设备,因为全球大多数用户都是通过这些设备访问网络。OMT 提供了一种有望在这些设备上提升性能的方法,而不会对高端设备的用户产生不利影响。
此外,OMT 还有一些附带好处:
- 它会将 JavaScript 执行开销转移到单独的线程。
- 它会移除解析开销,这意味着界面可能会更快启动。这可能会缩短首次内容绘制或甚至互动所需时间,进而提高 Lighthouse 评分。
Web Worker 并不可怕。Comlink 等工具可以代替工作人员执行工作,因此非常适合各种 Web 应用。