提升前端性能:使用Web Workers处理CPU密集型任务
作者选择将”Girls Who Code”作为”写作筹款计划”的捐赠对象。
介绍
由于 JavaScript 的执行是按顺序依次在单个线程中进行的,所以它通常被称为单线程语言。如果您在具有多个核心的设备上访问 Web 应用程序,JavaScript 只会使用一个核心。当一个任务在主线程上执行时,所有后续任务都必须等待该任务完成后才能执行。当任务所需时间较长时,它会阻塞主线程,从而阻止剩余的任务执行。大部分阻塞任务往往是占用 CPU 的任务,也称为 CPU 密集型任务,例如处理图形、数学计算以及视频或图像压缩等任务。
除了 CPU 绑定的任务外,您还有非阻塞的 I/O 绑定任务。这些 I/O 绑定任务大部分时间都用于向操作系统(OS)发出请求并等待响应。例如,Fetch API 向服务器发出的网络请求。当您使用 Fetch API 从服务器获取资源时,操作系统接管了任务,Fetch API 则等待操作系统的响应。在此期间,Fetch API 回调函数被转移到一个队列中,等待操作系统的响应,从而释放主线程,并允许它执行其他后续任务。一旦收到响应,与 Fetch API 调用相关联的回调函数就会在主线程上执行。因为 I/O 绑定任务的性能取决于操作系统完成任务所需的时间,所以大多数 I/O 绑定任务(例如 Fetch)实现了 Promise,定义了在 Promise 解析时应该运行的函数;也就是说,当操作系统完成任务并返回响应时。
相比之下,与等待操作系统的 I/O 限制任务不同,具有 CPU 限制的任务不会闲置。CPU 限制任务会持有 CPU 直到任务完成,从而阻塞主线程。即使将它们封装在 Promise 中,它们仍然会阻塞主线程。此外,当主线程被阻塞时,用户可能会注意到 Web 应用的用户界面(UI)可能会冻结,任何使用 JavaScript 的功能可能无法正常工作。
作为解决这个问题的一个方法,浏览器引入了 Web Workers API,在浏览器中提供多线程支持。通过 Web Workers,您可以将 CPU 密集型任务分配给另一个线程,从而释放主线程。主线程在一个设备核心上执行 JavaScript 代码,而分配的任务在另一个核心上执行。这两个线程可以通过消息传递进行通信和共享数据。
在本教程中,你将创建一个耗用 CPU 资源的任务,阻塞浏览器的主线程,并观察它对 Web 应用的影响。然后,你将尝试使用 Promises 使这个耗用 CPU 资源的任务变为非阻塞的,但未成功。最后,你将创建一个 Web Worker,将耗用 CPU 资源的任务转移到另一个线程中,以防止它阻塞主线程。
先决条件
为了跟随这个教程,你将需要:
- 一台安装了现代浏览器的双核或多核计算机。
- 你的系统上有一个本地 Node.js 环境,你可以通过《如何在 Ubuntu 22.04 上安装 Node.js》来设置。在其他操作系统上,请按照《如何安装 Node.js 并创建本地开发环境》中的相应指南进行操作。
- 了解事件循环、回调和 Promises 的知识,你可以通过阅读《理解 JavaScript 中的事件循环、回调、Promises 和 Async/Await》来学习。
- 你还需要 HTML、CSS 和 JavaScript 的基础知识,你可以在我们的《如何使用 HTML 构建网站》系列、《如何使用 CSS 构建网站》系列以及《如何使用 JavaScript 编程》中找到。
第一步 – 创建一个不需要 Web Workers 的 CPU 密集型任务
在这一步中,您将创建一个具有阻塞 CPU 绑定任务和非阻塞任务的 Web 应用程序。该应用程序将具有三个按钮。第一个按钮将启动阻塞任务,即一个循环,迭代大约五十亿次。第二个按钮将增加网页上的一个值,第三个按钮将改变 Web 应用程序的背景颜色。用于增加和改变背景的按钮是非阻塞的。
首先,使用 mkdir 命令创建项目目录。
- mkdir workers_demo
用 cd 命令进入目录。
- cd workers_demo
使用 nano 或你最喜欢的文本编辑器,创建一个 index.html 文件。
- nano index.html
在你的 index.html 文件中,添加以下代码来创建按钮和元素,用于显示输出:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Web Workers示例</title>
<link rel="stylesheet" href="main.css" />
</head>
<body>
<div class="wrapper">
<div class="total-count"></div>
<div class="buttons">
<button class="btn btn-blocking" id="blockbtn">阻塞任务</button>
<button class="btn btn-nonblocking" id="incrementbtn">递增计数</button>
<button class="btn btn-nonblocking" id="changebtn">
更改背景
</button>
</div>
<div class="output"></div>
</div>
<script src="main.js"></script>
</body>
</html>
在头部部分,您引用了main.css样式表,其中包含了应用程序的样式。在body标签中,您创建了一个class为total-count的div元素,它将包含一个在点击按钮时递增的值。接下来,您创建了另一个div元素,它有三个按钮元素作为子元素。第一个按钮将启动一个占用CPU资源的任务,该任务是阻塞的。第二个按钮将递增class为total-count的div元素中的值,第三个按钮将触发JavaScript代码来改变背景颜色。这两个任务是非阻塞的。
下一个div元素将包含来自CPU密集型任务的输出,并且最后在body标签结束之前,您会引用main.js文件,其中将包含所有的JavaScript代码。
您可能注意到这些元素都有ID和类。在后面的步骤中,您将使用它们来在JavaScript中引用这些元素。
现在保存并退出文件。
创建并打开 main.css 文件。
- nano main.css
在您的main.css文件中,添加以下内容来样式化元素:
这是文章《如何使用Web Workers处理CPU密集型任务》的第3部分(共15部分)。
body {
background: #fff;
font-size: 16px;
}
.wrapper {
max-width: 600px;
margin: 0 auto;
}
.total-count {
margin-bottom: 34px;
font-size: 32px;
text-align: center;
}
.buttons {
border: 1px solid green;
padding: 1rem;
margin-bottom: 16px;
}
.btn {
border: 0;
padding: 1rem;
}
.btn-blocking {
background-color: #f44336;
color: #fff;
}
#changebtn {
background-color: #4caf50;
color: #fff;
}
.buttons定义了实心绿边框和轻微的内边距,而阻止任务进程又进一步通过.btn-blocking样式定义了不同的背景颜色。
保存并关闭文件。
既然你已经定义了CSS样式,现在你需要编写JavaScript代码来使HTML元素交互。保存并退出你的文件。
在你的编辑器中创建并打开main.js文件。
- nano main.js
在你的main.js文件中,添加以下代码以引用DOM元素:
这是文章《如何使用Web Workers处理CPU密集型任务》的第4部分(共15部分)。
const blockingBtn = document.getElementById("blockbtn");
const incrementBtn = document.getElementById("incrementbtn");
const changeColorBtn = document.getElementById("changebtn");
const output = document.querySelector(".output");
const totalCountEl = document.querySelector(".total-count");
在前三行代码中,我们使用document对象的getElementById()方法通过ID引用了按钮元素。在最后两行中,我们使用document对象的querySelector()方法通过类名引用了div元素。
接下来,我们将定义一个事件监听器,当点击incrementBtn按钮时,它会增加一个div元素的值。
...
totalCountEl.textContent = 0;
incrementBtn.addEventListener("click", function incrementValue() {
let counter = totalCountEl.textContent;
counter++;
totalCountEl.textContent = counter;
});
首先,我们将totalCountEl元素的文本内容设置为0。然后,我们使用DOM的addEventListener()方法将一个事件监听器附加到incrementBtn按钮上。该方法接受两个参数:要监听的事件和一个回调函数。在这里,事件监听器监听点击事件,并在点击事件触发时调用incrementValue()回调函数。
在incrementValue回调函数中,我们从DOM中获取totalCountEl元素的文本内容值,并将其赋值给计数器变量。然后,我们将该值增加1,并将增加后的值设置为totalCountEl元素的文本内容。
接下来,我们将以下代码添加到changeColorBtn按钮上,以便在点击按钮时随机更改背景颜色。
这是文章《如何使用Web Workers处理CPU密集型任务》的第5部分(共15部分)。
...
changeColorBtn.addEventListener("click", function changeBackgroundColor() {
colors = ["#009688", "#ffc107", "#dadada"];
const randomIndex = Math.floor(Math.random() * colors.length)
const randomColor = colors[randomIndex];
document.body.style.background = randomColor;
});
在先前的代码中,您添加了一个点击事件监听器,当用户点击changeColorBtn按钮时会运行changeBackgroundColor回调函数。在回调函数中,您将colors变量设置为一个包含三个十六进制颜色值的数组。然后您调用了Math.random()方法,并将其结果与数组长度值相乘,以生成一个0到数组长度3之间的随机数。随机值然后使用Math.floor()方法向下取整,并存储在randomIndex变量中。
然后,您使用随机索引从数组中选择一个值,然后将文档对象的body.style.background属性设置为该颜色。
现在您已经实现了两个触发非阻塞任务执行的按钮,您将为剩下的按钮添加一个事件监听器,以启动一个占用CPU资源较多的任务。循环将重复五十亿次,并将结果保存在DOM中。
仍然在您的main.js文件中,添加以下代码来为一个按钮添加点击事件监听器,该按钮将启动一个阻塞任务。
...
blockingBtn.addEventListener("click", function blockMainThread() {
let counter = 0;
for (let i = 0; i < 5_000_000_000; i++) {
counter++;
}
output.textContent = `计算结果: ${counter}`;
});
在前面的代码中,您添加了一个点击事件监听器,它运行blockMainThread()回调函数。在函数内部,您将计数器设置为0,然后创建了一个循环,循环迭代五十亿次。在每次迭代期间,计数器的值增加1。循环结束后,计算的结果被设置为输出元素的值。
现在完整的文件将与以下内容相匹配。
这是文章《如何使用Web Workers处理CPU密集型任务》的第6部分(共15部分)。
const blockingBtn = document.getElementById("blockbtn");
const incrementBtn = document.getElementById("incrementbtn");
const changeColorBtn = document.getElementById("changebtn");
const output = document.querySelector(".output");
const totalCountEl = document.querySelector(".total-count");
totalCountEl.textContent = 0;
incrementBtn.addEventListener("click", function incrementValue() {
let counter = totalCountEl.textContent;
counter++;
totalCountEl.textContent = counter;
});
changeColorBtn.addEventListener("click", function changeBackgroundColor() {
colors = ["#009688", "#ffc107", "#dadada"];
const randomIndex = Math.floor(Math.random() * colors.length)
const randomColor = colors[randomIndex];
document.body.style.background = randomColor;
});
blockingBtn.addEventListener("click", function blockMainThread() {
let counter = 0;
for (let i = 0; i < 5_000_000_000; i++) {
counter++;
}
output.textContent = `Result: ${counter}`;
});
一旦您输入代码完成,保存并退出文件。
为了避免在第三步开始使用Web Workers时出现跨域资源共享(CORS)错误,您需要为应用程序创建一个Web服务器。运行以下命令创建服务器:
- npx serve .
输入y以确认,控制台将输出”正在提供服务!”的消息来确认服务器正在运行。
┌─────────────────────────────────────────────────────┐
│ │
│ 服务已启动! │
│ │
│ - 本地: http://localhost:3000 │
│ - 在您的网络中: http://your_ip_address:3000 │
│ │
│ 已将本地地址复制到剪贴板! │
│ │
└─────────────────────────────────────────────────────┘
打开您偏爱的网络浏览器,访问 http://localhost:3000/index.html。
注意:如果您正在远程服务器上进行教程,您可以使用端口转发在浏览器中查看index.html文件。
在当前终端中,输入以下命令启动一个web服务器:
npx serve
在提示时,输入y以继续。
您的控制台可能会加载以下错误,但这不会影响您访问web服务器的能力:
ERROR:无法将服务器地址复制到剪贴板:找不到"xsel"二进制文件,且回退操作无效。在Debian/Ubuntu上,您可以使用以下命令安装xsel:sudo apt install xsel。
┌─────────────────────────────────────────────────────┐
│ │
│ 服务已启动! │
│ │
│ - 本地: http://localhost:3000 │
│ - 在您的网络中: http://your_ip_address:3000 │
│ │
│ 已将本地地址复制到剪贴板! │
│ │
└─────────────────────────────────────────────────────┘
在本地机器上打开第二个终端,然后输入以下命令:
ssh -L 3000:localhost:3000 your_non_root_user@your_server_ip
返回浏览器并访问http://localhost:3000/index.html以访问您的应用程序主页。当页面加载时,它将显示一个带有”阻止任务”、”递增”和”改变背景”按钮的主页。递增计数器将从0开始,因为您还没有按下递增计数的按钮。

首先,多次点击增加按钮,以便在每次点击时更新页面上的数字。

其次,点击几次”更改背景”按钮来改变页面的背景颜色。

最后,点击阻塞任务按钮,然后随机点击增加按钮和更改背景按钮。页面将变得无响应,按钮将无效。这个冻结是因为阻塞任务按钮开始了一个消耗CPU的任务,导致主线程被阻塞,其他代码直到主线程空闲才会执行。过一段时间后,当CPU密集任务完成时,页面将显示结果:5000000000。此时,如果点击其他按钮,它们将恢复正常工作。
正如您所经历的,一个阻塞任务对用户来说是立即可见的,并且会损害您的应用用户体验。
现在您已经创建了一个通过主线程冻结应用程序的阻塞任务的应用程序,您将使用 Promise 将 CPU 密集型任务转换为非阻塞任务。
第二步 – 使用Promise来卸载CPU密集型任务
使用Fetch API或其他基于Promise的方法处理I/O任务有时会给人一种错误的印象,即将一个CPU密集型任务包装在Promise中可以使其变为非阻塞。如介绍所述,I/O任务之所以是非阻塞的,是因为它们由操作系统处理,操作系统在完成任务时会通知JavaScript引擎。当操作系统执行I/O任务时,与I/O任务相关的回调会在一个队列中等待操作系统的响应。在它们等待的同时,主线程可以处理所有后续任务。当操作系统发送响应时,回调在主线程中执行,回调之间没有并行执行。
为了证明Promise不能使CPU绑定任务变为非阻塞状态,您将在此步骤中将CPU密集型任务包装在一个Promise中。
在您的文本编辑器中,打开main.js文件:
nano main.js
在您的main.js文件中,添加这段代码来创建一个calculateCount()函数,它将一个耗费大量CPU的任务封装在一个Promise中。
这是文章《如何使用Web Workers处理CPU密集型任务》的第8部分(共15部分)。
...
function calculateCount() {
return new Promise((resolve, reject) => {
let counter = 0;
for (let i = 0; i < 5_000_000_000; i++) {
counter++;
}
resolve(counter);
});
}
blockingBtn.addEventListener("click", function blockMainThread(){
....
})
calculateCount()
函数返回一个Promise。在这个函数中,使用new Promise
语法初始化了一个promise,该promise接受一个回调函数作为参数,该回调函数包含resolve
和reject
参数。这些参数处理回调中操作的成功或失败。回调中包含了一个耗费CPU的循环,循环迭代五十亿次。循环结束后,使用resolve
方法传递结果。
现在在calculateCount()
函数中有一个受CPU限制的任务,需要删除高亮显示的代码。
...
blockingBtn.addEventListener("click", function blockMainThread() {
let counter = 0;
for (let i = 0; i < 5_000_000_000; i++) {
counter++;
}
output.textContent = `结果: ${counter}`;
});
在移除了代码之后,将在blockMainThread()
函数中调用calculateCount()
函数。由于该函数返回一个promise,需要使用async/await语法来消耗这个promise。
将下面的高亮代码添加到blockMainThread()
函数中,使其异步并调用calculateCount()
函数。
...
blockingBtn.addEventListener("click", async function blockMainThread() {
const counter = await calculateCount();
output.textContent = `结果: ${counter}`;
});
在上述JavaScript代码中,我们在blockMainThread()
函数前添加了async
关键字,使其成为一个异步函数。这种方法是JavaScript异步编程的重要技术,可以有效处理CPU密集型任务而不阻塞主线程。
在函数内部,我们使用await
关键字来调用calculateCount()
函数。await
操作符会暂停函数执行,直到Promise对象解析完成。这种异步处理方式是Web Workers性能优化的关键策略之一。
一旦Promise解析完成,计数器变量将被设置为返回值,输出div元素将显示CPU密集型任务的计算结果。这种异步编程模式可以显著提升Web应用的响应速度和用户体验。
现在,您的完整文件将与以下内容匹配:
const blockingBtn = document.getElementById("blockbtn");
const incrementBtn = document.getElementById("incrementbtn");
const changeColorBtn = document.getElementById("changebtn");
const output = document.querySelector(".output");
const totalCountEl = document.querySelector(".total-count");
totalCountEl.textContent = 0;
incrementBtn.addEventListener("click", function incrementValue() {
let counter = totalCountEl.textContent;
counter++;
totalCountEl.textContent = counter;
});
changeColorBtn.addEventListener("click", function changeBackgroundColor() {
colors = ["#009688", "#ffc107", "#dadada"];
const randomIndex = Math.floor(Math.random() * colors.length)
const randomColor = colors[randomIndex];
document.body.style.background = randomColor;
});
function calculateCount() {
return new Promise((resolve, reject) => {
let counter = 0;
for (let i = 0; i < 5_000_000_000; i++) {
counter++;
}
resolve(counter);
});
}
blockingBtn.addEventListener("click", async function blockMainThread() {
const counter = await calculateCount();
output.textContent = `Result: ${counter}`;
});
完成更改后,保存并退出您的文件。
当您的服务器仍在运行时,在浏览器中刷新http://localhost:3000/index.html。点击增加和更改背景的按钮。之后,点击阻塞任务按钮,然后点击其他按钮。当运行一个占用CPU的任务时,其他按钮仍然无响应,这证明将一个占用CPU的任务包装在一个Promise中并不会使任务非阻塞。
既然你已经尝试过使用承诺(promises)来减轻CPU负载并注意到其失败,那么你将使用Web Workers使CPU密集型任务变得非阻塞。
第三步 – 使用Web Workers来卸载CPU密集型任务
在这一步中,您将创建一个专用工作线程,通过将CPU密集型任务移入一个worker.js文件来卸载这些任务。在main.js文件中,您将使用worker.js文件的路径来实例化一个专用的Web Worker。一旦Web Worker被初始化,CPU密集型任务将会被卸载到一个独立线程,而主线程将会自由地处理剩下的任务。
首先,创建一个worker.js文件。
- nano worker.js
在您的worker.js文件中,添加以下代码以在文件中添加CPU密集型任务。
let counter = 0;
for (let i = 0; i < 5_000_000_000; i++) {
counter++;
}
前面的代码块包含了迄今为止您一直在使用的CPU密集型任务。现在这段代码将在一个单独的线程中运行。
为了确保主线程可以获取计算结果,您需要使用Worker接口的postMessage()方法发送包含数据的消息。
在您的worker.js文件中,添加突出显示的行以将数据发送到主线程。
let counter = 0;
for (let i = 0; i < 5_000_000_000; i++) {
counter++;
}
postMessage(counter);
在此行代码中,您使用包含CPU密集型任务计算结果的counter变量调用postMessage()方法。
保存并关闭文件。
既然您已将CPU密集型任务转移到了worker.js文件中,现在打开main.js文件。
- nano main.js
请删除main.js文件中包含CPU密集型任务的突出显示行。
...
function calculateCount() {
return new Promise((resolve, reject) => {
let counter = 0;
for (let i = 0; i < 5_000_000_000; i++) {
counter++;
}
resolve(counter);
});
}
blockingBtn.addEventListener("click", async function blockMainThread() {
const counter = await calculateCount();
output.textContent = `结果: ${counter}`;
});
在blockMainThread
回调中,添加以下突出显示的代码来初始化工作线程并监听工作线程的消息。
blockingBtn.addEventListener("click", function blockMainThread() {
const worker = new Worker("worker.js");
worker.onmessage = (msg) => {
output.textContent = `结果: ${msg.data}`;
};
});
首先,您使用之前创建的worker.js文件的路径创建了一个Worker实例。其次,您将Worker接口的onmessage属性附加到工作线程上,以便监听来自工作线程的任何消息。如果有消息进来,就会触发消息事件,调用回调函数并将消息数据msg作为参数传递。在回调函数中,您使用来自Web Worker的消息修改输出文本的内容。
完整的文件现在将与以下代码块相匹配。
const blockingBtn = document.getElementById("blockbtn");
const incrementBtn = document.getElementById("incrementbtn");
const changeColorBtn = document.getElementById("changebtn");
const output = document.querySelector(".output");
const totalCountEl = document.querySelector(".total-count");
totalCountEl.textContent = 0;
incrementBtn.addEventListener("click", function incrementValue() {
let counter = totalCountEl.textContent;
counter++;
totalCountEl.textContent = counter;
});
changeColorBtn.addEventListener("click", function changeBackgroundColor() {
colors = ["#009688", "#ffc107", "#dadada"];
const randomIndex = Math.floor(Math.random() * colors.length)
const randomColor = colors[randomIndex];
document.body.style.background = randomColor;
});
blockingBtn.addEventListener("click", function blockMainThread() {
const worker = new Worker("worker.js");
worker.onmessage = (msg) => {
output.textContent = `结果:${msg.data}`;
};
});
保存并退出文件。
在服务器运行时,返回到您的网络浏览器并访问http://localhost:3000/index.html。页面将成功从服务器加载。
首先,点击递增和更改背景按钮几次。其次,点击阻塞任务按钮来启动需要大量CPU资源的任务,然后继续点击其他的按钮。尽管CPU密集型任务仍在执行,但这些按钮现在能正常工作。
您现在可以使用一个专门的Web Worker来卸载一个CPU密集型任务,使之成为非阻塞的。
总结
在这个教程中,您创建了一个启动CPU密集型任务并阻塞主线程的应用程序,并尝试使用Promise使CPU密集型任务非阻塞,但未能成功。最后,您使用了一个专用的Web Worker将CPU密集型任务转移到另一个线程,使其变得非阻塞。
作为下一步,您可以访问Web Workers API文档,全面了解专用Web Workers。除了专用Web Workers,Web Workers API还包含共享Web Workers(Shared Workers)和服务Web Workers(Service Workers),可以用于提供离线访问和提升性能。
如果您使用Node.js,您可以学习如何在《如何使用Node.js进行多线程编程》中使用工作线程。