使用模块 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 已在大多数浏览器中提供十多年。这意味着 worker 具有出色的浏览器支持并经过良好优化,但也意味着它们早于 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() 进行代码加载,但会将模块封装在函数中以避免变量冲突,并模拟依赖项导入和导出。

输入模块工作器

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 时,脚本会作为标准 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 脚本的选项有限,并且不一定可靠。传统工作器有自己的“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?

服务工件规范已更新,支持使用与模块 worker 相同的 {type:"module"} 选项接受 JavaScript 模块作为入口点,但此更改尚未在浏览器中实现。这样一来,您就可以使用以下代码使用 JavaScript 模块实例化服务工件了:

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

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

其他资源和延伸阅读