使用模块 worker 编织 Web

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

JavaScript 是单线程的,这意味着它一次只能执行一个操作。这很直观,并且在 Web 上的许多情况下都运行良好,但是当我们需要执行诸如数据处理、解析、计算或分析之类的繁重任务时,可能会成为问题。随着越来越多的复杂应用程序在 Web 上交付,对多线程处理的需求也越来越高。

在 Web 平台上,用于线程和并行化的主要原语是 Web Workers API。Worker 是对 操作系统线程 的轻量级抽象,它公开了用于线程间通信的消息传递 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

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

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() 进行代码加载,但将模块包装在函数中以避免变量冲突并模拟依赖项导入和导出。

进入模块 worker

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

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

由于模块 worker 是标准的 JavaScript 模块,因此它们可以使用 import 和 export 语句。与所有 JavaScript 模块一样,依赖项在给定的上下文中(主线程、worker 等)仅执行一次,并且所有将来的导入都引用已执行的模块实例。浏览器还优化了 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 中不可用。将 worker 切换为使用 JavaScript 模块意味着所有代码都在 严格模式 下加载。另一个值得注意的变化是,JavaScript 模块的顶层作用域中 this 的值是 undefined,而在经典 worker 中,该值是 worker 的全局作用域。幸运的是,一直存在一个 self 全局变量,它提供了对全局作用域的引用。它在所有类型的 worker(包括 Service Worker)以及 DOM 中都可用。

使用 modulepreload 预加载 worker

模块 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 缓存。当与正确的缓存标头结合使用时,这使得可以避免 worker 实例化必须等待下载 worker 脚本。但是,与 modulepreload 不同,此技术不支持预加载依赖项或预解析。

共享 worker 呢?

共享 worker 已更新,自 Chrome 83 起支持 JavaScript 模块。与专用 worker 一样,使用 {type:"module"} 选项构造共享 worker 现在会将 worker 脚本作为模块而不是经典脚本加载

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

在支持 JavaScript 模块之前,SharedWorker() 构造函数仅需要一个 URL 和一个可选的 name 参数。这对于经典共享 worker 用法将继续有效;但是,创建模块共享 worker 需要使用新的 options 参数。可用选项 与专用 worker 的选项相同,包括取代以前的 name 参数的 name 选项。

Service Worker 呢?

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

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

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

其他资源和延伸阅读