4
0

把前端重计算丢进 Worker 前,我会先写清消息协议

最近给一个 AI 知识库前端做批量文本预处理时,页面在导入几百个 Markdown 后开始掉帧。把切分、去重和统计丢进 Web Worker 很容易,真正麻烦的是消息边界:谁发起任务、进度如何回传、取消怎样生效、迟到结果如何丢弃、异常怎样映射到 UI。

UI、Protocol、Worker、Reducer 组成的前端 Worker 消息协议流程图

原创示意图:把 UI 动作、协议信封、Worker 执行器和 Reducer 状态合并拆成稳定边界。 来源:Codex image generation

问题背景

MDN 的 Web Workers 文档说明,worker 能让脚本在后台线程运行,并通过 postMessage()onmessage 同主线程交换消息。这个模型适合 AI 应用里的本地预处理、向量前清洗、文件摘要预扫描和离线规则校验。我的场景是用户拖入一批文档后,前端要先做标题提取、块级切分、重复段落过滤和预估 token 统计。任务放在主线程会影响输入框、进度条和取消按钮。

踩坑和关键难点

第一个坑是传输内容没有收口。结构化克隆算法用于 worker 的 postMessage(),但 MDN 明确列出 Function 对象和 DOM 节点等内容无法被复制,原型链、属性描述符等信息也不会完整保留。早期我把带方法的 parser 实例塞进消息里,浏览器直接抛 DataCloneError,错误还出现在 worker 边界外。

第二个坑是缺少任务身份。重计算往往比用户操作慢,用户连续导入两批文件时,上一批结果可能晚到。如果消息里没有 jobId 和阶段状态,Reducer 只能凭时间猜测当前结果是否仍然有效。第三个坑是把错误和取消混在一起。用户主动取消、输入不合法、worker 内部异常、worker 被终止,都会表现为任务结束,但它们对应的 UI 文案、重试策略和日志级别并不相同。

解决思路

我现在会先写 TypeScript 消息契约,再移动计算代码。输入消息只保留 runcancel,输出消息拆成 progressresulterrorcancelled。所有消息都带 jobId,payload 只允许普通对象、数组、字符串、数字、布尔值、TypedArray 或可转移的 ArrayBuffer。

type WorkerIn = { type: 'run'; jobId: string; payload: PlainJob } | { type: 'cancel'; jobId: string };
type WorkerOut = { type: 'progress'; jobId: string; done: number; total: number } | { type: 'result'; jobId: string; data: WorkerResult } | { type: 'cancelled'; jobId: string } | { type: 'error'; jobId: string; message: string; code?: string };

这份契约让 UI 和 worker 各自只处理自己的边界。UI 发出任务意图,Reducer 根据 jobId 合并状态,worker 负责执行和上报。任务结束后,主线程只接受仍处于 running 状态的结果,旧任务的迟到消息直接丢弃并记录调试日志。

关键步骤

第一步是把 worker 入口变成稳定资产。Vite 官方文档推荐用 new Worker(new URL('./worker.js', import.meta.url), { type: 'module' }) 这类构造方式,让构建工具识别并处理 worker 文件。第二步是把传输层写薄,主线程只暴露 startJob()cancelJob()dispose(),worker 内部初始化 parser、规则表和缓存,避免跨线程传复杂对象。

第三步是给进度和错误做协议化。进度只汇报数字,不汇报 UI 文案。错误只传 codemessage 和必要上下文,堆栈留在调试日志里。如果调用形态已经很像远程方法,可以评估 Comlink。它在 README 中把自己定位为基于 postMessage 和 ES6 Proxy 的 RPC 封装,也提供 TypeScript 类型。

可复用经验

Worker 改造的第一收益是释放主线程,第二收益才是抽象计算模块。只要消息协议足够小,后面要增加取消、进度、批处理、超时和回放测试都比较自然。这次复盘后,我给前端重计算定了一个默认检查表:任务必须有 ID,消息必须可结构化克隆,状态必须可归并,取消和错误必须分流,worker 创建方式必须交给构建工具识别。

还有一个小经验是把协议文件放在主线程和 worker 都能引用的位置,比如 shared/worker-protocol.ts,并给每一种消息写一个最小单测。测试里只关心输入消息能否被序列化、输出消息能否被 Reducer 接住、取消后的迟到结果是否被丢弃。这样以后换解析器、换 worker 打包方式、或把同一套逻辑搬到 Electron 渲染进程时,协议仍然能作为边界线留下来,也方便审查。

主要来源

MDN: Using Web Workers

MDN: The structured clone algorithm

Vite: Web Workers

GitHub: GoogleChromeLabs Comlink

评论