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

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

在过去的 20 年里,网络发展迅猛,从只有少数样式和图片的静态文档发展为复杂的动态应用。不过,有一件事基本没有变化:每个浏览器标签页只有一个线程(存在一些例外情况)来完成呈现网站和运行 JavaScript 的工作。

因此,主线程变得极为过度劳累。随着 Web 应用变得越来越复杂,主线程会成为性能的重要瓶颈。更糟糕的是,在主线程上为指定用户运行代码所花费的时间几乎完全不可预测,因为设备功能对性能有很大影响。只有当用户通过越来越多样化的设备(从超级受限的功能手机到高性能、高刷新率的旗舰机)访问网络时,这种不可预测性才会增加。

如果我们希望复杂的 Web 应用能够可靠地满足 Core Web Vitals 等性能准则(该准则基于人类感知和心理学方面的经验数据),则需要通过主线程 (OMT) 的方式执行我们的代码。

为什么选择 Web Worker?

默认情况下,JavaScript 是一种单线程语言,可在主线程上运行任务。不过,Web Worker 允许开发者创建单独的线程来处理主线程以外的工作,从而为主线程提供了一种应急方法。虽然 Web Worker 的范围有限,并且无法直接访问 DOM,但如果有大量工作需要完成,否则会导致主线程不堪重负,那么它们将大有裨益。

核心网页指标而言,在主线程以外运行工作是有益的。特别是,将工作从主线程分流到 Web Worker 可以减少对主线程的争用,从而改善页面的 Interaction to Next Paint (INP) 响应指标。当主线程要处理的工作减少时,它可以更快地响应用户互动。

减少主线程工作量(尤其是在启动期间)也可以减少耗时较长的任务,从而为 Largest Contentful Paint (LCP) 带来潜在好处。渲染 LCP 元素需要主线程时间(无论是渲染经常使用的 LCP 元素的文本或图片),还是通过减少主线程整体上的工作量,您可以确保网页的 LCP 元素不太可能被 Web Worker 处理的开销大的工作阻塞。

使用 Web Worker 进行线程处理

其他平台通常支持并行工作,即允许您为线程提供一个函数,该函数会与程序的其余部分并行运行。您可以从这两个线程访问相同的变量,并且对这些共享资源的访问可以与互斥量和信号量同步,以防止出现竞态条件。

在 JavaScript 中,我们可以从 Web Worker 获得大致类似的功能,此功能从 2007 年左右问世,自 2012 年起在所有主流浏览器上受支持。Web Worker 与主线程并行运行,但与 OS 线程不同,它们无法共享变量。

如需创建 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 和主线程之间共享变量,这与支持线程处理的其他编程语言几乎一样。

您可以通过在 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 以及 WebUSBWebRTCWeb Audio 等许多 API,因此您不能将依赖此类访问权限的应用片段放置在 Worker 中。不过,移动到 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 秒钟后才可以执行其他操作。

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
非 OMT 版本的 PROXX 中的界面响应时间。

但在 OMT 版本中,游戏需要 12 秒才能完成界面更新。虽然这看起来像是性能下降,但实际上却会增加对用户的反馈。出现速度变慢是因为该应用传输的帧数多于非 OMT 版本,后者完全不传输任何帧。因此,用户知道正在发生的事情,并且可以随着界面更新继续玩游戏,从而显著改善游戏体验。

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
OMT 版本的 PROXX 中的界面响应时间。

这是一种有意识的权衡:我们为受限设备的用户提供感觉更佳的体验,而不会对使用高端设备的用户造成负面影响。

OMT 架构的影响

正如 PROXX 示例所示,OMT 可让您的应用在更广泛的设备上可靠地运行,但无法提高应用的速度:

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

权衡利弊

由于主线程在 JavaScript 运行时可以自由处理滚动等用户互动,因此丢帧会更少,尽管总等待时间可能会略长一些。让用户等待一会儿可比丢帧更可取,因为丢帧的误差范围较小:丢帧会在毫秒内发生,而用户有几百毫秒才会感知到等待时间。

由于跨设备性能的不可预测性,OMT 架构的目标实际上是降低风险,使应用在面临高度变化的运行时条件时更加稳健,而不是并行化的性能优势。弹性的提升和用户体验的改进,对于速度方面的任何小小的权衡都值得一读。

关于工具的说明

Web Worker 目前尚未成为主流,因此大多数模块工具(例如 webpackRollup)都不支持开箱即用。(不过,您可以使用 Parcel 文件!)幸运的是,有一些插件可让 Web Worker 与 webpack 和 Rollup 协同工作:

汇总

为确保我们的应用尽可能可靠且易于访问,尤其是在日益全球化的市场中,我们需要支持受限设备 - 全球大多数用户访问网络的方式正是这些设备。OMT 提供了一种颇具前景的方法,既可提高此类设备上的性能,又不会对高端设备的用户产生不利影响。

此外,OMT 还有另外一些优势:

  • 它将 JavaScript 执行开销转移到单独的线程。
  • 它可以降低解析成本,这意味着界面启动速度更快。 这可能会减少 First Contentful Paint 甚至是可交互时间 这反过来又能 Lighthouse 得分。

Web Worker 无需那么害怕。Comlink 等工具可以减轻员工的工作负担,使其成为众多 Web 应用的可行选择。

主打图片来自 UnspinJames Peacock 制作。