低代码报表里有一个场景:接口返回十万行明细,前端需要按多个维度聚合,再生成透视表。计算函数在本机只要一百多毫秒,放到真实页面却会冻结滚动和输入。把它移进 Web Worker 后,第一次实现仍然卡,因为向 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 很容易从“优化”变成“更隐蔽的卡顿来源”。

这条边界和十万级数据渲染是同一件事的两面:那里讲如何把数据管线拆开测量,这里讲如何把其中的计算与传输真正搬离主线程。