现在,使用 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 模块用于服务工件时,需要为其实现此操作。此外,在某些情况下,服务工件在检查更新时需要能够绕过脚本缓存。
其他资源和延伸阅读
- 功能状态、浏览器共识和标准化
- 添加了原始模块工作器规范
- 适用于共享工作器的 JavaScript 模块
- 适用于服务工件的 JavaScript 模块:Chrome 实现状态