使用 Web Worker 在浏览器的主线程之外运行 JavaScript

非主线程架构可以显著提升应用的可靠性和用户体验。

在过去 20 年中,Web 发生了巨大变化,从只有几种样式和图片的静态文档发展到复杂的动态应用。不过,有一点基本没有改变:每个浏览器标签页只有一个线程(有一些例外情况)来执行网站渲染和 JavaScript 运行工作。

因此,主线程的工作量变得非常大。随着 Web 应用变得越来越复杂,主线程成为影响性能的重大瓶颈。更糟糕的是,对于特定用户,在主线程上运行代码所需的时间几乎完全无法预测,因为设备功能对性能有很大影响。随着用户通过越来越多样化的设备(从性能受限的非智能手机到高刷新率的高性能旗舰机)访问网络,这种不可预测性只会越来越高。

如果我们希望复杂的 Web 应用能够可靠地满足 Core Web Vitals(基于有关人类感知和心理学的经验数据)等性能准则,我们需要在主线程之外 (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 可以处理的高开销工作阻塞。

使用 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 是一个库,旨在让您无需考虑 postMessage 的细节即可使用 Web Worker。Comlink 允许您在 Web Worker 和主线程之间共享变量,就像其他支持线程的编程语言一样。

您可以通过以下方式设置 Comlink:在 Web Worker 中导入 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 以及 WebUSB、WebRTC 或 Web Audio 等许多 API,因此您无法将应用中依赖此类访问权限的部分放入 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 版 PROXX 中的界面响应时间。

但在 OMT 版本中,游戏需要 12 秒才能完成界面更新。虽然这看起来像是性能损失,但实际上它会增加对用户的反馈。之所以出现速度变慢的情况,是因为该应用比非 OMT 版本传送了更多帧,而非 OMT 版本根本不传送任何帧。这样,用户就知道有事情正在发生,并且可以在界面更新时继续玩游戏,从而大大提升游戏体验。

OMT 版 PROXX 中的界面响应时间。

这是一种有意识的权衡:我们为受限设备的用户提供感觉更好的体验,同时不会影响高端设备的用户。

OMT 架构的影响

如 PROXX 示例所示,OMT 可让您的应用在更多设备上可靠地运行,但不会提高应用运行速度:

  • 您只是将工作从主线程移出,而不是减少工作。
  • Web Worker 与主线程之间的额外通信开销有时可能会导致速度略微变慢。

权衡利弊

由于主线程在 JavaScript 运行时可以自由处理滚动等用户互动,因此即使总等待时间可能略长,掉帧情况也会减少。让用户稍等片刻比丢帧更好,因为丢帧的误差幅度更小:丢帧发生在几毫秒内,而用户需要数百毫秒才能感知到等待时间。

由于设备性能难以预测,OMT 架构的目标实际上是降低风险,即让应用在面对高度可变的运行时条件时更加稳健,而不是为了获得并行化带来的性能优势。虽然速度上会有所牺牲,但换来的是更高的弹性和更好的用户体验,这绝对物超所值。

关于工具的注意事项

Web worker 尚未成为主流,因此大多数模块工具(如 webpack 和 Rollup)都不支持开箱即用。(不过,Parcel 会!)幸运的是,有一些插件可以让 Web Worker 与 webpack 和 Rollup 搭配使用:

总结

为了确保我们的应用尽可能可靠且易于访问,尤其是在日益全球化的市场中,我们需要支持受限设备,因为全球大多数用户都是通过这些设备访问网络的。OMT 提供了一种很有前景的方法,可以在不给高端设备用户带来负面影响的情况下,提升此类设备上的性能。

此外,OMT 还有其他好处:

  • 它会将 JavaScript 执行开销转移到单独的线程。
  • 它会转移解析费用,这意味着界面可能会更快启动。 这可能会缩短 First Contentful Paint 时间,甚至缩短 Time to Interactive 时间,进而提高 Lighthouse 得分。

Web Worker 并不难。Comlink 等工具正在简化工作器,使其成为各种 Web 应用的可行选择。