Web Worker 是一种在后台线程中执行 JavaScript 代码的方式,它可以让网页在不阻塞主线程的情况下执行复杂或耗时的任务。主线程(通常是 UI 线程)负责用户交互和界面更新,而 Web Worker 则在独立的线程中处理计算密集型或需要大量时间的操作,从而提高应用的响应能力。
发展历史
在之前的 js 版本中,如果一个 js 文件执行时间过长,会导致页面卡顿,为了解决这个问题,就有了 web worker。
我们先来看一下没有 web worker 之前,js 是如何执行的。
┌─────────────────────────────────────────────────────────────────┐
│ 主线程 (UI 线程) │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 处理用户交互 │ ──► │ 执行复杂计算 │ ──► │ 更新 DOM │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 处理事件队列 │ ◄── │ 【UI 冻结/卡顿】 │ ◄── │ 渲染页面 │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 继续执行其他任务 │
└─────────────────────────────────────────────────────────────────┘
在上面的内容中,所有操作都是在同一个线程中顺序执行,当遇到了复杂计算会阻塞 UI 更新和用户交互,导致页面卡顿。,这就导致用户体验很差。
为了解决这个问题,就有了 web worker。
web worker 是运行在后台线程中的 JavaScript 代码,它可以让网页在不阻塞主线程的情况下执行复杂或耗时的任务。主线程(通常是 UI 线程)负责用户交互和界面更新,而 Web Worker 则在独立的线程中处理计算密集型或需要大量时间的操作,从而提高应用的响应能力。
┌─────────────────────────────────────────────────────────────────┐
│ 主线程 (UI 线程) │
└─────────────────────────────────────────────────────────────────┘
│ │
▼ │
┌─────────────────┐ ┌─────────────────┐ │
│ 处理用户交互 │ ──► │ 创建 Worker │ │
└─────────────────┘ └─────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ 发送数据到Worker │ │
│ └─────────────────┘ │
│ │ │
▼ │ ▼
┌─────────────────┐ │ ┌─────────────────┐
│ 继续处理 UI │ │ │ 接收 Worker │
│ 事件和交互 │◄────────────┘ │ 处理结果 │
└─────────────────┘ └─────────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ 更新 DOM │ ◄───────────────────── │ 处理结果数据 │
└─────────────────┘ └─────────────────┘
│
▼
┌─────────────────┐
│ 渲染页面 │
└─────────────────┘
┌─────────────────────────────┐
│ Worker 线程 │
└─────────────────────────────┘
│
▼
┌─────────────────────────────┐
│ 接收主线程数据 │
└─────────────────────────────┘
│
▼
┌─────────────────────────────┐
│ 执行复杂计算 │
└─────────────────────────────┘
│
▼
┌─────────────────────────────┐
│ 返回结果到主线程 │
└─────────────────────────────┘
在 web worker 中,主线程负责处理用户交互和界面更新,而 web worker 则在独立的线程中处理计算密集型或需要大量时间的操作,从而提高应用的响应能力。
web worker 的基本使用
首先,你需要通过 Worker 构造函数来创建一个新的 Worker 实例。如下代码所示:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<script>
const worker = new Worker("worker.js");
worker.onmessage = function (e) {
console.log("从 worker 线程收到的消息:", e.data);
};
worker.postMessage("Hello, Worker!");
</script>
</body>
</html>
在上述代码中,我们创建了一个新的 Worker 实例,并给它传递了一个 worker.js 文件。
在 worker.js 文件中,我们定义了一个 onmessage 事件处理程序,当主线程发送消息时,它会触发这个事件处理程序。
// worker.js
onmessage = function (e) {
console.log("从主线程收到的消息:", e.data); // 输出主线程发来的数据
postMessage("Hello, Main Thread!"); // 返回消息给主线程
};
在上述代码中,我们定义了一个 onmessage 事件处理程序,当主线程发送消息时,它会触发这个事件处理程序。
在主线程中,我们创建了一个新的 Worker 实例,并给它传递了一个 worker.js 文件。
最终的输出结果如下图所示:
在上面的代码中,我们创建了一个新的 Worker 实例,并给它传递了一个 worker.js 文件。并通过 worker.postMessage 方法发送消息给 worker.js 文件。
在 worker.js 文件中,我们定义了一个 onmessage 事件处理程序,当主线程发送消息时,它会触发这个事件处理程序。
在 worker.js 文件中,我们通过 postMessage 方法返回消息给主线程。
如果 Worker 的任务完成,或者我们之后不再需要它时,可以使用 worker.terminate()
方法来终止 Worker 线程。
const worker = new Worker("worker.js");
worker.onmessage = function (e) {
console.log("从 worker 线程收到的消息:", e.data);
// 在接收到消息后再终止 Worker
worker.terminate();
// 发送第二条消息,此时 Worker 已终止,不会再处理
worker.postMessage("Hello, Moment!");
};
worker.postMessage("Hello, Worker!");
最终的输出结果如下图所示:
从上图可以看出,当 Worker 线程收到消息后,会先处理消息,然后终止 Worker 线程,最后再发送第二条消息,此时 Worker 线程已终止,不会再处理。
web worker 的限制
Web Worker 是一种非常强大的工具,可以将耗时的计算任务从主线程中分离出来,保持页面的响应性。然而,Web Worker 也有一些限制和约束,需要在使用时了解清楚。以下是 Web Worker 的主要限制。
无法直接访问 DOM
Web Worker 运行在主线程的独立线程中,它没有访问主线程中的 DOM 或者浏览器的 UI 的能力。Worker 中的 JavaScript 代码只能进行计算和数据处理,不能直接更新页面元素。
例如,如果你尝试在 Work 中访问 DOM,会导致错误:
// 在 Worker 中是不能访问 DOM 的
document.getElementById("moment"); // 这会抛出错误
如下结果所示:
无法直接访问 window 对象
Web Worker 运行在一个完全独立的线程中,它没有 window 对象,因此无法访问主线程中的 window、document、localStorage、sessionStorage 等 Web API。
console.log(window);
如下结果所示:
Web Worker 错误处理
Web Worker 线程中的 JavaScript 错误不会直接传播到主线程,因此我们需要明确地设置错误处理机制。这通常通过以下几种方式来实现:
-
捕获 Worker 线程的错误(onerror 事件)
-
使用 try...catch 捕获 Worker 内部的错误
-
在主线程中处理 Worker 的异常
捕获 Worker 线程的错误:onerror 事件
当 Worker 中发生错误时,你可以通过在 Worker 实例上设置 onerror 事件处理器来捕获这些错误。
// 创建 Worker
const worker = new Worker("worker.js");
// 设置 onerror 事件处理器
worker.onerror = function (errorEvent) {
console.error("Worker 错误:", errorEvent.message);
console.error(
"错误发生在行号:",
errorEvent.lineno,
"列号:",
errorEvent.colno
);
console.error("错误发生的文件:", errorEvent.filename);
};
// 向 Worker 发送消息
worker.postMessage("start");
onerror 事件的属性: message:错误消息。
-
filename:错误发生的脚本文件。
-
lineno:发生错误的行号。
-
colno:发生错误的列号。
-
error:捕获的 Error 对象。
onerror 事件会捕获 Worker 中的任何未处理错误,并提供详细的错误信息。
使用 try...catch 捕获 Worker 内部的错误
虽然 onerror 事件可以捕获未处理的错误,但你也可以在 Worker 内部使用 try...catch 语句来处理已知的错误。这样你可以通过编程方式捕获并处理异常,而不是让它们传播到主线程。
// worker.js
onmessage = function (e) {
try {
// 假设这里有可能会抛出错误的代码
if (e.data === "error") {
throw new Error("An intentional error occurred");
}
postMessage("成功处理消息");
} catch (error) {
// 捕获并处理错误,防止它传播到主线程
postMessage("错误:" + error.message);
}
};
在这种情况下,Worker 会在发生错误时捕获异常并将错误信息通过 postMessage() 返回给主线程,而不是触发 onerror 事件。
在主线程中处理 Worker 的异常
当 Worker 抛出错误时,主线程通过 onerror 事件或 onmessage 事件接收消息。在 onerror 事件中,你可以捕获 Worker 线程的错误并采取适当的措施。
// 主线程
const worker = new Worker("worker.js");
// 设置 onerror 事件处理器
worker.onerror = function (e) {
console.log("捕获到 Worker 错误:", e.message);
console.log("错误发生的脚本:", e.filename);
console.log("行号:", e.lineno, "列号:", e.colno);
};
// 向 Worker 发送消息
worker.postMessage("error"); // 发送会触发错误的消息
如果在 Worker 中发生了错误,onerror 事件会被触发,并且你可以通过 e.message 获取错误信息。
在 Worker 线程中使用 throw 抛出自定义错误
你还可以在 Worker 中使用 throw 关键字抛出自定义错误,并在主线程捕获这些错误。
// worker.js
onmessage = function () {
throw new Error("Custom error message");
};
主线程:
const worker = new Worker("worker.js");
worker.onerror = function (e) {
console.log("捕获到 Worker 错误:", e.message);
};
worker.postMessage("trigger error"); // 触发错误
小结
Web Worker 错误处理主要通过以下方式进行:
-
使用 onerror 捕获 Worker 错误:当 Worker 中发生未处理的错误时,通过 onerror 捕获。
-
在 Worker 内部使用 try...catch:可以在 Worker 线程内部通过 try...catch 捕获并处理错误,避免错误传播到主线程。
-
通过 postMessage 传递错误信息:Worker 可以通过 postMessage 向主线程报告错误。
-
抛出自定义错误:在 Worker 线程中使用 throw 关键字抛出自定义错误,主线程可以捕获这些错误。
使用这些错误处理机制,你可以在 Worker 中处理异常情况,并确保主线程能够正确捕获并响应 Worker 线程的错误,从而提高应用的健壮性和用户体验。
Web Worker 工作原理
Web Worker 是浏览器提供的一种在后台线程中执行 JavaScript 代码的机制。要深入理解 Web Worker 的工作原理,我们需要从浏览器架构、线程模型、内存管理和通信机制等多个层面进行分析。
浏览器的多进程架构
首先,了解现代浏览器的多进程架构有助于理解 Web Worker 的位置:
-
浏览器进程:管理浏览器的用户界面、地址栏、书签栏等
-
渲染进程:负责网页内容的渲染,每个标签页通常有独立的渲染进程
-
插件进程:管理浏览器插件
-
GPU 进程:处理 GPU 任务,加速渲染
-
网络进程:处理网络请求
Web Worker 在渲染进程内部运行,但与主线程(也称为 UI 线程)分离。
渲染进程的线程模型
当调用 new Worker() 时,浏览器会执行以下步骤:
-
验证脚本 URL:检查 Worker 脚本是否符合同源策略
-
创建新线程:在渲染进程内分配一个新的线程
-
初始化环境:为 Worker 线程创建一个全新的 JavaScript 执行环境
-
加载脚本:异步下载并解析 Worker 脚本
-
执行脚本:在新线程中执行 Worker 脚本代码
接下来我们将使用一个模拟代码来演示 Web Worker 的工作原理。
// 创建Worker时的内部过程
function createWorker(scriptURL) {
// 1. 验证URL是否同源
if (!isSameOrigin(scriptURL)) {
throw new SecurityError();
}
// 2. 创建新线程
const workerThread = createNewThread();
// 3. 初始化Worker环境
const workerGlobalScope = createWorkerGlobalScope();
// 4. 异步加载脚本
fetchScript(scriptURL).then((scriptContent) => {
// 5. 在Worker线程中执行脚本
executeInThread(workerThread, scriptContent, workerGlobalScope);
});
// 返回Worker实例
return new Worker(workerThread);
}
Worker 线程拥有独立的执行环境,与主线程隔离:
-
全局对象:Worker 使用 WorkerGlobalScope 作为全局对象,而不是 window
-
可用 API:只能访问部分 Web API,如 fetch、IndexedDB、WebSockets 等
-
内存隔离:Worker 有自己的内存堆,与主线程不共享内存(除了 SharedArrayBuffer)
-
独立执行:Worker 中的代码执行不会阻塞主线程,反之亦然
通信机制
Web Worker 与主线程之间通过消息传递进行通信,这是基于事件驱动的异步通信模型:
-
消息队列:每个线程都有自己的消息队列
-
事件循环:线程通过事件循环处理消息队列中的消息
-
结构化克隆算法:用于序列化和反序列化传递的数据
当调用 postMessage() 时,浏览器执行以下步骤:
-
序列化数据:使用结构化克隆算法将数据序列化
-
复制数据:创建数据的深拷贝(除非使用 Transferable Objects)
-
传递消息:将消息放入目标线程的消息队列
-
触发事件:在目标线程中触发 message 事件
接下来我们将使用一个模拟代码来演示 postMessage 的通信机制。
// postMessage的内部实现(简化版)
function postMessage(message, transfer) {
// 1. 序列化数据
const serializedData = structuredClone(message);
// 2. 如果有transferable对象,转移所有权而不是复制
if (transfer && transfer.length) {
transferOwnership(serializedData, transfer);
}
// 3. 将消息添加到目标线程的消息队列
targetThread.messageQueue.enqueue({
type: "message",
data: serializedData,
});
// 4. 如果目标线程空闲,通知它处理消息
if (targetThread.isIdle) {
targetThread.processMessageQueue();
}
}
结构化克隆算法
结构化克隆算法用于复制复杂 JavaScript 对象的算法。通过来自 Worker 的 postMessage() 或使用 IndexedDB 存储对象时在内部使用。它通过递归输入对象来构建克隆,同时保持先前访问过的引用的映射,以避免无限遍历循环。
结构化克隆算法可以处理以下数据类型:
// 1. 基本数据类型
const number = 123;
const string = "hello";
const boolean = true;
const nullValue = null;
const undefinedValue = undefined;
// 2. 复杂数据类型
const array = [1, 2, 3];
const object = { name: "John", age: 30 };
const date = new Date();
const regexp = /pattern/;
// 3. 特殊数据类型
const map = new Map();
const set = new Set();
const arrayBuffer = new ArrayBuffer(8);
const dataView = new DataView(arrayBuffer);
const int32Array = new Int32Array(4);
const error = new Error("Custom error");
不支持的类型:
- 函数:
const obj = {
method: function () {
console.log("hello");
},
};
// ❌ 无法克隆,会抛出 DATA_CLONE_ERR 错误
const clone = structuredClone(obj);
- DOM 节点:
const div = document.createElement("div");
// ❌ 无法克隆,会抛出 DATA_CLONE_ERR 错误
const clone = structuredClone(div);
-
对象的特定属性:
- RegExp 对象的 lastIndex
const regexp = /pattern/; // ❌ 无法克隆,会抛出 DATA_CLONE_ERR 错误 const clone = structuredClone(regexp);
- 属性描述符(Property Descriptors)
const obj = {}; Object.defineProperty(obj, "prop", { value: "value", writable: false }); // ❌ 无法克隆,会抛出 DATA_CLONE_ERR 错误 const clone = structuredClone(obj);
- 原型链上的属性
class MyClass { constructor() { this.name = "test"; } method() { return "hello"; } } const instance = new MyClass(); const clone = structuredClone(instance); // 原型链上的方法不会被克隆 console.log(clone.method); // undefined // 只有实例属性会被克隆 console.log(clone.name); // 'test'
这些限制主要是出于以下原因:
-
安全性考虑:函数可能包含敏感逻辑或闭包
-
跨上下文问题:DOM 节点与特定的文档上下文绑定
-
实现复杂性:属性描述符和原型链的克隆会带来额外的复杂性
-
性能考虑:完整克隆所有特性会带来性能开销
可转移对象
在 JavaScript 中,Web Worker 线程之间的通信通常是基于 消息传递 的。当你向 Worker 发送数据时,数据会被 复制 并传递给 Worker 线程。这种数据的复制可能会有性能开销,尤其是在处理大量数据时。为了优化这种性能开销,Web Worker 引入了 可转移对象(Transferable Objects) 概念,它允许在主线程与 Worker 线程之间 转移数据的所有权,而不是复制数据。
可转移对象 是一种特殊的对象,当你将其发送给 Worker 时,数据的所有权会从主线程转移到 Worker 线程,而不是复制。这使得数据传输更加高效,避免了数据复制的性能开销。
一旦数据所有权被转移,原对象在主线程中会变成 "空",即它的内容会被清空。主线程将不再能够访问该数据。
并非所有的对象都可以转移,只有某些特定类型的对象支持转移。常见的转移对象包括::
-
ArrayBuffer:一个用于存储二进制数据的对象,通常用于处理大量数据,特别是通过 TypedArray 进行高效访问和操作。
-
MessagePort:用于在多个线程或不同的执行环境之间建立通信的端口。它通过消息传递进行双向数据交换,通常与 MessageChannel 一起使用。
-
ReadableStream:表示一个可以读取数据的流,适用于从源头(如网络请求、文件系统)按需获取数据,并且支持流式处理。
-
WritableStream:表示一个可以写入数据的流,适用于将数据写入目标(如文件、网络连接等),支持按需处理写入操作。
-
TransformStream:结合了 ReadableStream 和 WritableStream,允许对流数据进行变换(例如过滤、解码或压缩数据)。
-
AudioData:用于表示音频数据的对象,适用于音频处理和操作,通常与 Web Audio API 配合使用,处理音频帧。
-
ImageBitmap:表示图像位图的对象,通常用于图像处理和优化渲染性能,可以直接从图像文件、Canvas 或 ImageData 创建。
-
VideoFrame:表示视频帧的对象,通常用于视频处理应用中,适用于高效传输和操作视频帧数据,特别是与 WebRTC 或实时视频流相关的场景。
-
OffscreenCanvas:一个在 Worker 中使用的画布对象,适用于在后台线程中进行图形渲染,避免了主线程的渲染阻塞。
-
RTCDataChannel:在 WebRTC 应用中用于传输任意数据的通道,适用于实时通信中的数据传输,如文件共享、聊天消息等。
以下代码演示了当消息从主线程发送到 web worker 线程时,传输是如何工作的。Uint8Array 在其缓冲区被转移时,被拷贝到 worker 中。传输后,任何尝试从主线程读或者写 uInt8Array 都将抛出错误,但是你仍然可以检查 byteLength 以确定它现在是 0。
// Create an 8MB "file" and fill it. 8MB = 1024 * 1024 * 8 B
const uInt8Array = new Uint8Array(1024 * 1024 * 8).map((v, i) => i);
console.log(uInt8Array.byteLength); // 8388608
// Transfer the underlying buffer to a worker
worker.postMessage(uInt8Array, [uInt8Array.buffer]);
console.log(uInt8Array.byteLength); // 0
以下代码展示了 structuredClone() 操作,将底层缓冲区从原始对象复制到克隆对象(clone)。
const original = new Uint8Array(1024);
const clone = structuredClone(original);
console.log(original.byteLength); // 1024
console.log(clone.byteLength); // 1024
original[0] = 1;
console.log(clone[0]); // 0
// Transferring the Uint8Array would throw an exception as it is not a transferable object
// const transferred = structuredClone(original, {transfer: [original]});
// We can transfer Uint8Array.buffer.
const transferred = structuredClone(original, { transfer: [original.buffer] });
console.log(transferred.byteLength); // 1024
console.log(transferred[0]); // 1
// After transferring Uint8Array.buffer cannot be used.
console.log(original.byteLength); // 0
web worker 是否越多越好
虽然 Web Worker 提供了多线程的优势,使得主线程能够保持响应而不被长时间的计算任务阻塞,但 并不是 Web Worker 创建得越多越好。过多的 Web Worker 可能会导致性能下降或资源浪费,以下是几个考虑因素:
-
线程创建和销毁的开销:每次创建一个 Worker 都需要一定的系统资源和时间。频繁地创建和销毁 Worker 可能会导致性能下降,尤其是在 Worker 数量较多时。创建线程的开销和销毁线程的开销可能抵消 Web Worker 带来的性能提升。
-
系统资源和 CPU 核心限制:Web Worker 运行在独立的线程中,它们消耗 CPU 核心资源。大多数系统都有数量有限的 CPU 核心。如果创建过多的 Web Worker,线程将会争夺 CPU 资源,这可能导致上下文切换过于频繁,反而影响应用的性能。
-
内存消耗:每个 Worker 都有独立的内存空间,如果 Worker 数量过多,会消耗大量内存,导致浏览器变慢甚至崩溃。因此,需要谨慎管理 Worker 的数量,尤其是在需要处理大量数据时。
-
线程间的上下文切换:线程之间的上下文切换本身也有开销。虽然 Web Worker 在并行处理任务时可以提升性能,但如果 Worker 数量过多,线程切换的频繁发生可能会导致性能降低,尤其是在资源受限的系统中。
Web Worker 是一个强大的工具,可以提升应用的性能,但 创建过多的 Web Worker 会导致线程管理的开销、资源竞争和内存消耗,反而影响性能。因此,合理设计 Worker 的数量和使用方式是提升性能的关键。
总结
Web Worker 允许 JavaScript 代码在后台线程中执行,从而避免了主线程(通常是 UI 线程)被阻塞,提高了应用的响应性。它通过 postMessage()
与主线程进行通信,支持使用可转移对象来优化数据传输效率。Web Worker 适用于处理计算密集型任务,如大数据处理、图像/视频渲染等,但它有一些限制,如不能直接访问 DOM 和 window
对象。通过合理使用 Worker,能显著提升应用性能和用户体验,特别是在需要并行计算的场景中。