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

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

JavaScript 是单线程的,这意味着它一次只能执行一项操作。这种方法直观易用,适用于 Web 上的许多场景,但当我们需要执行数据处理、解析、计算或分析等繁重任务时,可能会遇到问题。随着越来越多的复杂应用在 Web 上交付,对多线程处理的需求也越来越大。

在 Web 平台上,线程处理和并行处理的主要基元是 Web Workers API。Worker 是基于操作系统线程的轻量级抽象,可公开消息传递 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 模块。由于在设计工作器时没有模块系统,因此用于将代码加载到工作器和组合脚本的 API 与 2009 年常见的同步脚本加载方法类似。

历史记录:传统版工作器

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

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

您可以在 Web Worker 中使用 importScripts() 函数加载其他代码,但它会暂停 worker 的执行,以提取和评估每个脚本。它还会像传统的 <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 工作器。例如,webpack 等打包工具会将一个小型模块加载器实现嵌入到生成的代码中,该代码使用 importScripts() 进行代码加载,但会将模块封装在函数中以避免变量冲突并模拟依赖项导入和导出。

输入模块 worker

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

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

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

改用 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;
}

为确保出色的性能,模块 worker 中不提供旧版 importScripts() 方法。将工作器切换为使用 JavaScript 模块意味着所有代码均在严格模式下加载。另一个值得注意的变化是,JavaScript 模块的顶级作用域中的 this 值为 undefined,而在传统工作器中,该值为工作器的全局作用域。幸运的是,一直存在提供对全局范围的引用的 self 全局变量。它适用于所有类型的工作器(包括服务工作器),以及 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 Worker 脚本的选项有限,并且不一定可靠。传统工作器有自己的用于预加载的“工作器”资源类型,但没有浏览器实现 <link rel="preload" as="worker">。因此,用于预加载 Web Worker 的主要技术是使用 <link rel="prefetch">,该技术完全依赖于 HTTP 缓存。与正确的缓存标头结合使用时,这可以避免工作器实例化必须等待下载工作器脚本。不过,与 modulepreload 不同,此技术不支持预加载依赖项或预解析。

共享 Worker 怎么样?

从 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 模块实例化服务工件:

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

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

其他资源和延伸阅读