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

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

苏尔马
Surma

过去 20 年间,网络飞速发展,从包含几种样式和图片的静态文档发展为复杂的动态应用。不过,有一点在很大程度上保持不变:我们在每个浏览器标签页上只有一个线程(有一些例外情况),用来渲染网站和运行 JavaScript 的工作。

从而导致主线程负担过重。随着 Web 应用的复杂性不断增加,主线程成为性能方面的重大瓶颈。更糟糕的是,在主线程上为给定用户运行代码所花费的时间几乎是完全不可预测的,因为设备功能会对性能产生巨大的影响。只有当用户通过越来越多样化的设备(从超受限的功能手机到高性能、高刷新率的旗舰机)访问网络时,这种不可预测性才会增加。

如果我们希望复杂的 Web 应用可靠地满足核心网页指标(基于人类感知和心理学的经验数据)等性能指南,我们需要在主线程 (OMT) 之外执行代码。

为什么使用 Web Worker?

默认情况下,JavaScript 是一种在主线程上运行任务的单线程语言。不过,Web Worker 让开发者能够启动单独的线程来处理主线程以外的工作,为主线程提供了一种缓解办法。虽然 Web Worker 的范围有限且无法直接访问 DOM,但如果需要完成大量工作,否则主线程会过载,这些工作将大有裨益。

核心网页指标而言,在主线程之外运行工作会带来好处。具体而言,将工作从主线程分流到 Web 工作器可以减少对主线程的争用,从而提高重要的响应性指标,例如 Interaction to Next Paint (INP)First Input Delay (FID)。当主线程要处理的工作量较少时,它可以更快地响应用户互动。

主线程工作量减少(尤其是在启动期间)也可以减少耗时较长的任务,为 Largest Contentful Paint (LCP) 带来潜在好处。渲染 LCP 元素需要主线程时间(用于渲染常用且常见的 LCP 元素)所需的主线程。此外,通过从总体上减少主线程工作量,您可以确保网页的 LCP 元素不太可能被 Web Worker 可以处理的代价高昂的工作阻止。

使用 Web Worker 进行线程处理

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

在 JavaScript 中,我们可以通过 Web Worker 获得大致相似的功能,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 是一个库,其目标在于让您使用 Web Worker,而无需考虑 postMessage 的细节。通过 Comlink,您可以在 Web Worker 和主线程之间共享变量,这就像其他支持线程处理的编程语言一样。

设置 Comlink 的方式是,将 Comlink 导入 Web Worker,然后定义一组函数以向主线程公开。然后,您可以在主线程上导入 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 秒。没有反馈,用户必须等待六秒钟的时间才能执行其他操作。

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

而在 OMT 版本中,游戏需要 12 秒才能完成界面更新。虽然这看起来像是性能下降,但实际上会带来更多用户反馈。发生速度变慢的原因是,该应用支持的帧数多于非 OMT 版本,因为非 OMT 版本根本不会提供任何帧。这样,用户就会知道发生了什么,并且可以在界面更新时继续畅玩,从而使游戏体验大幅提升。

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

Unsplash 的主打图片,作者:James Peacock