采用非主线程架构可以显著提高应用的可靠性和用户体验。
在过去 20 年里,Web 从只有少量样式和图片的静态文档发展成为复杂的动态应用。不过,有一点在很大程度上保持不变:每个浏览器标签页(少数情况除外)只有一个线程来完成呈现网站和运行 JavaScript 的工作。
因此,主线程的工作量变得非常大。随着 Web 应用的复杂性不断增加,主线程会成为性能方面的一大瓶颈。更糟糕的是,在主线程上为给定用户运行代码所需的时间几乎完全无法预测,因为设备功能对性能有巨大影响。随着用户通过越来越多的设备(从高度受限的非智能手机到高性能、高刷新率的旗舰设备)访问网络,这种不可预测性只会越来越强。
如果我们希望复杂的 Web 应用能够可靠地满足核心 Web 指标等性能指南(该指标基于有关人类感知和心理学的经验数据),就需要采用在主线程之外 (OMT) 执行代码的方法。
为何要使用 Web Worker?
默认情况下,JavaScript 是一种单线程语言,可在主线程上运行任务。不过,Web Worker 为开发者提供了一种从主线程中脱身的途径,允许他们创建单独的线程来处理主线程之外的工作。虽然 Web 工作器的作用域有限,无法直接访问 DOM,但如果需要完成大量工作,否则会使主线程不堪重负,那么 Web 工作器会非常有用。
就核心 Web 指标而言,在主线程之外运行工作可以带来好处。具体而言,将工作从主线程分流到 Web 工作器可以减少主线程的争用,从而提高网页的 Interaction to Next Paint (INP) 响应速度指标。当主线程需要处理的工作较少时,它可以更快地响应用户互动。
减少主线程工作量(尤其是在启动期间)还可以通过减少长时间运行的任务来潜在地提升 Largest Contentful Paint (LCP)。渲染 LCP 元素需要主线程时间(无论是渲染文字还是图片,这都是常见的 LCP 元素),而通过减少主线程的总体工作量,您可以确保网页的 LCP 元素不太可能被 Web Worker 可以处理的昂贵工作阻塞。
使用网页工作器进行线程处理
其他平台通常支持并行工作,允许您为线程提供一个函数,该函数与程序的其余部分并行运行。您可以从这两个线程访问相同的变量,并且可以使用互斥锁和信号量同步对这些共享资源的访问,以防止出现竞态条件。
在 JavaScript 中,我们可以通过 Web Worker 获得大致类似的功能,该功能自 2007 年以来一直存在,并自 2012 年以来在所有主要浏览器中都得到支持。Web worker 与主线程并行运行,但与操作系统线程不同,它们无法共享变量。
如需创建 Web Worker,请将文件传递给 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 工作器未得到更广泛采用的原因。
但如果我们能消除主线程和 Web Worker 之间通信的一些困难,那么这种模型将非常适合许多应用场景。幸运的是,有一个库可以实现此目的!
Comlink:让网页工作器更轻松
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 中的变量相同,只不过每个函数都会返回一个值对应的 promise,而不是值本身。
哪些代码应移至 Web Worker?
Web Worker 无法访问 DOM 和许多 API(例如 WebUSB、WebRTC 或 Web Audio),因此您无法将依赖于此类访问权限的应用部分放在 Worker 中。不过,每段移至工作线程的小代码都会为主线程上必须执行的任务(例如更新界面)腾出更多空间。
对于 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 版本中,游戏需要 12 秒才能完成界面更新。虽然这看起来会损失性能,但实际上会增加向用户提供的反馈。出现减速是因为应用传送的帧数比根本不传送任何帧的非 OMT 版本多。因此,用户知道正在发生某些事情,并且可以在界面更新时继续玩游戏,从而使游戏体验得到显著提升。
这是一种有意识的权衡:我们为受限设备的用户提供感觉更好的体验,同时不会影响高端设备的用户。
OMT 架构的影响
如 PROXX 示例所示,OMT 可让您的应用在更广泛的设备上可靠运行,但不会提高应用的速度:
- 您只是将工作从主线程移出,并没有减少工作量。
- Web Worker 与主线程之间的额外通信开销有时会使速度略微变慢。
权衡利弊
由于主线程在 JavaScript 运行时可以自由处理滚动等用户互动,因此即使总等待时间可能略长,丢帧也会更少。让用户稍等片刻比丢弃帧要好,因为丢弃帧的误差范围更小:丢弃帧发生在毫秒级,而用户感知到等待时间需要数百毫秒。
由于不同设备的性能不可预测,因此 OMT 架构的目标实际上是降低风险,即让您的应用在高度可变的运行时条件下更加稳健,而不是利用并行化来提升性能。弹性的提升和用户体验的改进完全值得牺牲一点速度。
关于工具的说明
Web Worker 尚未成为主流,因此大多数模块工具(例如 webpack 和 Rollup)都不支持开箱即用。(但Parcel可以!)幸运的是,有一些插件可让 Web Worker 与 webpack 和 Rollup 搭配使用:
- 适用于 webpack 的 worker-plugin
- 适用于 Rollup 的 rollup-plugin-off-main-thread
总结
为了确保我们的应用尽可能可靠且易于访问,尤其是在日益全球化的市场中,我们需要支持受限设备,因为全球大多数用户都是通过这类设备访问网络的。OMT 提供了一种有前景的方法,可在不影响高端设备用户的情况下提高此类设备的性能。
此外,OMT 还具有次要优势:
- 它将 JavaScript 执行费用转移到单独的线程。
- 它会转移解析费用,这意味着界面可能会更快启动。 这可能会缩短首次内容渲染时间,甚至缩短可交互时间,从而提高 Lighthouse 分数。
Web worker 不一定很可怕。Comlink 等工具正在简化工作器的使用,使其成为各种 Web 应用的可行选择。