Web Worker 多线程实战:让浏览器不再卡顿
JavaScript 是单线程的,但浏览器不是。掌握 Web Worker,让耗时计算不再阻塞 UI。
有一次我在做图片压缩功能的时候,用户选了 20 张高清图同时压缩,结果整个页面直接冻住了——按钮点不了、滚动条拉不动、进度条也不动。用户还以为浏览器崩了,疯狂刷新。后来我用 Web Worker 把压缩逻辑丢到后台线程,主线程只负责更新 UI,问题瞬间解决。那一刻我才真正理解:「不要在主线程做重活」不是说说而已。
说实话,Web Worker 的 API 设计真的很「古早味」。postMessage + onmessage 这套通信机制,写起来跟十年前的 WebSocket 一样啰嗦。社区已经有 Comlink 这样的库把它封装成了 RPC 风格的调用,用起来才像是现代 JavaScript。但原理你还是得懂,不然出了 bug 你都不知道数据为什么传不过去。
1. 为什么需要 Web Worker?
浏览器的渲染进程中,JavaScript 执行和 UI 渲染共享同一个主线程。这意味着:当你的 JS 在执行一个耗时 500ms 的计算时,这 500ms 内页面是完全卡死的——不响应点击、不更新动画、不渲染新内容。
人眼对流畅性的感知阈值大约是 16.6ms(60fps),也就是说主线程上任何超过 16ms 的同步任务都可能造成可感知的卡顿。而图片处理、加密解密、大数据排序等操作,动辄几百毫秒甚至几秒,放在主线程上简直是灾难。
典型的「主线程杀手」场景:
- • 大文件的 Hash 计算(如 MD5、SHA-256)
- • 图片/视频的像素级处理(滤镜、压缩、水印)
- • 大型 JSON 的解析与序列化(10MB+)
- • 复杂的数学运算(加密、机器学习推理)
- • CSV/Excel 数据的批量转换和排序
2. Web Worker 基础用法
Web Worker 的核心思想很简单:创建一个独立的线程,通过消息传递(postMessage)与主线程通信。Worker 线程有自己的全局作用域(不是 window,而是 self),不能直接操作 DOM,但可以使用 fetch、IndexedDB、WebSocket 等 API。
// worker.ts — Worker 线程
self.onmessage = (e: MessageEvent) => {
const { imageData, quality } = e.data
// 耗时的图片压缩计算(不会阻塞主线程)
const result = compressImage(imageData, quality)
// 将结果发回主线程
self.postMessage({ compressed: result })
}// main.ts — 主线程
const worker = new Worker(
new URL('./worker.ts', globalThis._importMeta_.url),
{ type: 'module' }
)
worker.onmessage = (e) => {
console.log('压缩完成', e.data.compressed)
updateProgressBar(100) // UI 一直是流畅的
}
worker.postMessage({ imageData, quality: 0.8 })3. 数据传输的性能陷阱
postMessage 默认使用结构化克隆算法来传递数据。这意味着数据会被完整复制一份。如果你传递一个 50MB 的 ArrayBuffer,浏览器会在内存中再分配 50MB 来存放副本——不仅浪费内存,复制过程本身也需要时间。
结构化克隆(慢)
数据被完整拷贝一份
50MB 数据 → 占用 100MB 内存
大对象拷贝耗时 50-200ms
Transferable(快)
所有权转移,零拷贝
50MB 数据 → 仍然只占 50MB
传输耗时接近 0ms
// 使用 Transferable 零拷贝传输
const buffer = new ArrayBuffer(50 * 1024 * 1024) // 50MB
// 第二个参数指定 transfer list
worker.postMessage({ data: buffer }, [buffer])
// 注意:transfer 后原线程中 buffer.byteLength === 0
// 所有权已经转移给 Worker 线程了可以被 transfer 的对象包括:ArrayBuffer、MessagePort、ImageBitmap、OffscreenCanvas 等。记住一个原则:如果主线程不再需要这份数据,就用 transfer。
4. Worker 的三种类型
Dedicated Worker(专用 Worker)
最常用的类型。一个 Worker 实例只能被创建它的页面使用。适合页面级别的计算密集型任务,比如图片处理、文件转换。
Shared Worker(共享 Worker)
可以被同源的多个页面/iframe 共享。适合需要跨页面共享状态的场景,比如 WebSocket 连接池。但浏览器支持不太好,Safari 直到最近才支持。
Service Worker(服务 Worker)
特殊的 Worker,充当浏览器与网络之间的代理。可以拦截请求、缓存资源、实现离线功能。生命周期独立于页面,是 PWA 的核心技术。
5. 实战模式:Worker Pool
创建 Worker 有开销(加载脚本、初始化环境),频繁创建销毁不划算。实际项目中通常会维护一个 Worker 池,复用 Worker 实例来处理多个任务。
// workerPool.ts — 简易 Worker 池
class WorkerPool {
private workers: Worker[] = []
private queue: Array<{ data: any; resolve: Function }> = []
private idle: Worker[] = []
constructor(url: URL, size = navigator.hardwareConcurrency || 4) {
for (let i = 0; i < size; i++) {
const w = new Worker(url, { type: 'module' })
this.workers.push(w)
this.idle.push(w)
}
}
exec<T>(data: any): Promise<T> {
return new Promise((resolve) => {
const worker = this.idle.pop()
if (worker) {
this.run(worker, data, resolve)
} else {
this.queue.push({ data, resolve })
}
})
}
private run(worker: Worker, data: any, resolve: Function) {
worker.onmessage = (e) => {
resolve(e.data)
// 任务完成,归还到空闲池
const next = this.queue.shift()
if (next) this.run(worker, next.data, next.resolve)
else this.idle.push(worker)
}
worker.postMessage(data)
}
terminate() {
this.workers.forEach(w => w.terminate())
}
} 默认池大小使用 navigator.hardwareConcurrency,它会返回用户 CPU 的逻辑核心数。4 核 CPU 就创建 4 个 Worker,充分利用多核能力而不会过度竞争资源。
6. Comlink:让 Worker 像调函数一样简单
Google 出品的 Comlink 库把 Worker 的通信封装成了 RPC 风格。你在 Worker 里 expose 一个对象,主线程就能像调用普通函数一样使用它——底层的 postMessage 完全被隐藏。
// worker.ts
import { expose } from 'comlink'
const api = {
async compress(data: Uint8Array, quality: number) {
// 耗时操作...
return compressedData
},
async hash(data: Uint8Array) {
return calculateSHA256(data)
}
}
expose(api)// main.ts
import { wrap } from 'comlink'
const worker = new Worker(new URL('./worker.ts', globalThis._importMeta_.url), { type: 'module' })
const api = wrap<typeof import('./worker').api>(worker)
// 就像调用普通异步函数一样!
const compressed = await api.compress(imageData, 0.8)
const hash = await api.hash(fileData)