为什么你的页面会“卡死”?
先想象一个场景:用户点击按钮触发一个计算量很大的任务,比如解析一个 10MB 的 CSV 文件、对图片做像素级滤镜、或者跑一段复杂的加密算法。你写下了这样的代码:
button.addEventListener('click', () => {
const result = heavyComputation(data); // 耗时 3 秒
render(result);
});
点击之后,页面立刻失去响应——按钮按不动、滚动卡顿、动画冻结。浏览器甚至可能弹出“页面无响应”的提示。原因很简单:JavaScript 是单线程的。主线程既要执行 JS,又要负责样式计算、布局、绘制和事件响应。当一段同步代码长时间占用主线程时,其他所有事情都得排队等待。
解决这个问题的标准方案就是 Web Worker。它允许你在后台线程中运行脚本,与主线程并行工作,互不阻塞。
Web Worker 的核心限制
在动手之前,先明确 Worker 的边界:
- 不能直接操作 DOM。Worker 运行在独立上下文中,没有
window、document、parent等对象。 - 不能使用部分 Web API,比如
alert()、localStorage(同步版本)。 - 同源限制。Worker 脚本必须与主页面同源,除非使用 Blob URL 或
data:URL 创建内联 Worker。 - 通信靠消息传递。主线程和 Worker 之间通过
postMessage和onmessage交换数据,数据会被结构化克隆(Structured Clone),而不是共享内存(除非使用SharedArrayBuffer)。
这些限制听起来很多,但换个角度看:Worker 本来就不该碰 UI,它只负责“算”,算完把结果丢回主线程,由主线程更新界面。职责清晰反而更安全。
实战一:把耗时计算搬进 Worker
假设我们有一个函数 calculatePrimes(limit),用来找出某个范围内的所有质数。当 limit 很大时,主线程会卡住。
主线程代码(main.js):
const worker = new Worker('prime-worker.js');
document.querySelector('#start').addEventListener('click', () => {
const limit = 5_000_000;
worker.postMessage({ command: 'calculate', limit });
});
worker.onmessage = (event) => {
const { primes, duration } = event.data;
document.querySelector('#result').textContent =
`找到 ${primes.length} 个质数,耗时 ${duration}ms`;
};
Worker 代码(prime-worker.js):
function calculatePrimes(limit) {
const primes = [];
for (let i = 2; i <= limit; i++) {
let isPrime = true;
for (let j = 2; j * j <= i; j++) {
if (i % j === 0) { isPrime = false; break; }
}
if (isPrime) primes.push(i);
}
return primes;
}
self.onmessage = (event) => {
const { command, limit } = event.data;
if (command === 'calculate') {
const start = performance.now();
const primes = calculatePrimes(limit);
const duration = performance.now() - start;
self.postMessage({ primes, duration });
}
};
现在点击按钮,主线程依然可以响应滚动、点击和其他交互。计算完成后,Worker 把结果发回来,主线程只做一次轻量的 DOM 更新。
实战二:处理大文件而不冻结界面
另一个典型场景是文件解析。用户上传一个大型 JSON 或 CSV 文件,如果直接在主线程 JSON.parse 或逐行处理,页面会僵住几秒甚至更久。
主线程:
const fileInput = document.querySelector('#file');
const worker = new Worker('file-worker.js');
fileInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
const text = await file.text(); // 读取文件本身是异步的
worker.postMessage({ text });
});
worker.onmessage = (e) => {
const { rows, summary } = e.data;
renderTable(rows);
document.querySelector('#summary').textContent = summary;
};
Worker:
self.onmessage = (e) => {
const { text } = e.data;
const lines = text.split('\n');
const rows = lines.slice(1).map(line => {
const [id, name, value] = line.split(',');
return { id: Number(id), name, value: Number(value) };
});
const total = rows.reduce((sum, r) => sum + r.value, 0);
self.postMessage({
rows,
summary: `共 ${rows.length} 行,总值 ${total}`
});
};
注意:file.text() 返回 Promise,不会阻塞主线程。真正耗时的解析和计算被完全移到了 Worker 中。
进阶技巧:Worker 池与可转移对象
Worker 池
如果频繁创建和销毁 Worker,开销会累积。更好的做法是维护一个 Worker 池,复用固定数量的 Worker 来处理任务队列。简单实现思路:
class WorkerPool {
constructor(size, script) {
this.workers = Array.from({ length: size }, () => new Worker(script));
this.idle = [...this.workers];
this.queue = [];
}
run(data) {
return new Promise((resolve) => {
const task = { data, resolve };
const worker = this.idle.pop();
if (worker) this._execute(worker, task);
else this.queue.push(task);
});
}
_execute(worker, task) {
worker.onmessage = (e) => {
task.resolve(e.data);
const next = this.queue.shift();
if (next) this._execute(worker, next);
else this.idle.push(worker);
};
worker.postMessage(task.data);
}
}
可转移对象(Transferable Objects)
默认情况下,postMessage 会结构化克隆数据,大数组或 ArrayBuffer 的拷贝成本很高。如果数据不再需要留在发送方,可以用转移的方式零拷贝移交:
// 主线程
const buffer = new ArrayBuffer(1024 * 1024 * 50); // 50MB
worker.postMessage({ buffer }, [buffer]);
// 此后主线程的 buffer 变为不可用(byteLength 为 0)
在 Worker 中接收后,这块内存就归 Worker 所有。处理完再转移回来即可。对于图像处理、音视频分析等场景,这能显著降低内存占用和延迟。
什么时候不该用 Web Worker?
Web Worker 不是银弹。以下情况不建议使用:
- 任务非常轻量(几毫秒内完成)。创建 Worker 和消息传递的开销可能比直接执行还大。
- 需要频繁访问 DOM。Worker 无法操作 DOM,来回通信反而增加复杂度。
- 需要共享复杂状态。Worker 之间不共享内存(除非用 SharedArrayBuffer),状态同步需要额外设计。
一个实用的判断标准:如果任务耗时超过 50ms,就值得考虑放进 Worker。
总结
Web Worker 的本质是把“计算”和“渲染”分离到不同线程。主线程专注于用户交互和界面更新,Worker 专注于纯计算任务。通过 postMessage 通信、可转移对象优化、Worker 池复用,你可以构建出流畅且响应迅速的前端应用。
下次遇到页面卡顿,先问自己:这段代码真的必须跑在主线程上吗?如果答案是否定的,就把它交给 Worker。
未经允许不得转载:任鹏个人博客 » Web Worker 实战:用多线程解决主线程阻塞问题

