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

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

Surma
Surma

在过去 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 应用的可行选择。