使用模块 worker 进行 Web 线程处理

现在,使用 Web Worker 中的 JavaScript 模块可以更轻松地将繁重工作转移到后台线程。

JavaScript 是单线程的,这意味着它一次只能执行一项操作。这种方式很直观,适用于网络上的许多情况,但在我们需要执行繁重的任务(如数据处理、解析、计算或分析)时,可能会出现问题。随着 Web 上的应用越来越复杂,对多线程处理的需求也水涨船高。

在 Web 平台上,线程处理和并行处理的主要基元是 Web Workers API。工作器是操作系统线程之上的轻量级抽象,用于公开用于线程间通信的消息传递 API。在执行成本高昂的计算或对大型数据集执行操作时,这非常有用,可让主线程在一个或多个后台线程上执行开销大的操作的同时顺畅运行。

以下是一个典型的工作器使用示例,其中工作器脚本监听来自主线程的消息,并通过发回自己的消息来进行响应:

page.js:

const worker = new Worker('worker.js');
worker.addEventListener('message', e => {
  console.log(e.data);
});
worker.postMessage('hello');

worker.js:

addEventListener('message', e => {
  if (e.data === 'hello') {
    postMessage('world');
  }
});

在大多数浏览器中,Web Worker API 已经使用了十多年。虽然这意味着工作器能够提供出色的浏览器支持且已经过充分优化,但这也意味着它们早就早于 JavaScript 模块。由于设计 worker 时没有模块系统,因此用于将代码加载到 worker 中并编写脚本的 API 仍然与 2009 年常见的同步脚本加载方法相似。

历史记录:传统版工作器

Worker 构造函数采用传统脚本网址,该网址相对于文档网址。它会立即返回对新工作器实例的引用,该实例会公开消息传递接口以及立即停止并销毁工作器的 terminate() 方法。

const worker = new Worker('worker.js');

Web Worker 中提供了 importScripts() 函数,用于加载其他代码,但它会暂停工作器的执行,以便提取和评估每个脚本。它还会像传统的 <script> 标记一样在全局范围内执行脚本,这意味着一个脚本中的变量可能会被另一个脚本中的变量覆盖。

worker.js:

importScripts('greet.js');
// ^ could block for seconds
addEventListener('message', e => {
  postMessage(sayHello());
});

greet.js:

// global to the whole worker
function sayHello() {
  return 'world';
}

因此,Web Worker 历来对应用的架构施加了巨大影响。开发者必须创建巧妙的工具和解决方法,以便在不放弃现代开发实践的情况下使用 Web Worker。例如,Webpack 等打包器将一个小型模块加载器实现嵌入到使用 importScripts() 进行代码加载的生成的代码中,但会将模块封装在函数中,以避免变量冲突并模拟依赖项导入和导出。

输入模块 worker

Chrome 80 中推出了一种新的 Web Worker 模式,称为模块工作器,它具备 JavaScript 模块的工效学设计和性能优势。Worker 构造函数现在接受新的 {type:"module"} 选项,该选项更改脚本加载和执行以与 <script type="module"> 匹配。

const worker = new Worker('worker.js', {
  type: 'module'
});

由于模块工作器是标准的 JavaScript 模块,因此它们可以使用 import 和 export 语句。与所有 JavaScript 模块一样,依赖项仅在给定上下文(主线程、工作器等)中执行一次,并且所有后续导入都会引用已执行的模块实例。JavaScript 模块的加载和执行也由浏览器进行了优化。模块的依赖项可以在模块执行之前加载,这样就可以并行加载整个模块树。模块加载还会缓存已解析的代码,这意味着,在主线程和 worker 中使用的模块只需要解析一次。

移至 JavaScript 模块还支持使用动态导入延迟加载代码,而不会阻止 worker 的执行。动态导入比使用 importScripts() 加载依赖项要明确得多,因为系统会返回已导入的模块的导出,而不是依赖于全局变量。

worker.js:

import { sayHello } from './greet.js';
addEventListener('message', e => {
  postMessage(sayHello());
});

greet.js:

import greetings from './data.js';
export function sayHello() {
  return greetings.hello;
}

为确保实现出色的性能,旧的 importScripts() 方法在模块工作器中不可用。将 worker 切换为使用 JavaScript 模块意味着所有代码都会在严格模式下加载。另一个显著的变化是,JavaScript 模块的顶级作用域中的 this 值为 undefined,而在传统版工作器中,该值是工作器的全局作用域。幸运的是,一直存在提供对全局范围的引用的 self 全局变量。它适用于所有类型的工作器(包括 Service Worker),以及 DOM 中。

使用 modulepreload 预加载工作器

模块 worker 的一项重大性能提升是能够预加载 worker 及其依赖项。对于模块工作器,脚本将作为标准 JavaScript 模块加载和执行,这意味着它们可以使用 modulepreload 预加载甚至预解析:

<!-- preloads worker.js and its dependencies: -->
<link rel="modulepreload" href="worker.js">

<script>
  addEventListener('load', () => {
    // our worker code is likely already parsed and ready to execute!
    const worker = new Worker('worker.js', { type: 'module' });
  });
</script>

主线程和模块 worker 也可以使用预加载的模块。对于在两种上下文中导入的模块,或者无法提前知道某个模块将用在主线程还是工作器中时,这非常有用。

以前,用于预加载 Web 工作器脚本的选项有限,且不一定可靠。传统工作器有自己的“工作器”资源类型,可用于预加载,但没有浏览器实现 <link rel="preload" as="worker">。因此,可用于预加载 Web 工作器的主要方法是使用 <link rel="prefetch">,后者完全依赖于 HTTP 缓存。与正确的缓存标头结合使用时,可以避免工作器实例化必须等待下载工作器脚本。不过,与 modulepreload 不同,此技术不支持预加载依赖项或预解析。

共享工作器呢?

共享工作器已更新,从 Chrome 83 开始支持 JavaScript 模块。与专用工作器一样,使用 {type:"module"} 选项构建共享工作器现在会将工作器脚本作为模块(而不是传统脚本)进行加载:

const worker = new SharedWorker('/worker.js', {
  type: 'module'
});

在支持 JavaScript 模块之前,SharedWorker() 构造函数只需要一个网址和一个可选的 name 参数。这仍可用于传统共享工作器;但是,创建模块共享工作器需要使用新的 options 参数。可用选项与专用工作器的选项相同,包括取代之前的 name 参数的 name 选项。

Service Worker 呢?

Service Worker 规范已更新,以支持接受 JavaScript 模块作为入口点,并使用与模块工作器相同的 {type:"module"} 选项,但此更改尚未在浏览器中实现。一旦发生这种情况,您便可以通过以下代码,使用 JavaScript 模块实例化 Service Worker:

navigator.serviceWorker.register('/sw.js', {
  type: 'module'
});

由于该规范已更新,因此浏览器将开始实现新行为。这需要时间,因为将 JavaScript 模块引入 Service Worker 会带来一些额外的复杂问题。在确定是否触发更新时,Service Worker 注册需要将导入的脚本与其之前的缓存版本进行比较,并且在用于 Service Worker 时,需要为 JavaScript 模块实现此操作。此外,在某些情况下,在检查更新时,Service Worker 需要能够为脚本绕过缓存

其他资源和补充阅读材料