低代码报表里有一个场景:接口返回十万行明细,前端需要按多个维度聚合,再生成透视表。计算函数在本机只要一百多毫秒,放到真实页面却会冻结滚动和输入。把它移进 Web Worker 后,第一次实现仍然卡,因为向 Worker 复制巨大对象本身就占用主线程和双倍内存。
Worker 解决的是执行线程,不自动解决数据传输、任务竞争和结果过期。
图 1:每次计算都有 taskId;用户切换条件后,旧任务即使完成也不能覆盖新结果。
先把对象数组变成紧凑列数据
十万条 {region, amount, timestamp} 对象包含大量属性名和字符串重复。服务层把可枚举字段编码成字典,数值放进 TypedArray:
type ColumnBatch = {
rowCount: number;
regionCodes: Uint16Array;
amounts: Float64Array;
timestamps: Float64Array;
regions: string[];
};发送时转移 ArrayBuffer 所有权,不做结构化克隆:
worker.postMessage(
{ type: "aggregate", taskId, batch },
[
batch.regionCodes.buffer,
batch.amounts.buffer,
batch.timestamps.buffer,
],
);转移后主线程里的这些 buffer 会被 detach,不能继续读。这个所有权变化必须写进接口,不能让调用方以为数据仍可复用。
任务协议要有版本和取消
type WorkerRequest =
| { version: 1; type: "aggregate"; taskId: string; batch: ColumnBatch; groupBy: string[] }
| { version: 1; type: "cancel"; taskId: string };
type WorkerResponse =
| { version: 1; type: "progress"; taskId: string; completedRows: number }
| { version: 1; type: "result"; taskId: string; result: AggregationResult }
| { version: 1; type: "error"; taskId: string; code: string; message: string };Worker 无法在一段不让出执行权的长循环中及时处理 cancel。聚合按批次运行,每处理几千行检查取消集合,并通过 setTimeout(0) 或分段消息给事件循环机会。
页面只接受当前任务结果
let activeTaskId: string | null = null;
function runAggregation(input: Input) {
if (activeTaskId) worker.postMessage({ type: "cancel", taskId: activeTaskId });
activeTaskId = crypto.randomUUID();
worker.postMessage(buildRequest(activeTaskId, input), transferList(input));
}
worker.onmessage = ({ data }: MessageEvent<WorkerResponse>) => {
if (data.taskId !== activeTaskId) return;
if (data.type === "result") render(data.result);
};取消是性能优化,taskId 检查才是正确性保证。旧 Worker 消息可能已经在队列中,不能假设 cancel 一定赶得上。
内存峰值比平均耗时更容易被忽视
我们同时记录主线程和 Worker 的输入字节、结果字节和峰值。数据解析阶段如果先创建对象数组,再转换 TypedArray,会短时间保留两份数据。更好的方式是流式解析直接填充列缓冲,或让服务端输出更适合计算的二进制/列格式。
| 风险 | 观测 | 处理 |
|---|---|---|
| postMessage 克隆太慢 | 发送前后主线程长任务 | 使用 Transferable |
| Worker 内存峰值 | 每批字节与浏览器崩溃率 | 分块、限制最大行数 |
| 用户快速切换 | 取消数与过期结果数 | taskId + 分段取消 |
| 结果仍然很大 | 返回字节与渲染耗时 | 只返回聚合结果或视窗数据 |
Worker 不能替代服务端边界
把计算移出主线程后页面不再卡,并不意味着让浏览器承担无限数据合理。超过一定规模,我们仍要求服务端聚合、分页或预计算。Worker 是让中等规模交互更流畅的工具,不是把数据仓库搬到用户电脑的理由。
最终的性能收益来自一整套边界:紧凑表示降低传输成本,Transferable 避免复制,taskId 防止旧结果覆盖,分块计算支持取消,数据规模上限控制内存。只把一个函数包进 new Worker(),通常只能把卡顿从一个位置移到另一个位置。
上线后我在监控里保留四个数字:单次任务传输字节、Worker 内存峰值、取消及时率(cancel 发出后任务实际停止的时间)、以及过期结果丢弃数。前两个衡量收益,后两个衡量这个方案有没有引入新的竞态。没有这些观测,Worker 很容易从“优化”变成“更隐蔽的卡顿来源”。
这条边界和十万级数据渲染是同一件事的两面:那里讲如何把数据管线拆开测量,这里讲如何把其中的计算与传输真正搬离主线程。