脱离主线程的架构可以显著提高应用的可靠性和用户体验。
在过去的 20 年里,Web 已经发生了巨大的变化,从只有少量样式和图片的静态文档发展到复杂的动态应用程序。然而,有一件事在很大程度上没有改变:每个浏览器标签页(有一些例外)只有一个线程来完成渲染站点和运行 JavaScript 的工作。
因此,主线程变得异常过载。随着 Web 应用复杂性的增加,主线程成为性能的重大瓶颈。更糟糕的是,对于给定用户,在主线程上运行代码所需的时间几乎完全无法预测,因为设备功能对性能有巨大影响。随着用户通过越来越多样化的设备(从高度受限的功能手机到高性能、高刷新率的旗舰机器)访问 Web,这种不可预测性只会增加。
如果我们希望复杂的 Web 应用可靠地满足诸如 Core Web Vitals 之类的性能指南(该指南基于关于人类感知和心理学的经验数据),我们需要在主线程外 (OMT) 执行代码的方法。
为什么选择 Web Worker?
JavaScript 默认是一种单线程语言,它在主线程上运行任务。但是,Web Worker 提供了一种从主线程“逃生”的方式,允许开发人员创建单独的线程来处理主线程外的工作。虽然 Web Worker 的范围有限,并且不提供对 DOM 的直接访问,但如果需要完成大量工作,否则会使主线程不堪重负,那么它们可能会非常有用。
就 Core Web Vitals 而言,在主线程外运行工作可能是有益的。特别是,将工作从主线程卸载到 Web Worker 可以减少主线程的争用,这可以提高页面的交互到下次绘制 (INP) 响应速度指标。当主线程需要处理的工作较少时,它可以更快地响应用户交互。
较少的主线程工作(尤其是在启动期间)也可能为最大内容渲染 (LCP) 带来潜在的好处,方法是减少长任务。渲染 LCP 元素需要主线程时间(无论是渲染文本还是图像,它们是频繁且常见的 LCP 元素),通过减少整体主线程工作,您可以确保页面的 LCP 元素不太可能被 Web Worker 可以处理的昂贵工作阻塞。
使用 Web Worker 进行线程处理
其他平台通常通过允许您为线程提供一个函数来支持并行工作,该函数与程序的其余部分并行运行。您可以从两个线程访问相同的变量,并且可以使用互斥锁和信号量同步对这些共享资源的访问,以防止出现竞争条件。
在 JavaScript 中,我们可以从 Web Worker 获得大致相似的功能,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 Worker 处理多个操作会很快变得笨拙:您不仅必须对参数进行编码,还必须对消息中的操作进行编码,并且必须进行簿记以将响应与请求匹配。这种复杂性很可能是 Web Worker 没有得到更广泛采用的原因。
但是,如果我们可以消除主线程和 Web Worker 之间通信的一些困难,那么这种模型可能非常适合许多用例。幸运的是,有一个库可以做到这一点!
Comlink:让 Web Worker 更轻松
Comlink 是一个库,其目标是让您使用 Web Worker,而无需考虑 postMessage
的细节。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 中。尽管如此,移至 Worker 的每一小段代码都会为主线程上必须存在的内容(例如更新用户界面)腾出更多空间。
Web 开发人员面临的一个问题是,大多数 Web 应用都依赖于诸如 Vue 或 React 之类的 UI 框架来协调应用中的所有内容;一切都是框架的组件,因此本质上与 DOM 绑定在一起。这似乎使得迁移到 OMT 架构变得困难。
但是,如果我们转向 UI 问题与其他问题(如状态管理)分离的模型,即使对于基于框架的应用,Web Worker 也可能非常有用。这正是 PROXX 采用的方法。
PROXX:OMT 案例研究
Google Chrome 团队开发了 PROXX,作为满足渐进式 Web 应用 要求的扫雷克隆版,包括离线工作和具有引人入胜的用户体验。不幸的是,该游戏的早期版本在功能手机等受限设备上的性能很差,这使得团队意识到主线程是一个瓶颈。
该团队决定使用 Web Worker 将游戏的视觉状态与其逻辑分离
- 主线程处理动画和过渡的渲染。
- Web Worker 处理游戏逻辑,这纯粹是计算性的。
OMT 对 PROXX 的功能手机性能产生了有趣的影响。在非 OMT 版本中,用户与之交互后,UI 会冻结六秒钟。没有反馈,用户必须等待整整六秒钟才能执行其他操作。
然而,在 OMT 版本中,游戏需要十二秒才能完成 UI 更新。虽然这看起来像是性能损失,但实际上会导致用户获得更多反馈。速度减慢的原因是该应用发送的帧数多于非 OMT 版本,而非 OMT 版本根本不发送任何帧。因此,用户知道正在发生某些事情,并且可以在 UI 更新时继续玩游戏,从而使游戏感觉好得多。
这是一个有意识的权衡:我们为受限设备的用户提供了感觉更好的体验,而不会惩罚高端设备的用户。
OMT 架构的含义
正如 PROXX 示例所示,OMT 使您的应用能够在更广泛的设备上可靠运行,但它不会使您的应用更快
- 您只是在移动主线程的工作,而不是减少工作。
- Web Worker 和主线程之间额外的通信开销有时会使事情稍微变慢。
考虑权衡
由于主线程可以自由处理诸如滚动之类的用户交互,同时 JavaScript 正在运行,因此即使总等待时间可能稍微长一些,丢帧也会更少。让用户稍等片刻比丢帧更好,因为丢帧的误差幅度较小:丢帧发生在毫秒内,而您有数百毫秒的时间用户才会感知到等待时间。
由于跨设备的性能不可预测,OMT 架构的目标实际上是降低风险——使您的应用在面对高度可变运行时条件时更强大——而不是并行化的性能优势。弹性的提高和 UX 的改进远远超过了速度上的任何微小权衡。
关于工具的说明
Web Worker 尚未成为主流,因此大多数模块工具(如 webpack 和 Rollup)都不支持开箱即用。(Parcel 支持!)幸运的是,有一些插件可以使 Web Worker 与 webpack 和 Rollup 协同工作
- worker-plugin(用于 webpack)
- rollup-plugin-off-main-thread(用于 Rollup)
总结
为了确保我们的应用尽可能可靠和可访问,尤其是在日益全球化的市场中,我们需要支持受限设备——它们是大多数用户在全球范围内访问 Web 的方式。OMT 提供了一种有希望的方式来提高此类设备上的性能,而不会对高端设备的用户产生不利影响。
此外,OMT 还具有次要好处
- 它将 JavaScript 执行成本转移到单独的线程。
- 它转移了解析成本,这意味着 UI 可能会启动得更快。这可能会减少 首次内容渲染 甚至 可交互就绪时间,这反过来又可以提高您的 Lighthouse 分数。
Web Worker 不必令人恐惧。Comlink 等工具正在消除 Worker 的工作量,并使其成为各种 Web 应用的可行选择。