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

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

在过去 20 年中,Web 从仅包含少量样式和图片的静态文档,演变为复杂的动态应用。不过,有一件事基本没有变化:每个浏览器标签页只有一个线程(存在一些例外情况),用于呈现网站和运行 JavaScript。

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

如果我们希望复杂的 Web 应用能够可靠地满足性能准则(例如基于人类感知和心理学的实证数据的核心 Web 指标),就需要找到在主线程之外 (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 可以处理的高成本工作所阻塞。

使用网页工作器进行线程处理

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

在 JavaScript 中,我们可以通过 Web Worker 获得大致类似的功能。Web Worker 自 2007 年起就已存在,自 2012 年起,所有主要浏览器都支持 Web Worker。Web Worker 与主线程并行运行,但与操作系统线程不同,它们无法共享变量。

如需创建 Web 工作器,请将文件传递给 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 工作器处理多项操作很快就会变得难以处理:您不仅需要对消息中的参数进行编码,还需要对操作进行编码,并且您还必须进行记账以将响应与请求进行匹配。这种复杂性可能是 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 中的 api 变量相同,但每个函数都会返回一个值的 Promise,而不是值本身。

您应该将哪些代码移至 Web Worker?

Web 工作器无法访问 DOM 和许多 API(例如 WebUSBWebRTCWeb Audio),因此您无法将依赖于此类访问权限的应用部分放入工作器中。不过,将每一段小代码移至工作器,都可以为主线程上必须执行的操作(例如更新界面)留出更多空间。

Web 开发者面临的一个问题是,大多数 Web 应用都依赖于 Vue 或 React 等界面框架来协调应用中的所有内容;所有内容都是框架的组件,因此与 DOM 有内在关联。这似乎会使迁移到 OMT 架构变得困难。

不过,如果我们改用将界面问题与其他问题(例如状态管理)分离开的模型,即使在基于框架的应用中,Web Worker 也非常有用。PROXX 正是采用了这种方法。

PROXX:OMT 案例研究

Google Chrome 团队开发了 PROXX,这是一个符合渐进式 Web 应用要求(包括离线工作和提供富有吸引力的用户体验)的扫雷游戏克隆。遗憾的是,游戏的早期版本在功能机等受限设备上运行效果不佳,这让团队意识到主线程是瓶颈。

该团队决定使用 Web Worker 将游戏的视觉状态与其逻辑分离:

  • 主线程负责处理动画和过渡的渲染。
  • Web Worker 用于处理纯计算性的游戏逻辑。

OMT 对 PROXX 的功能手机性能产生了有趣的影响。在非 OMT 版本中,界面会在用户与其互动后冻结 6 秒。没有任何反馈,用户必须等待完整的 6 秒钟才能执行其他操作。

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

不过,在 OMT 版本中,游戏需要 十二 秒才能完成界面更新。虽然这似乎会降低性能,但实际上会增加向用户提供的反馈。之所以会出现速度变慢的问题,是因为应用发送的帧数比非 OMT 版本多,后者根本不发送任何帧。因此,用户知道正在发生某些事情,并且可以在界面更新时继续玩游戏,从而获得更出色的游戏体验。

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

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

OMT 架构的影响

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

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

权衡利弊

由于主线程在 JavaScript 运行时可以自由处理滚动等用户互动,因此丢帧次数会减少,即使总等待时间可能会略微延长也是如此。让用户稍等一会比丢帧更可取,因为丢帧的误差更小:丢帧发生在几毫秒内,而用户感知到等待时间需要 几百毫秒。

由于不同设备上的性能不可预测,OMT 架构的目标实际上是降低风险(让应用在面对高度不稳定的运行时条件时更为稳健),而不是追求并行处理带来的性能优势。提高弹性和改进用户体验的价值远远大于速度上的任何小小权衡。

关于工具的说明

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

总结

为了确保我们的应用尽可能可靠且易于访问,尤其是在日益全球化的市场中,我们需要支持受限设备,因为全球大多数用户都是通过这些设备访问网络。OMT 提供了一种有望在这些设备上提升性能的方法,而不会对高端设备的用户产生不利影响。

此外,OMT 还有一些附带好处:

  • 它会将 JavaScript 执行开销转移到单独的线程。
  • 它会移除解析开销,这意味着界面可能会更快启动。这可能会缩短首次内容绘制或甚至互动所需时间,进而提高 Lighthouse 评分。

Web Worker 并不可怕。Comlink 等工具可以代替工作人员执行工作,因此非常适合各种 Web 应用。