一、真实场景:排序5万行表格,页面卡死3秒
上个月我们做的一个金融报表项目,用户点击“按交易金额排序”,表格有5万行数据。排序函数用了标准的Array.sort,结果浏览器无响应3秒,用户直接反馈“卡死了,整个页面动不了”。产品经理当天就拍了桌子。
问题根源:JavaScript是单线程,排序这种CPU密集型任务会阻塞主线程,导致UI渲染和事件处理全部暂停。5万条数据排序时间虽短,但叠加其他计算(比如格式化、统计)后,卡顿感明显。更糟的是,如果数据量到100万,直接变成“死机”感。
二、方案对比:为什么选Web Worker
| 方案 | 原理 | UI是否阻塞 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 主线程直接计算 | 原地排序 | 是 | 低 | 数据量小(<1000条) |
| setTimeout分片 | 把计算拆成小块,每次setTimeout 0ms | 伪异步,长分片依然卡 | 中 | 少量长任务 |
| Dedicated Worker | 独立线程,通过postMessage通信 | 否 | 中 | 单页面复杂计算 |
| SharedWorker | 多页面共享一个Worker线程 | 否 | 高 | 多个Tab共享数据池 |
| Service Worker | 后台代理请求,不直接做计算 | 否 | 高 | 离线缓存/消息推送 |
我们的场景是单个页面的排序/过滤/统计,选择Dedicated Worker最轻量。SharedWorker虽然能跨页面,但引入锁和通信复杂度,我们不需要。
三、完整代码实现
3.1 目录结构
project/
├── src/
│ ├── workers/
│ │ └── sort.worker.js # Worker脚本
│ ├── composables/
│ │ └── useWorkerSort.js # Vue3组合函数
│ └── views/
│ └── TableView.vue # 主页面
└── vite.config.js # 构建配置
3.2 Worker脚本:sort.worker.js
// sort.worker.js - 专用于排序的Worker
let currentTaskId = null;
// 接收主线程消息
self.addEventListener('message', (e) => {
const { taskId, data, order, config } = e.data;
currentTaskId = taskId;
// 注意:Worker中不能访问DOM,只能进行纯计算
const sorted = [...data].sort((a, b) => {
return order === 'asc'
? a[config.sortField] - b[config.sortField]
: b[config.sortField] - a[config.sortField];
});
// 模拟分段发送进度(实际排序太快,这里为演示)
self.postMessage({ taskId, type: 'progress', progress: 50 });
// 模拟一些额外处理(比如格式化数字)
const result = sorted.map(item => ({
...item,
formattedAmount: `¥${item.amount.toFixed(2)}`
}));
self.postMessage({
taskId,
type: 'result',
data: result,
// 使用Transferable Objects移出内存(下面会讲)
buffer: new ArrayBuffer(0)
});
currentTaskId = null;
});
// 允许主线程中断任务
self.addEventListener('messageerror', () => {
self.close(); // 发生错误时关闭Worker
});
3.3 Vue3组合函数:useWorkerSort.js
// useWorkerSort.js - Vue3 Composable
import { ref, onUnmounted } from 'vue';
export function useWorkerSort() {
const worker = ref(null);
const isLoading = ref(false);
const progress = ref(0);
let currentTaskId = 0;
// 初始化Worker
function initWorker() {
if (worker.value) return;
// 使用相对路径,Vite会自动复制文件
worker.value = new Worker(
new URL('../workers/sort.worker.js', import.meta.url),
{ type: 'module' }
);
worker.value.addEventListener('message', handleMessage);
worker.value.addEventListener('error', handleError);
}
function handleMessage(e) {
const { taskId, type, data, progress: p } = e.data;
// 只处理当前任务的消息
if (taskId !== currentTaskId) return;
if (type === 'progress') {
progress.value = p;
} else if (type === 'result') {
isLoading.value = false;
// 把结果通过回调返回
// 实际使用中通过Promise或事件派发
if (sortCallback) sortCallback(data);
}
}
function handleError(e) {
console.error('Worker error:', e.message);
isLoading.value = false;
}
let sortCallback = null;
function runSort(data, order, config) {
return new Promise((resolve, reject) => {
if (!worker.value) initWorker();
isLoading.value = true;
progress.value = 0;
currentTaskId++;
const taskId = currentTaskId;
sortCallback = (result) => {
resolve(result);
sortCallback = null;
};
worker.value.postMessage({ taskId, data, order, config });
// 设置超时(60秒)
setTimeout(() => {
if (isLoading.value) {
isLoading.value = false;
reject(new Error('Sort timeout'));
}
}, 60000);
});
}
// 清理Worker
onUnmounted(() => {
if (worker.value) {
worker.value.terminate();
worker.value = null;
}
});
return { runSort, isLoading, progress };
}
3.4 主页面:TableView.vue
<template>
<div>
<button :disabled="isLoading" @click="doSort">
{{ isLoading ? `排序中... ${progress}%` : '按金额排序' }}
</button>
<table>
<tr v-for="row in sortedData" :key="row.id">
<td>{{ row.name }}</td>
<td>{{ row.formattedAmount }}</td>
</tr>
</table>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useWorkerSort } from '../composables/useWorkerSort';
const { runSort, isLoading, progress } = useWorkerSort();
const sortedData = ref([]);
// 模拟10万条数据
const rawData = Array.from({ length: 100000 }, (_, i) => ({
id: i + 1,
name: `用户${i}`,
amount: Math.random() * 100000
}));
async function doSort() {
try {
const result = await runSort(rawData, 'asc', { sortField: 'amount' });
sortedData.value = result;
} catch (err) {
// 降级方案:主线程排序
console.warn('Worker降级,使用主线程排序');
sortedData.value = [...rawData].sort((a,b) => a.amount - b.amount);
}
}
</script>
3.5 数据传输优化:Transferable Objects
默认postMessage会拷贝数据,拷贝大对象(比如100万条数据约50MB)会消耗200-500ms。使用Transferable Objects可以把数据的控制权直接转移给Worker,零拷贝时间,但发送后原线程不能再访问该数据。
// 主线程
// 把数据转为ArrayBuffer(比如从Canvas或TypedArray)
const buffer = new ArrayBuffer(100 * 1024 * 1024); // 100MB
const view = new Float64Array(buffer);
// 填充数据...
// 发送并转移所有权
worker.postMessage({ taskId, type: 'data', buffer }, [buffer]);
// 发送后,主线程不能再访问buffer(会报错)
// Worker中接收
self.addEventListener('message', (e) => {
const { buffer } = e.data;
// 注意:buffer已经是“空”了(长度0),但Worker可以读取
const view = new Float64Array(buffer);
// 计算...
// 计算完成后,可以把结果buffer转回主线程
self.postMessage({ resultBuffer: buffer }, [buffer]);
});
注意:Transferable只能用于ArrayBuffer、MessagePort、ImageBitmap等类型,普通对象不能转移。
四、效果数据:压测对比
测试环境:MacBook Pro M1 (10核CPU, 16GB内存), Chrome 122.0.6261.129, 关闭GPU加速, 数据为100万条随机浮点数排序。
| 方案 | 总耗时(ms) | UI阻塞时间(ms) | 内存峰值(MB) | 数据传输额外成本(ms) |
|---|---|---|---|---|
| 主线程排序 | 3200 | 3200(页面无响应) | 120 | 0 |
| setTimeout分片(每片10条) | 6500 | 1500(分片间隙有响应,但长分片仍卡) | 110 | 0 |
| Worker + JSON拷贝 | 1350 | 0 | 180(主+Worker各一份) | 280(拷贝100万对象) |
| Worker + Transferable | 800 | 0 | 90(主线程释放数据后) | 0 |
结论:Web Worker + Transferable Objects能让100万数据排序耗时从3.2秒降到0.8秒,且完全不影响UI响应。注意setTimeout分片实际更慢,因为分片增加了调度开销。
五、避坑指南(我踩过的7个坑)
5.1 坑1:Worker不关闭,造成内存泄漏
上线后监控发现,用户切换页面时内存只增不减。原因:Vue组件卸载时没有调用worker.terminate()。每个Worker约占用5-10MB内存,不关闭会导致泄漏。
解决:在Vue的onUnmounted或React的useEffect清理函数中调用terminate。注意:terminate后Worker立刻死亡,不需要等待onmessage。
5.2 坑2:大数据传输反而变慢
没有用Transferable Objects,100万条数据postMessage拷贝花了300ms,排序本身只花了800ms,总耗时1.1s依然比主线程快,但传输时间占了27%。后来改用ArrayBuffer转移,传输时间归零。
经验:如果数据量超过10万条,或者数据是二进制(图片、音频),一定要用Transferable Objects。对于普通JSON对象,可以先用structuredClone在Worker内克隆,但拷贝成本依然在。最佳实践:在Worker内生成或处理二进制数据。
5.3 坑3:Worker脚本路径写死,上线报404
开发时直接写new Worker('sort.worker.js'),本地OK,但打包后文件没复制到dist目录。Vite需要new Worker(new URL('...', import.meta.url))方式引用。
解决:用import.meta.url动态生成路径,Vite会自动打包Worker。
5.4 坑4:Worker内不能使用console.table,调试困难
调试Worker时,打了console.log但在主线程的控制台看不到输出。Chrome支持在控制台选择“Worker线程”查看,但很多人不知道。
技巧:在Worker内使用self.postMessage({type:'debug', info: someData})发送给主线程,主线程再用console.log打印。或者用self.console.log,然后打开“允许跨域日志”选项(Chrome DevTools > Settings > Console > Show console in worker)。
5.5 坑5:低版本浏览器不支持Worker
客户使用的是IE11(还是发生了)。必须提供降级方案:检测window.Worker是否存在,不存在则直接主线程执行。
代码:
if (typeof Worker === 'undefined') {
// 降级到主线程
const result = data.sort(compare);
callback(result);
} else {
// 使用Worker
}
5.6 坑6:SharedWorker中多Tab竞争导致数据不一致
当时想用SharedWorker做数据池,发现两个Tab同时写同一个变量时,出现覆盖。SharedWorker没有内置锁机制,需要自己实现消息队列或互斥信号量。
建议:如果不是强需求,别用SharedWorker做共享状态。实在要用,参考“BroadcastChannel + 锁”模式。
5.7 坑7:Worker内使用第三方库
想在Worker里用lodash进行深度排序,但打包后Worker运行报错。因为默认构建工具不会把库打包进Worker。
解决:把需要用到的库函数单独抽取成模块,在Worker中通过importScripts(非module模式)或ES Module方式引入。Vite自动处理ES Module Worker依赖。
六、原理:Web Worker到底怎么跑
Web Worker的本质是浏览器为JavaScript脚本在后台开辟的一个独立线程。主线程和Worker线程通过postMessage发送消息,接收方通过onmessage事件处理。两边不能共享内存(除非使用SharedArrayBuffer,但需要设置安全头)。
浏览器内部实现:每个Worker对应一个独立的V8引擎实例(或JavaScriptCore实例)。这意味着Worker有自己独立的全局作用域、事件循环和堆栈。当主线程创建Worker时,浏览器会启动一个新的渲染进程(或线程池中的一个),专门执行Worker脚本。
消息传递是异步的,通过事件循环的微任务队列处理。主线程发消息后可以立即做其他事,Worker收到消息后开始计算,计算完成后发回结果,主线程再处理。这其实就是Actor模型的简化版。
需要注意的是,Worker中不能访问主线程的DOM、BOM(如window、document),也不能操作Canvas(但可以接收ImageBitmap)。Worker适合做的:排序、图像处理、PDF生成、大文件解析、加密等。
七、总结
Web Worker是解决前端CPU密集型任务的首选工具,不用白不用。实现上注意三点:1) 使用Transferable Objects优化大数据传输;2) 组件卸载时terminate;3) 提供降级方案。本文代码可直接复制到Vue3+Vite项目中使用,其他框架(React/Angular)原理一致。
八、附录:常用Worker类型速查
| 类型 | 创建方式 | 通信范围 | 特点 |
|---|---|---|---|
| Dedicated Worker | new Worker(url) | 创建它的页面 | 最常用,一对一 |
| Shared Worker | new SharedWorker(url) | 同源所有页面 | 多页面共享,需要端口 |
| Service Worker | navigator.serviceWorker.register | 浏览器后台 | 拦截请求,离线缓存 |