Web Workers 学习笔记
最近在开发中遇到了一个需求,一大堆的图片都需要调用两个接口。这对单线程的 JavaScript 运行环境构成了挑战,容易影响用户体验。所以决定学习 Web Workers 并记录一下。
Web Workers 的作用就是提供一个多线程环境,允许将一些繁重任务(涉及大量计算或网络请求时)从主线程分离出来。在主线程创建的 Worker 线程中,这些任务可在后台运行,避免阻塞主线程,使主线程可以更专注于 UI 交互等实时响应,从而大幅提升应用的流畅性。
Worker 一旦创建,会在后台持续运行,不受主线程活动(如用户点击或提交)打扰,直到完成任务后将结果返回给主线程。这种处理方式大大改善了用户体验。但需要注意的是,Worker 会占用额外资源,合理使用、及时关闭非常重要。
兼容性
兼容性良好,可以放心使用。
注意事项
1. 无法直接操作 DOM
Web Workers 运行在独立的线程中,不能直接访问主线程的 DOM,也无法使用 document、window、parent 这些对象。与 DOM 相关的更新需要通过消息传递将数据返回主线程来完成。
Worker 线程可以使用 navigator 对象和 location 对象
navigator 可以访问网络状态等信息,这些属性在 Worker 线程中是可访问的,并且与主线程中的 navigator 保持一致。
// worker.ts
console.log(navigator.userAgent); // 可用
console.log(navigator.language); // 可用
console.log(navigator.onLine); // 可用
location 可用来读取 Worker 文件的 URL,但它与主线程的 window
.
location 有所不同。Worker 的 location 是只读的,通常称为 WorkerLocation,提供了 Worker 脚本的 URL 信息,而非页面的 URL。以下是它提供的属性:
location.href
:Worker 脚本的完整 URLlocation.protocol
:协议(如https:
)location.host
:主机名和端口location.hostname
:主机名location.port
:端口号location.pathname
:路径名location.search
:查询字符串location.hash
:哈希值这些属性都是只读的,因此无法在 Worker 中通过
location
重定向。
// worker.ts
console.log(location.href); // Worker 脚本的 URL
console.log(location.origin); // 脚本的来源,如 "https://example.com"
console.log(location.pathname); // Worker 文件的路径
2. 数据通信通过消息传递
主线程和 Worker 不在同一个上下文环境,他们之间的通信使用 `postMessage` 方法发送消息,并通过 `onmessage` 接收消息。由于 Worker 不共享内存,数据会被序列化和反序列化,因此传输大量数据时性能可能受影响。
3. 没有同步 API
Web Workers 中,出于性能和隔离的考虑,一些同步 API 被禁用了,特别是涉及阻塞线程和用户交互的 API,比如 alert()
、confirm()
和 prompt()
,以及涉及本地存储的 localStorage
。
被禁用原因:
- Web Worker 是为后台计算和处理任务设计的,不应该在执行过程中阻塞或打断自己或主线程的操作。
- Worker 线程没有访问页面 UI 的权限,因此也无法创建原生的弹框,这些操作应交由主线程完成。
localStorage
是一种同步 API,存储和读取数据会在调用时立即完成,且会阻塞线程。解决方案:
- 如果 Worker 需要获得用户输入或确认信息,可以通过消息传递通知主线程,让主线程显示弹框并将结果返回给 Worker。
- 使用异步存储 API,例如
IndexedDB
或将数据传递给主线程,通过主线程访问localStorage
4. 需要同源
Worker 脚本文件必须与页面在同一个源下进行加载,或者以绝对路径加载。
5. 消耗系统资源
每个 Worker 创建都会占用系统资源,包括内存和线程。对于简单任务,不建议创建大量 Worker,因为资源开销会变大。建议重用 Worker 或限制并发 Worker 数量。
-
重用 Worker:
使用单个 Worker 处理多个任务。如果任务不需要并行处理,并且可以顺序执行,可以重用同一个 Worker。每次任务完成后,通过消息通知主线程,主线程再发送新的任务到同一个 Worker 中。
// worker.js
self.onmessage = (event) => {
const { task, data } = event.data;
let result;
// 根据任务类型执行不同操作
switch (task) {
case 'task1':
result = data * 2;
break;
case 'task2':
result = data + 10;
break;
default:
result = 'unknown task';
}
// 将结果返回主线程
self.postMessage({ task, result });
};
const worker = new Worker('worker.ts');
worker.onmessage = (event) => {
console.log(`Result for ${event.data.task}:`, event.data.result);
// 执行下一个任务
sendNextTask();
};
function sendNextTask() {
const task = getNextTask(); // 假设从队列获取下一个任务
if (task) {
worker.postMessage(task);
}
}
const taskQueue = [
{ type: 'task1', data: 5 },
{ type: 'task2', data: 10 },
{ type: 'task3', data: 20 }
];
function getNextTask() {
return taskQueue.shift(); // 从任务队列中取出第一个任务
}
// 初始化时发送第一个任务
sendNextTask();
为了节省系统资源,使用完毕需要关闭 Worker。
// 主线程 worker.terminate(); // Worker 线程 self.close();
-
限制并发 Worker 数量:
创建 Worker 池(Worker 池的逻辑会写在主线程的文件中)。在处理大量任务时,可以创建一个有限数量的 Worker 池,按照需要动态分配任务。这种方式类似于线程池,限制了同时运行的 Worker 数量,避免资源过度消耗。
- 初始化了
MAX_WORKERS
个 Worker,并将它们放入workerPool
数组。 assignTask
函数从任务队列中取出任务,并将其分配给空闲的 Worker。addTask
函数将任务加入taskQueue
队列,同时检查是否有空闲 Worker,如果有,则立即分配任务。
const MAX_WORKERS = 4; // 最大 Worker 数量
const workerPool = []; // 存储 Worker 实例
const taskQueue = []; // 存储待处理任务
// 初始化 Worker 池
for (let i = 0; i < MAX_WORKERS; i++) {
const worker = new Worker('worker.js');
worker.isBusy = false;
worker.onmessage = (event) => {
console.log('Worker result:', event.data);
worker.isBusy = false; // 标记 Worker 空闲
assignTask(worker); // 任务完成后分配下一个任务
};
workerPool.push(worker);
}
// 分配任务给空闲 Worker
function assignTask(worker) {
if (taskQueue.length > 0) {
const task = taskQueue.shift(); // 获取下一个任务
worker.postMessage(task); // 发送任务到 Worker
worker.isBusy = true; // 标记 Worker 正在工作
}
}
// 添加任务到队列并尝试分配
function addTask(task) {
taskQueue.push(task); // 添加任务到队列
const idleWorker = workerPool.find(w => !w.isBusy);
if (idleWorker) {
assignTask(idleWorker); // 立即分配任务给空闲 Worker
}
}
// 使用 Worker 池处理多个任务
addTask({ type: 'task1', data: 5 });
addTask({ type: 'task2', data: 10 });
addTask({ type: 'task3', data: 20 });
- 如果
MAX_WORKERS = 4
,而addTask
只调用了一次,那么只有一个 Worker 会处理任务,其余 3 个 Worker 空闲。 - 如果
addTask
调用三次,MAX_WORKERS = 4
则可以同时分配这 3 个任务到 3 个空闲 Worker。 - 如果
addTask
调用超过 4 次(比如调用了 6 次),那么前 4 个任务可以立即分配给 4 个 Worker,其余 2 个任务会在taskQueue
中等待,直到有 Worker 完成任务并变为空闲状态时才继续分配。
6. 网络请求的限制
Worker 中允许发起 `fetch` 请求,但不支持如 `XMLHttpRequest` 的某些同步网络请求(同步请求会阻塞线程)(但支持异步)。注意 Worker 中的请求与主线程网络策略一致,仍然受 CORS 限制。
7.读取文件的限制
Worker 线程无法读取本地文件,即不能打开本机的文件系统(file://
),它所加载的脚本,必须来自网络。
使用实例
1.创建 Worker 文件(worker.ts)
self 代表子线程自身,即子线程的全局对象。
// worker.ts
self.onmessage = function (event) {
const imageUrl = event.data; // 接收主线程传递的图片URL
// 模拟图片处理操作
fetch(imageUrl).then(response => response.blob())
.then(blob => {
// 假设对图片进行了处理
self.postMessage(blob); // 返回处理结果给主线程
}).catch(error => self.postMessage({ error }));
};
2.主线程调用 Worker(main.ts)
// main.ts
export const processImages = (images: string[]) => {
const worker = new Worker('./utils/worker.ts');
worker.onmessage = (event) => {
const result = event.data;
if (result.error) {
console.error("Error processing image:", result.error);
} else {
console.log("Processed image blob:", result);
// 在主线程中将结果进行下一步处理,比如显示或上传
}
};
images.forEach(imageUrl => {
worker.postMessage(imageUrl); // 将图片URL传递给 Worker
});
// 可选:在处理完所有任务后,可以通过 worker.terminate() 关闭 Worker 释放资源
return () => worker.terminate();
};
3.外部调用 Web Workers
// imageUpload.ts
import { processImages } from './main';
const images = ['url1', 'url2', 'url3'];
const terminateWorker = processImages(images);
报错:
- 我首先排查是不是 worker.ts 的问题,因此我将 worker.ts 改为 worker.js。
- 仍然会报错,就检查引入 worker.ts 的路径问题。
- 仍然会报错,在
main.ts
中创建 Worker 时,确保使用正确的路径格式和模块类型
// main.ts
const worker = new Worker(new URL('worker.ts', import.meta.url), { type: 'module' });
成功,控制台输出信息:
注意:确保
worker.js
文件没有任何import
或export
语句。
self.addEventListener() 和 self.onmessage 都可以用。根据主线程发来的数据,Worker 线程可以调用不同的方法:
self.addEventListener('message', function (e) {
const data = e.data;
switch (data.cmd) {
case 'start':
self.postMessage('WORKER STARTED: ' + data.msg);
break;
case 'stop':
self.postMessage('WORKER STOPPED: ' + data.msg);
self.close(); // 用于在 Worker 内部关闭自身
break;
default:
self.postMessage('Unknown command: ' + data.msg);
};
}, false);
Worker 加载脚本
假设有以下几个文件:
worker.ts
:主 Worker 文件math.ts
:包含数学相关的函数stringUtils.ts
:包含字符串处理函数
// math.ts
function add(a, b) {
return a + b;
}
function multiply(a, b) {
return a * b;
}
// stringUtils.ts
function capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
// worker.ts
importScripts('math.ts', 'stringUtils.ts');
self.onmessage = function (event) {
const { type, payload } = event.data;
if (type === 'calculate') {
const result = add(payload.a, payload.b);
self.postMessage({ type: 'result', result });
}
if (type === 'capitalize') {
const result = capitalize(payload.text);
self.postMessage({ type: 'result', result });
}
};
// 主线程
const worker = new Worker('worker.ts');
// 监听 Worker 的消息
worker.onmessage = (event) => {
console.log('Worker result:', event.data.result);
};
// 发送计算任务
worker.postMessage({ type: 'calculate', payload: { a: 5, b: 3 } });
// 发送字符串任务
worker.postMessage({ type: 'capitalize', payload: { text: 'hello' } });
错误处理
worker.onerror(function (event) {
console.log([
'ERROR: Line ', e.lineno, ' in ', e.filename, ': ', e.message
].join(''));
});
// OR
worker.addEventListener('error', function (event) {
// ...
});
主线程与 Worker 通信
在 Web Worker 中,主线程与 Worker 之间的通信是通过消息传递实现的,这种通信方式是基于拷贝关系,即传递的是值的副本而不是引用。这意味着主线程和 Worker 中的相同数据是独立的,修改一方的数据不会影响另一方。
具体来说,浏览器会对通信内容进行序列化(即将数据转为字符串形式),再将其发送给 Worker 线程,Worker 收到后再将数据反序列化为原来的格式。这种机制虽然安全且防止线程之间的数据干扰,但对性能有一定开销。
传递普通数据(文本或对象)
// main.ts(主线程)
const worker = new Worker('worker.ts');
// 深拷贝数据
const messageData = { task: 'processData', value: 42 };
const clonedData = JSON.parse(JSON.stringify(messageData)); // 创建深拷贝
worker.postMessage(clonedData); // 发送深拷贝数据到 Worker
worker.onmessage = (event) => {
console.log('Original data in main thread:', messageData); // 验证原始数据未被修改
console.log('Message from Worker:', event.data); // 接收 Worker 返回的消息
};
// worker.js(Worker 线程)
self.onmessage = (event) => {
const receivedData = event.data;
console.log('Worker received:', receivedData);
receivedData.value = 100; // 修改数据
console.log('Worker received after:', receivedData); // 打印修改后的数据
self.postMessage(receivedData); // 返回数据给主线程
};
传递二进制数据(ArrayBuffer
)
对于二进制数据,主线程和 Worker 之间可以通过Transferable 对象进行零拷贝传递,避免拷贝开销并提高性能。
// main.ts(主线程)
const worker = new Worker('worker.ts');
const buffer = new ArrayBuffer(8); // 创建一个 8 字节的二进制缓冲区
const view = new Uint8Array(buffer);
view[0] = 10;
// 传递 buffer,使用 Transferable 对象
worker.postMessage(buffer, [buffer]); // 第二个参数是 Transferable 对象
console.log(buffer.byteLength); // 输出 0,因为 buffer 已被转移到 Worker
worker.onmessage = (event) => {
console.log('Modified buffer received from Worker:', event.data);
};
// worker.js(Worker 线程)
self.onmessage = (event) => {
const buffer = event.data;
const view = new Uint8Array(buffer);
view[0] = 20; // 修改数据
self.postMessage(buffer, [buffer]); // 传回给主线程
};
通过 Transferable 对象将 buffer
传递给 Worker,主线程中的 buffer
会被“转移”,即 buffer.byteLength
会变为 0,表示这个 ArrayBuffer
已经转移到 Worker,主线程不再拥有它。这种机制避免了对大数据的拷贝开销,并使传递二进制数据更高效。
这种转移数据的方法,叫做 Transferable Objects。这使得主线程可以快速把数据交给 Worker,对于影像处理、声音处理、3D 运算等就非常方便了,不会产生性能负担。
Worker 代码载入
Web Workers 通过单独的 JavaScript 文件运行,因为 Worker 是独立线程,无法直接访问主线程的作用域和上下文。不过,有时需要在 Worker 中执行一些与主线程相关的代码,比如传递函数或使用主线程中的脚本逻辑。可以通过以下两种方法实现 Worker 和主线程的代码共享:
嵌入 Worker 脚本
在 HTML 页面中使用 <script>
标签嵌入 Worker 脚本内容。
- 注意:为了避免浏览器将这个脚本当作普通的 JavaScript 执行,
type
属性指定为一个不被识别的值(例如app/worker
)。这样浏览器会忽略它,而不会报错。
使用 Blob 载入内联代码
// 载入与主线程在同一个网页的代码
<!DOCTYPE html>
<html>
<body>
<!-- 嵌入 Worker 代码 -->
<script id="worker" type="app/worker">
addEventListener('message', function () {
postMessage('some message');
}, false);
</script>
<script>
// 获取嵌入的 Worker 代码
const blob = new Blob([document.querySelector('#worker').textContent], { type: 'application/javascript' });
// OR
const workerCode = `
self.onmessage = function(event) {
console.log('Worker received:', event.data);
self.postMessage('Hello from inline Worker');
`;
// 创建 Blob 对象并生成 URL
const blob = new Blob([workerCode], { type: 'application/javascript' });
const url = URL.createObjectURL(blob);
// 创建 Worker
const worker = new Worker(url);
// 监听 Worker 消息
worker.onmessage = function (e) {
console.log(e.data); // 输出 'some message'
};
// 向 Worker 发送消息,触发 Worker 的 `message` 事件
worker.postMessage('Hello Worker');
</script>
</body>
</html>
使用 Data URL 载入内联代码
// main.ts
const worker = new Worker(
`data:text/javascript,
self.onmessage = function(event) {
console.log('Worker received:', event.data);
self.postMessage('Hello from Data URL Worker');
};`
);
worker.onmessage = (event) => {
console.log('Message from Worker:', event.data);
};
worker.postMessage('Hello Worker');
Worker 中实现轮询任务
// worker.ts
// 设置轮询的时间间隔(毫秒)
const POLLING_INTERVAL = 5000;
// 模拟一个轮询函数
function poll() {
fetch('https://api.example.com/data') // 假设这个 URL 是服务器端的 API
.then(response => response.json())
.then(data => {
// 将数据返回给主线程
postMessage(data);
// 假设某种条件满足时停止轮询
if (data.status === 'complete') {
close(); // 关闭 Worker,停止轮询
} else {
// 否则,继续下次轮询
setTimeout(poll, POLLING_INTERVAL);
}
})
.catch(error => {
postMessage({ error: error.message });
setTimeout(poll, POLLING_INTERVAL); // 即使出错也继续轮询
});
}
// 启动轮询
poll();
属性和方法
- Worker.onerror:指定 error 事件的监听函数。
- Worker.onmessage:指定 message 事件的监听函数,发送过来的数据在
Event.data
属性中。- Worker.onmessageerror:指定 messageerror 事件的监听函数。发送的数据无法序列化成字符串时,会触发这个事件。
- Worker.postMessage():向 Worker 线程发送消息。
- Worker.terminate():立即终止 Worker 线程。
Web Workers 的全局对象
Web Workers 有自己的全局对象,不是主线程的window
,而是一个专门为 Worker 定制的全局对象。因此定义在window
上面的对象和方法不是全部都可以使用。
- self.name: Worker 的名字。该属性只读,由构造函数指定。
- self.onmessage:指定
message
事件的监听函数。- self.onmessageerror:指定 messageerror 事件的监听函数。发送的数据无法序列化成字符串时,会触发这个事件。
- self.close():关闭 Worker 线程。
- self.postMessage():向产生这个 Worker 线程发送消息。
- self.importScripts():加载 JS 脚本。
扩展:嵌套 Worker
Web Workers 支持在其内部再创建新的 Worker,这种模式称为 嵌套 Worker(Nested Worker)。它可以帮助更复杂的任务进行进一步分层,并在 Worker 内部将计算任务进一步分解到新的 Worker 中。
兼容性
- 现代浏览器:大部分现代浏览器支持嵌套 Worker(如 Chrome、Firefox 和 Edge)。
- Safari:早期版本中不支持嵌套 Worker,因此需要进行兼容性测试。
- IE 浏览器:不支持嵌套特性。
// main.ts (主线程)
const worker = new Worker('worker.js');
worker.onmessage = function (event) {
console.log('Message from nested Worker:', event.data);
};
worker.postMessage('start'); // 启动嵌套任务
第一层 Worker 文件(worker.ts
)
接收来自主线程的消息,然后创建第二层嵌套 Worker
// worker.ts
self.onmessage = function (event) {
if (event.data === 'start') {
// 创建嵌套 Worker
const nestedWorker = new Worker('nestedWorker.ts');
// 监听嵌套 Worker 的消息
nestedWorker.onmessage = function (e) {
// 将嵌套 Worker 的结果传回主线程
self.postMessage(e.data);
};
// 向嵌套 Worker 发送任务
nestedWorker.postMessage('do heavy computation');
}
};
第二层嵌套 Worker 文件(nestedWorker.ts
)
这个文件是嵌套 Worker 的代码,处理实际的任务并将结果返回给第一层 Worker。
// nestedWorker.ts
self.onmessage = function (event) {
if (event.data === 'do heavy computation') {
// 执行一些复杂的任务
let result = 0;
for (let i = 0; i < 1e6; i++) {
result += i;
}
// 返回结果
self.postMessage(`Computation result: ${result}`);
}
};
- 主线程创建了第一个 Worker(
worker.ts
)。 worker.ts
接收主线程的消息,创建了一个嵌套 Worker(nestedWorker.ts
)来处理复杂的计算任务。nestedWorker.ts
完成任务后,将结果发送回worker.ts
,再由worker.ts
将结果传回主线程。
文章参考:
https://www.ruanyifeng.com/blog/2018/07/web-worker.html?20241106192347#comment-last
https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API
Web Workers | Can I use... Support tables for HTML5, CSS3, etc