Web Worker 实践:把耗时任务从主线程里挪出去
CSV 解析这种事,写在主线程里,用户上传一个几万行的文件,页面直接卡死几秒;原封不动挪进 Worker 里跑,页面照样能滚动、能点按钮,总耗时甚至没有明显变化。这组对比说明白了 Worker 到底解决什么问题:它不是让代码跑得更快,而是让主线程腾出手来干别的事。
浏览器主线程要做的事情很多:执行 JS、响应用户输入、样式计算、布局、绘制。任何一段 JS 只要连续占用几百毫秒,中间不出让出(yield),用户能明显感觉到点击没反应、滚动卡顿。Web Worker 提供的是另一条独立线程,代码在里面跑,不管跑多久,都不会挡住主线程处理输入和渲染。这学期我们组接的几个后台页面里,数据量都在往上涨,数据导入、日志分析、图片批处理这类任务越来越常出现,Worker 也就从"听说过但没用过"变成了这个月我需要认真弄清楚的东西。
先弄清楚:什么活儿能扔给 Worker,什么不能
Worker 和主线程之间隔着一道过不去的限制,不是"哪个快用哪个"这么简单的取舍——Worker 里没有 document、没有 window,拿不到 DOM,也拿不到大部分浏览器 UI API。这不是当前实现的限制,是规范层面的设计:Worker 运行在独立的全局上下文(DedicatedWorkerGlobalScope),根本不共享主线程的 window 对象。
这条限制决定了判断标准不是"这段代码耗时多长",而是"这段代码有没有 DOM 依赖":
1// 不能放进 Worker:里面有 DOM 读写 2function renderRows(data) { 3 const fragment = document.createDocumentFragment() 4 data.forEach((row) => { 5 const tr = document.createElement('tr') 6 tr.textContent = row.join(',') 7 fragment.appendChild(tr) 8 }) 9 document.querySelector('#table-body').appendChild(fragment) 10} 11 12// 可以放进 Worker:纯计算,输入输出都能序列化 13function parseAndValidate(text) { 14 const lines = text.split('\n') 15 return lines.map((line) => line.split(',')) 16}
第一次踩这个坑是我把一个"解析 + 渲染"混在一起的函数直接搬进 Worker 文件里,结果控制台报 document is not defined。这才意识到迁移到 Worker 不是简单地"挪文件",而是要先把计算和 DOM 操作拆干净:Worker 只管算,算完把结果通过消息传回主线程,由主线程负责渲染。
除了 DOM,localStorage、alert、大部分 window.xxx 挂载的 API 在 Worker 里同样访问不到。Worker 里能用的是一套更精简的全局环境:fetch、setTimeout、console、URL、Blob 这些和 DOM 无关的能力都保留着,但凡是要碰界面的,一律不行。
判断一个任务该不该丢进 Worker,我现在会依次问三个问题:
- 这段计算连续跑起来会不会超过一两百毫秒?
- 它中途需不需要读写 DOM 或调用 UI API?
- 输入输出能不能变成可以跨线程传递的数据(普通对象、数组、
ArrayBuffer这些)?
三个问题都过了关,才值得为它多开一个 Worker;哪怕计算量大,只要中间夹杂 DOM 操作,要么先把 DOM 部分剥离出来,要么就得接受它留在主线程、用别的手段(比如分片配合 requestIdleCallback)来缓解卡顿。
不适合 Worker 的还有一种情况容易被忽略:通信本身有开销。如果一个任务虽然不碰 DOM,但需要和主线程极高频率地来回传小数据包,消息传递的序列化成本可能反而比计算本身还贵。这种细碎的双向通信任务,不如老实留在主线程,配合 requestAnimationFrame 或分帧处理。
基础用法
主线程创建 Worker:
1const worker = new Worker('/workers/sum.js') 2 3worker.postMessage({ 4 numbers: [1, 2, 3, 4, 5], 5}) 6 7worker.onmessage = function (event) { 8 console.log('result:', event.data.result) 9} 10 11worker.onerror = function (event) { 12 console.error('worker error:', event.message) 13}
Worker 文件:
1self.onmessage = function (event) { 2 var numbers = event.data.numbers 3 var result = numbers.reduce(function (sum, value) { 4 return sum + value 5 }, 0) 6 7 self.postMessage({ result: result }) 8}
通信基于消息传递,主线程和 Worker 不共享内存、不共享普通对象。postMessage 传过去的数据默认走的是结构化克隆算法(structured clone),不是引用传递,也不是 JSON 序列化——结构化克隆能处理 Date、Map、Set、ArrayBuffer 这些 JSON 处理不了的类型,但函数、DOM 节点这些还是传不过去。
结构化克隆和 Transferable 对象:差的不是一星半点
这次让我真正重视通信成本的,是拿一个 20MB 左右的 ArrayBuffer 做对比测试。用普通方式 postMessage 传过去,浏览器要把整块内存复制一份给 Worker,观察 Performance 面板能看到明显的一段同步耗时;换成 Transferable Objects,同样的数据传过去几乎是瞬时的。
1// 结构化克隆:数据被复制一份,两边各有一份内存 2const buffer = new ArrayBuffer(1024 * 1024 * 20) 3worker.postMessage({ buffer: buffer }) 4// 这里主线程的 buffer 还能继续用,因为它没有被转移 5 6// Transferable:所有权直接转移,没有复制开销 7const buffer2 = new ArrayBuffer(1024 * 1024 * 20) 8worker.postMessage({ buffer: buffer2 }, [buffer2]) 9// 转移之后,主线程里的 buffer2.byteLength 会变成 0,不能再用
这两种方式的差异不是"哪个 API 更新潮",是内存模型完全不同:结构化克隆意味着两份内存同时存在,数据量一大,复制本身就要花时间还要占内存;Transferable 是把底层内存的所有权直接移交给对方,浏览器不需要复制,代价是转移方立刻失去访问权。这是一笔典型的性能和所有权的交换,拿到之前那份"要不要用 Transferable"的判断依据很简单:数据量小、传输频率低,用普通方式省心;数据量大或者传输频繁(比如持续从主线程喂视频帧、音频数据给 Worker),Transferable 基本是必选项。
处理大文件、二进制数据、Canvas 像素数据这类场景,这个细节会直接反映在体感流畅度上。
分块读取,别一口气吃下整个文件
对于更大的数据,还要考虑分块读取。一次性把整个文件读进内存再传给 Worker,会造成内存峰值过高——文件内容会同时以原始 File、字符串、解析后的数组几种形式存在。
更稳的做法是按块读,用 TextDecoder 处理跨块的字符边界:
1async function readFileByChunk(file, onChunk) { 2 const decoder = new TextDecoder('utf-8') 3 const chunkSize = 1024 * 1024 4 let offset = 0 5 let rest = '' 6 7 while (offset < file.size) { 8 const blob = file.slice(offset, offset + chunkSize) 9 const buffer = await blob.arrayBuffer() 10 const text = rest + decoder.decode(buffer, { stream: offset + chunkSize < file.size }) 11 const lines = text.split('\n') 12 13 rest = lines.pop() || '' 14 await onChunk(lines, offset / file.size) 15 offset += chunkSize 16 } 17 18 if (rest) { 19 await onChunk([rest], 1) 20 } 21}
这里的 rest 是关键:一行 CSV 完全可能刚好被切在两个 chunk 中间,直接对每块单独 split('\n') 会把一条记录拆坏。真实的 CSV 格式还要处理引号内换行、转义字符、不同编码,这部分我会交给成熟解析库去处理语法细节,但"分块读、分批处理、分批回报进度"这个方向是自己动手写的。
CSV 解析:主线程和 Worker 各司其职
假设用户上传一个大 CSV,如果全部在主线程解析,页面会明显卡顿。
主线程:
1const worker = new Worker('/workers/parse-csv.js') 2 3function parseCsv(file) { 4 worker.postMessage({ file: file }) 5} 6 7worker.onmessage = function (event) { 8 var type = event.data.type 9 var payload = event.data.payload 10 11 if (type === 'progress') { 12 updateProgress(payload.percent) 13 } 14 15 if (type === 'done') { 16 renderTable(payload.rows) 17 } 18}
Worker:
1// Worker 内同样实现分块读取,和前面主线程讲的思路完全一致 2async function readFileByChunk(file, onChunk) { 3 var decoder = new TextDecoder('utf-8') 4 var chunkSize = 1024 * 1024 5 var offset = 0 6 var rest = '' 7 8 while (offset < file.size) { 9 var blob = file.slice(offset, offset + chunkSize) 10 var buffer = await blob.arrayBuffer() 11 var text = rest + decoder.decode(buffer, { stream: offset + chunkSize < file.size }) 12 var lines = text.split('\n') 13 rest = lines.pop() || '' 14 await onChunk(lines, offset / file.size) 15 offset += chunkSize 16 } 17 18 if (rest) { 19 await onChunk([rest], 1) 20 } 21} 22 23self.onmessage = async function (event) { 24 var file = event.data.file 25 var rows = [] 26 27 await readFileByChunk(file, async function (lines, progress) { 28 lines.forEach(function (line) { 29 rows.push(line.split(',')) 30 }) 31 32 self.postMessage({ 33 type: 'progress', 34 payload: { percent: Math.round(progress * 100) }, 35 }) 36 }) 37 38 self.postMessage({ 39 type: 'done', 40 payload: { rows: rows }, 41 }) 42}
Worker 内部用 file.slice 按 1 MB 逐块读取,rest 变量拼接跨块被截断的那行,进度直接从 offset / file.size 算出来,不需要先知道总行数。真实项目还需要处理引号、转义、编码这些问题。
还有一点容易被忽略:Worker 传回主线程的数据同样不能太大。解析完十万行后,如果一次性把完整二维数组传回来,主线程接收和渲染依然会卡——这时候瓶颈从"解析"转移到了"传输 + 渲染",Worker 白忙活了。更实际的做法是分页返回预览数据,完整数据留在 Worker 内部或者交给后端流程继续处理,主线程只拿它眼下要展示的那一小部分。
错误处理要统一
Worker 内部抛出的错误不能只靠打开控制台去看,得显式传回主线程:
1self.onmessage = async function (event) { 2 try { 3 var result = await runTask(event.data) 4 self.postMessage({ 5 type: 'success', 6 payload: result, 7 }) 8 } catch (error) { 9 self.postMessage({ 10 type: 'error', 11 error: { 12 code: error.code || 'WORKER_ERROR', 13 message: error.message || '任务处理失败', 14 }, 15 }) 16 } 17}
主线程统一处理:
1worker.onmessage = function (event) { 2 var message = event.data 3 4 if (message.type === 'error') { 5 showToast(message.error.message) 6 reportError(message.error) 7 return 8 } 9 10 handleResult(message.payload) 11}
这和接口错误处理是同一个原则:错误结构要固定,调用方才不用为每种消息类型单独写一套判断逻辑。另外 worker.onerror 和消息里手动传回的 error 是两回事——onerror 抓的是 Worker 脚本本身的未捕获异常(比如语法错误、加载失败),业务逻辑里 try/catch 住的错误得自己组装消息传回来,两者都得处理,只处理一种会漏掉另一半的异常场景。
消息协议要提前设计
Worker 通信如果只传随意结构的对象,任务一多消息结构就会变得难以维护。这次我给自己定的协议大致是这样:
1type WorkerMessage = 2 | { type: 'start'; taskId: number; payload: unknown } 3 | { type: 'progress'; taskId: number; percent: number } 4 | { type: 'success'; taskId: number; payload: unknown } 5 | { type: 'error'; taskId: number; error: { code: string; message: string } }
有了 type 和 taskId,主线程就能稳定区分进度、成功、失败和已经过期的任务,不用等 Worker 逻辑变复杂之后再回头补协议。这里没有用 TS 4.9 才有的 satisfies(这个操作符要到 11 月的 4.9 版本才发布),普通的联合类型加类型注解已经够用。
取消任务
Worker 任务可能持续很久,用户切换页面或者重新上传文件时,需要能取消旧任务。
最简单粗暴的方式是直接终止 Worker:
1worker.terminate()
如果希望复用同一个 Worker 而不是每次都重新创建,可以给任务编号:
1let currentTaskId = 0 2 3function startTask(payload) { 4 currentTaskId += 1 5 worker.postMessage({ 6 taskId: currentTaskId, 7 payload: payload, 8 }) 9} 10 11worker.onmessage = function (event) { 12 if (event.data.taskId !== currentTaskId) { 13 return 14 } 15 16 handleMessage(event.data) 17}
这样旧任务即使算完了才返回结果,也不会覆盖新任务的结果。
如果任务本身是分段循环执行的,也可以在 Worker 内部检查一个取消标记,而不是只能用 terminate 一刀切。terminate 简单有效,但会连带丢掉 Worker 内部所有状态;如果打算长期复用同一个 Worker(比如做成一个小型任务池),任务级别的取消会更精细,不用每次都承担重新创建 Worker 的成本。
SharedWorker:能跨标签页共享,但用得少
翻文档的时候注意到还有一个 SharedWorker,可以被同源的多个标签页共享同一个 Worker 实例,通信通过 port 而不是直接 postMessage。理论上如果一个站点有多个标签页都要做同样的后台计算(比如都要维护一份 IndexedDB 索引),SharedWorker 能省掉重复计算。实际项目里我们目前用不上——业务场景基本是单标签页操作,SharedWorker 调试起来也比普通 Worker 麻烦(各浏览器的调试面板对它的支持程度不一),这次先记下来,没有实际投入使用。
OffscreenCanvas:Chrome 系能用,Safari 还早
Worker 里原本碰不到 Canvas,因为 Canvas 依赖 DOM。OffscreenCanvas 这个新 API 想解决的正是这个问题——把 Canvas 的绘制上下文转移到 Worker 里,图片处理、复杂图形运算就可以完全在后台线程完成,不用来回倒腾像素数据。
试着在内部一个图片批处理小工具里接了一下:
1const canvas = document.querySelector('canvas') 2const offscreen = canvas.transferControlToOffscreen() 3 4worker.postMessage({ canvas: offscreen }, [offscreen])
Worker 里:
1self.onmessage = function (event) { 2 var canvas = event.data.canvas 3 var ctx = canvas.getContext('2d') 4 // 在 Worker 里直接绘制、处理像素,不需要来回传数据给主线程 5}
Chrome 这边的支持已经比较完善,能正常用。但 Safari 目前还不支持 OffscreenCanvas,这个功能只能在 Chrome 系浏览器(包括用 Chromium 内核的 Edge)里用,面向 Safari 用户的页面暂时用不了这一套,得留一个主线程 Canvas 的兜底方案,或者干脆先不在生产环境里依赖它。这次我们只在内部工具(不考虑 Safari 兼容)里小范围试了一下,对外的功能还是走的老办法:Worker 算完像素数据传回来,主线程负责绘制。
打包工具里的 Worker
在现在的构建工具里,Worker 可以用模块方式引入,不用再手写死路径:
1const worker = new Worker( 2 new URL('./worker.ts', import.meta.url), 3 { type: 'module' } 4)
这种写法比手写 /workers/xxx.js 更适合工程化项目,因为构建工具能处理路径、hash 和 TypeScript 编译。团队内部工具链这次顺带升级到了 Vite 3(7 月刚发布不久),这种写法在 Vite 3 里开箱即用;用 webpack 5 的项目同样支持这种写法,但要注意 worker-loader 或者原生 new Worker(new URL(...)) 两种方式别混用,容易导致构建产物重复。
不管用哪个工具,都要确认 Worker 的构建产物和部署路径是否正确——Worker 文件因为路径配置问题 404,是这次调试里遇到最多的一类问题,报错信息还经常不直观,容易一开始怀疑错方向。
Node 端要不要也用 Worker Threads
顺带查了一下 Node 这边的对应能力:Node.js 从 10.5 起就带了 worker_threads 模块(早期要加 flag,12 之后默认可用),用来在 Node 里做 CPU 密集计算,思路和浏览器 Worker 很像,也是消息传递、也支持 Transferable 的 ArrayBuffer。团队里跑数据处理脚本的机器目前还是 Node 16(今年 4 月发布的 Node 18 要等到 10 月底才转 LTS,现阶段线上环境还是以 Node 16 LTS 为准,18 只在个别人本地环境尝鲜),等 10 月底转正后再考虑升级基础设施。前端和 Node 两边的 Worker 概念虽然接口不完全一样,但"把 CPU 密集任务挪出主/事件循环线程"这条思路是相通的,理解了浏览器这边,看 Node 那边上手会快很多。
Worker 数量也要控制
不是每个任务都值得单开一个 Worker。
Worker 本身有启动和内存成本,批量任务场景里如果一次创建几十个 Worker,页面同样会变慢,观察内存面板能看到明显往上涨。目前我倾向控制在一个 Worker 或者一个小型 Worker 池,按任务类型限制并发:
1轻任务:主线程或单 Worker 2重任务:单 Worker + 进度回报 3多文件批处理:小 Worker 池 + 任务队列
这和请求并发控制是同一个思路,目标不是把机器资源用满,而是让页面始终保持可响应。
先录 Performance,再决定要不要上 Worker
这次把导入校验页面的解析逻辑挪进 Worker 之后,页面响应确实明显变好——输入框、滚动、按钮点击都不再卡顿。但这不代表 Worker 是默认该上的方案。
上 Worker 之前,我现在会先录一次 Performance 面板,确认卡顿真的是主线程被长任务占满,而不是网络慢、图片没压缩、渲染列表过大或者布局抖动。如果瓶颈根本不在 JS 计算上,Worker 帮不上忙——它解决的只是"计算占用主线程"这一类问题,不解决接口响应慢,也不解决一次性渲染几千个 DOM 节点导致的卡顿。
上了 Worker 之后,通信成本、结构化克隆和 Transferable 的取舍、统一的错误结构、任务取消机制、构建产物路径,这几件事都得配套处理好,不然 Worker 只是把复杂度从主线程搬到另一条线程,出问题时反而更难查——两个线程之间的消息往来,没有堆栈能直接告诉你哪一步出的错,得靠协议里那个 taskId 和统一的错误结构去对齐。