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,localStoragealert、大部分 window.xxx 挂载的 API 在 Worker 里同样访问不到。Worker 里能用的是一套更精简的全局环境:fetchsetTimeoutconsoleURLBlob 这些和 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 序列化——结构化克隆能处理 DateMapSetArrayBuffer 这些 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 } }

有了 typetaskId,主线程就能稳定区分进度、成功、失败和已经过期的任务,不用等 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 和统一的错误结构去对齐。