JavaScript Web Worker 和非主线程任务入门
浏览器中的 JavaScript 在设计上是单线程的,这意味着我们所有的 JavaScript 代码将共享同一个调用堆栈。乍一看,这似乎有点难以置信;我们一直使用Promises执行并发操作。然而,这种并发性(以及setTimeout
和setInterval
其他并发性)是使用事件循环实现的。
通常来说,这已经足够了,尤其是对于那些主要使用 HTTP 和服务器获取并显示数据,或接受输入并持久化数据的应用来说。然而,随着客户端应用变得越来越复杂,越来越像应用程序,我们倾向于在浏览器中运行越来越多的 JavaScript,这会给我们的单线程(或“主线程”)带来压力。幸运的是,我们有Web Workers通过在后台线程中运行 JavaScript 代码来帮助我们减轻主线程的压力!
什么是 Web Worker?
根据 MDN 的说法,Web Worker是一种让 Web 内容在后台线程中运行脚本的简单方法。它们不要与 Service Worker 混淆,后者负责代理应用程序的网络请求。Web Worker 的价值在于它支持并行性,使您的应用程序能够同时运行多个 JavaScript 执行上下文。
使用 Web Workers 时需要考虑几个重要的限制:
- Web Workers 在完全独立的 JavaScript 环境中执行,不与主线程共享内存,而是通过消息进行通信
- Workers 的全局作用域与主 JS 线程不同:没有
window
对象,因此没有 DOM,localStorage
等等 - 你的 worker 的实际 JS 代码必须位于单独的文件中(稍后会详细介绍)
尽管 Web Workers 的使用频率不高,但它已经存在很长时间了,并且得到了所有主流浏览器的支持,甚至可以追溯到 IE 10(来源)
关于并发与并行的简要说明
虽然并发和并行乍一看似乎是指代同一概念的两个术语,但它们并非一回事。简而言之,并发是指在多个任务上同时执行,且执行顺序不一;想象一下,你的应用程序会
fetch
多次调用,并在每个 Promise 解析后执行某个任务,但执行顺序不一定正确,也不会阻塞其余代码。而并行是指在多个 CPU 或多个 CPU 核心上同时执行多个任务。想了解更多关于并发的知识,请查看这篇很棒的 StackOverflow 文章,其中有几种不同的解释!
基本示例
好了,讲解得够多了,我们来看一些代码!要创建一个新的Worker
实例,必须使用构造函数,如下所示:
// main.js
const worker = new Worker('path/to/worker.js');
如上所述,此路径实际上必须指向与主 bundle 不同的 JavaScript 文件。因此,您可能需要配置 bundler 或构建链以处理 Web Workers。如果您使用的是Parcel,Web Workers 是开箱即用的!因此,在本文的其余部分,我们将使用 Parcel。使用 Parcel 时,您可以通过传递Worker 实际源代码的相对路径来构造 Worker 实例,如下所示:
// main.js
const worker = new Worker('./worker.js');
注意:如果您在 CodeSandbox 中使用 Parcel,则此功能目前不受支持。您可以克隆一个类似这样的Parcel 样板文件,或者自己创建一个,然后在本地进行实验。
这太棒了,因为现在我们可以在 Worker 代码中使用 NPM 模块和各种 ESNext 特性了,Parcel 会帮我们处理打包成独立 bundle 的任务!🎉
可惜的是,worker.js
它还不存在……让我们来创建它吧。以下是我们的 Web Worker 的最小样板:
// worker.js
function handleMessage(event) {
self.postMessage(`Hello, ${event.data}!`);
}
self.addEventListener('message', handleMessage);
注意,我们self
在这里使用 而不是window
。现在,让我们回到主脚本,通过向 Worker 发送消息并处理响应来测试它:
// main.js
const worker = new Worker('./worker.js');
function handleMessage(event) {
console.log(event.data);
}
worker.addEventListener('message', handleMessage);
worker.postMessage('Mehdi');
// Hello, Mehdi!
这应该够用了!这是使用 Web Worker 的最小设置。不过,“hello world”应用并不太占用 CPU……我们来看一个更具体的例子,看看 Web Worker 何时能派上用场。
弹力球示例
为了说明 Web Workers 的实用性,让我们使用一个执行效率极低的递归斐波那契数列计算器,如下所示:
// fib.js
function fib(position) {
if (position === 0) return 0;
if (position === 1) return 1;
return fib(position - 1) + fib(position - 2);
}
export default fib;
在我们的计算器中间,我们想要一个弹力球,就像这样:
弹跳动画循环进行requestAnimationFrame
,这意味着浏览器会每隔约 16 毫秒尝试绘制一次球。如果主线程 JavaScript 的执行时间超过这个时间,就会出现丢帧和视觉卡顿。在充满交互和动画的实际应用中,这种情况会非常明显!让我们尝试计算位置处的斐波那契数40
,看看会发生什么:
我们的动画在代码运行时至少会卡顿 1.2 秒!这不足为奇,因为递归fib
函数总共被调用了 331160281 次,而调用堆栈却从未被清除。另外需要注意的是,这完全取决于用户的 CPU。本次测试是在 2017 款 MacBook Pro 上进行的。将 CPU 节流设置为 6 倍速后,时间飙升至 12 秒以上。
让我们用 Web Worker 来解决这个问题。不过,与其postMessage
在应用程序代码中处理各种调用和事件监听,不如围绕 Web Worker 实现一个更友好的基于 Promise 的接口。
首先,让我们创建我们的工作者,我们称之为fib.worker.js
:
// fib.worker.js
import fib from './fib';
function handleMessage(event) {
const result = fib(event);
self.postMessage(result);
};
self.addEventListener('message', handleMessage);
这和我们之前的 Worker 示例类似,只是多了对函数的调用fib
。现在,让我们创建一个asyncFib
函数,它最终将接受一个位置参数,并返回一个 Promise,该 Promise 将解析为该位置的斐波那契数列。
// asyncFib.js
function asyncFib(pos) {
// We want a function that returns a Promise that resolves to the answer
return new Promise((resolve, reject) => {
// Instantiate the worker
const worker = new Worker('./fib.worker.js');
// ... do the work and eventually resolve
})
}
export default asyncFib;
我们知道需要处理来自 Worker 的消息才能获取fib
函数的返回值,所以让我们创建一个message
事件处理程序来捕获消息,并使用其中包含的数据来解析 Promise。我们还将在worker.terminate()
处理程序内部调用,这将销毁 Worker 实例以防止内存泄漏:
// asyncFib.js
function asyncFib(pos) {
return new Promise((resolve, reject) => {
const worker = new Worker('./fib.worker.js');
// Create our message event handler
function handleMessage(e) {
worker.terminate();
resolve(e.data);
}
// Mount message event handler
worker.addEventListener('message', handleMessage);
})
}
我们也来处理一下error
事件。如果 Worker 遇到错误,我们希望用 error 事件来拒绝 Promise。因为这是任务的另一个退出场景,所以我们也想worker.terminate()
在这里调用:
// asyncFib.js
function asyncFib(pos) {
return new Promise((resolve, reject) => {
const worker = new Worker('./fib.worker.js');
function handleMessage(e) {
worker.terminate();
resolve(e.data);
}
// Create our error event handler
function handleError(err) {
worker.terminate();
reject(err);
}
worker.addEventListener('message', handleMessage);
// Mount our error event listener
worker.addEventListener('error', handleError);
})
}
最后,让我们postMessage
使用pos
参数的值来调用以启动一切!
// asyncFib.js
function asyncFib(pos) {
return new Promise((resolve, reject) => {
const worker = new Worker('./fib.worker.js');
function handleMessage(e) {
worker.terminate();
resolve(e.data);
}
function handleError(err) {
worker.terminate();
reject(err);
}
worker.addEventListener('message', handleMessage);
worker.addEventListener('error', handleError);
// Post the message to the worker
worker.postMessage(pos);
})
}
这样就大功告成了。最后还有一件事要做:检查一下它是否正常工作。让我们看看我们的应用程序在40
使用新asyncFib
函数计算位置处的斐波那契数时是什么样子的:
好多了!我们成功解锁了主线程,让小球继续弹跳,同时还创建了一个美观的界面来操作我们的asyncFib
函数。
如果您好奇,请试用示例应用程序或查看 GitHub 上的代码。
总结
Web Worker API 是一款功能强大且尚未得到充分利用的工具,它有望成为未来前端开发的重要组成部分。如今,许多低端移动设备占据了 Web 用户的大部分份额,这些设备 CPU 速度较慢,但拥有多个核心,因此采用非主线程架构将大有裨益。我喜欢分享 Web Worker 的内容,撰写文章/发表演讲,如果您感兴趣,请在 Twitter 上关注我。
这里还有一些其他有用的资源,可以激发您的创造力:
感谢阅读!
链接地址:https://dev.to/mvasigh/getting-started-with-javascript-web-workers-and-off-main-thread-tasks-4029