现在,借助 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 已在大多数浏览器中推出超过 10 年。虽然这意味着 Worker 具有出色的浏览器支持和良好的优化,但也意味着它们比 JavaScript 模块早得多。由于在设计工作器时没有模块系统,因此将代码加载到工作器中和编写脚本的 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
构造函数现在接受新的 {type:"module"}
选项,该选项会更改脚本加载和执行方式,以匹配 <script type="module">
。
const worker = new Worker('worker.js', {
type: 'module'
});
由于模块工作器是标准 JavaScript 模块,因此可以使用 import 和 export 语句。与所有 JavaScript 模块一样,依赖项在给定上下文(主线程、工作器等)中仅执行一次,并且所有未来的导入都会引用已执行的模块实例。浏览器还会优化 JavaScript 模块的加载和执行。模块的依赖项可以在模块执行之前加载,这样就可以并行加载整个模块树。模块加载还会缓存已解析的代码,这意味着在主线程和工作器中使用的模块只需解析一次。
迁移到 JavaScript 模块后,还可以使用动态导入来延迟加载代码,而不会阻塞工作线程的执行。与使用 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
全局变量,用于提供对全局范围的引用。它适用于所有类型的工作人员(包括服务工作者),也适用于 DOM。
使用 modulepreload
预加载工作器
模块工作器带来的一个显著性能改进是能够预加载工作器及其依赖项。借助模块工作器,脚本会作为标准 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
不同,此技术不支持预加载依赖项或预解析。
共享工作者呢?
自 Chrome 83 起,共享 worker 已更新,支持 JavaScript 模块。与专用工作器类似,现在使用 {type:"module"}
选项构建共享工作器会将工作器脚本作为模块而非经典脚本加载:
const worker = new SharedWorker('/worker.js', {
type: 'module'
});
在支持 JavaScript 模块之前,SharedWorker()
构造函数仅接受一个网址和一个可选的 name
实参。对于传统共享工作器用法,此方法将继续有效;不过,创建模块共享工作器需要使用新的 options
实参。可用选项与专用工作器的选项相同,包括取代之前 name
实参的 name
选项。
服务工作线程呢?
服务工作线程规范已更新,以支持接受 JavaScript 模块作为入口点,并使用与模块工作线程相同的 {type:"module"}
选项,但浏览器尚未实现此更改。之后,您就可以使用 JavaScript 模块实例化服务工作线程,代码如下:
navigator.serviceWorker.register('/sw.js', {
type: 'module'
});
规范更新后,浏览器开始实现新行为。 这需要时间,因为将 JavaScript 模块引入到 service worker 中会带来一些额外的复杂性。在确定是否触发更新时,Service worker 注册需要将导入的脚本与其之前的缓存版本进行比较,并且在使用 JavaScript 模块作为 service worker 时,需要实现此功能。此外,在检查更新时,服务工作线程需要在某些情况下能够绕过缓存来获取脚本。
其他资源和延伸阅读
- 功能状态、浏览器共识和标准化
- 添加了原始模块 worker 规范
- 适用于共享工作器的 JavaScript 模块
- 适用于服务工作线程的 JavaScript 模块:Chrome 实现状态