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

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

在过去的 20 年里,Web 已经发生了巨大的变化,从只有少量样式和图片的静态文档发展到复杂的动态应用程序。然而,有一件事在很大程度上没有改变:每个浏览器标签页(有一些例外)只有一个线程来完成渲染站点和运行 JavaScript 的工作。

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

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

为什么选择 Web Worker?

JavaScript 默认是一种单线程语言,它在主线程上运行任务。但是,Web Worker 提供了一种从主线程“逃生”的方式,允许开发人员创建单独的线程来处理主线程外的工作。虽然 Web Worker 的范围有限,并且不提供对 DOM 的直接访问,但如果需要完成大量工作,否则会使主线程不堪重负,那么它们可能会非常有用。

Core Web Vitals 而言,在主线程外运行工作可能是有益的。特别是,将工作从主线程卸载到 Web Worker 可以减少主线程的争用,这可以提高页面的交互到下次绘制 (INP) 响应速度指标。当主线程需要处理的工作较少时,它可以更快地响应用户交互。

较少的主线程工作(尤其是在启动期间)也可能为最大内容渲染 (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 是一个库,其目标是让您使用 Web Worker,而无需考虑 postMessage 的细节。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 和许多 API,例如 WebUSBWebRTCWeb Audio,因此您无法将依赖于此类访问的应用部分放入 Worker 中。尽管如此,移至 Worker 的每一小段代码都会为主线程上必须存在的内容(例如更新用户界面)腾出更多空间。

Web 开发人员面临的一个问题是,大多数 Web 应用都依赖于诸如 Vue 或 React 之类的 UI 框架来协调应用中的所有内容;一切都是框架的组件,因此本质上与 DOM 绑定在一起。这似乎使得迁移到 OMT 架构变得困难。

但是,如果我们转向 UI 问题与其他问题(如状态管理)分离的模型,即使对于基于框架的应用,Web Worker 也可能非常有用。这正是 PROXX 采用的方法。

PROXX:OMT 案例研究

Google Chrome 团队开发了 PROXX,作为满足渐进式 Web 应用 要求的扫雷克隆版,包括离线工作和具有引人入胜的用户体验。不幸的是,该游戏的早期版本在功能手机等受限设备上的性能很差,这使得团队意识到主线程是一个瓶颈。

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

  • 主线程处理动画和过渡的渲染。
  • Web Worker 处理游戏逻辑,这纯粹是计算性的。

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

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

然而,在 OMT 版本中,游戏需要十二秒才能完成 UI 更新。虽然这看起来像是性能损失,但实际上会导致用户获得更多反馈。速度减慢的原因是该应用发送的帧数多于非 OMT 版本,而非 OMT 版本根本不发送任何帧。因此,用户知道正在发生某些事情,并且可以在 UI 更新时继续玩游戏,从而使游戏感觉好得多。

PROXX 的 OMT 版本中的 UI 响应时间。

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

OMT 架构的含义

正如 PROXX 示例所示,OMT 使您的应用能够在更广泛的设备上可靠运行,但它不会使您的应用更快

  • 您只是在移动主线程的工作,而不是减少工作。
  • Web Worker 和主线程之间额外的通信开销有时会使事情稍微变慢。

考虑权衡

由于主线程可以自由处理诸如滚动之类的用户交互,同时 JavaScript 正在运行,因此即使总等待时间可能稍微长一些,丢帧也会更少。让用户稍等片刻比丢帧更好,因为丢帧的误差幅度较小:丢帧发生在毫秒内,而您有数百毫秒的时间用户才会感知到等待时间。

由于跨设备的性能不可预测,OMT 架构的目标实际上是降低风险——使您的应用在面对高度可变运行时条件时更强大——而不是并行化的性能优势。弹性的提高和 UX 的改进远远超过了速度上的任何微小权衡。

关于工具的说明

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

总结

为了确保我们的应用尽可能可靠和可访问,尤其是在日益全球化的市场中,我们需要支持受限设备——它们是大多数用户在全球范围内访问 Web 的方式。OMT 提供了一种有希望的方式来提高此类设备上的性能,而不会对高端设备的用户产生不利影响。

此外,OMT 还具有次要好处

  • 它将 JavaScript 执行成本转移到单独的线程。
  • 它转移了解析成本,这意味着 UI 可能会启动得更快。这可能会减少 首次内容渲染 甚至 可交互就绪时间,这反过来又可以提高您的 Lighthouse 分数。

Web Worker 不必令人恐惧。Comlink 等工具正在消除 Worker 的工作量,并使其成为各种 Web 应用的可行选择。