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

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

JavaScript 是单线程的,这意味着它一次只能执行一项操作。这种方式很直观,在 Web 上的许多情况下都能很好地运行,但当我们需要执行数据处理、解析、计算或分析等繁重任务时,可能会出现问题。随着越来越多复杂的应用在 Web 上交付,对多线程处理的需求也越来越大。

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

下面是 worker 用法的典型示例,其中 worker 脚本会监听来自主线程的消息,并通过发回自己的消息进行响应:

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 模块早得多。由于在设计 worker 时没有模块系统,因此用于将代码加载到 worker 并编写脚本的 API 仍然类似于 2009 年常见的同步脚本加载方法。

历史记录:传统工作器

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 Worker。例如,webpack 等打包器会将一个小型模块加载器实现嵌入到生成的代码中,该代码使用 importScripts() 进行代码加载,但会将模块封装在函数中,以避免变量冲突并模拟依赖项导入和导出。

输入模块工作器

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

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

由于模块 worker 是标准 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,而在传统 worker 中,该值为 worker 的全局作用域。幸运的是,一直有一个 self 全局变量,它提供了对全局作用域的引用。它适用于所有类型的工作器(包括 Service Worker)以及 DOM。

使用 modulepreload 预加载工作器

模块 worker 带来的一项重大性能改进是能够预加载 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 也可以使用预加载的模块。这对于在两种上下文中都导入的模块,或者在无法提前知道模块是在主线程还是在 worker 中使用的情况下非常有用。

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

Shared Worker 呢?

自 Chrome 83 起,Shared Worker 已更新,支持 JavaScript 模块。与专用工作器一样,现在使用 {type:"module"} 选项构建共享工作器会将工作器脚本作为模块(而非传统脚本)加载:

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

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

Service Worker 呢?

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

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

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

其他资源和补充阅读材料