先说个场景:某天你接了一个实时行情/日志聚合页,需求是把一路持续推送的 JSON 流接进页面,更新表格。第一版你直接在fetch响应体上getReader(),边读边解析边innerHTML,跑起来页面倒是能出数,但只要数据一上量,滚动就开始抖,点按钮要等很久才反应过来。把解析搬进 Worker 之后,主线程轻松了,可你又发现:postMessage传一个大 JSON 字符串,克隆和序列化的开销反而把高吞吐的流式场景拖垮了。
所以“Web Streams API 和 Web Workers 一起用”并不是两个 API 的叠加,而是处理实时数据流时一条绕不开的性能路径。这篇内容我就围绕一个真实可跑的 Web Component 案例,把ReadableStream、TransformStream、Transferable、背压这些点拆开讲,最后给一套可以照着搭的代码骨架和排坑清单。适合正在做实时监控页、大盘页面、日志流、可视化组件的开发同学参考。
1. 为什么必须把 Web Streams 和 Worker 放在一起用
1.1 单靠 Web Streams 的现实瓶颈
Web Streams API 给了前端一套标准化的流式处理能力,fetch的res.body就是一个ReadableStream,你可以pipeThrough接上TransformStream做切片、解码、格式转换,最后pipeTo到某个WritableStream,或者自己getReader()一点一点读。
这套机制解决的是“数据不用一次性全到内存”的问题。它真正消灭掉的是“等整个响应下载完再处理”的等待时间,让数据边到边消费。
但这里有个很多人没意识到的隐形前提:流处理的回调、转换逻辑、解析逻辑,默认全在主线程上执行。
以最常见的 JSON 流为例。一条实时行情数据过来,JSON.parse要跑、聚合计算要跑、V8 的垃圾回收要跑,数据量一上来,主线上一帧 16ms 的预算很快就被吃光。页面渲染、点击事件、滚动都排队等。这时候 Web Streams 的背压机制其实已经能派上用场:它会让上游暂时停一停,别一股脑推数据。但上游停下来了,卡顿只是被延后,不是被解决。
单用 Web Streams,真正的问题不是“不会流式”,而是“负责干活的那只手,同时也是要画画、要点鼠标、要响应用户的那只手”。
1.2 单靠 Worker 的隐藏成本
把解析和聚合丢给 Web Worker 是大多数人的第一反应,方向是对的。Worker 不管怎么造,都不会阻塞主线程渲染。
但单用 Worker 处理流式数据,会遇到一个非常现实的性能反例:
- 主线程从流里读到一小段数据,先转成字符串或对象;(编码 + 序列化)
- 通过
postMessage发给 Worker,Chrome 默认是结构化克隆;(复制一份) - Worker 收到后再解析、运算;算出结果再克隆回来;(又复制一份)
- 主线程接过结果再渲染。
如果数据是“一次一批、批次不大、频率不高”的场景,这个模型完全没问题。可实时数据流的特点恰恰相反:单块数据小,但频率极高,总量可能很大。每一次克隆、序列化、反序列化都要摊销进主线程的帧预算。数据一密,主线程就变成了一个纯粹的“搬运工”,高负荷但不产出,页面的卡顿其实是从克隆开销里悄悄溜出来的。
Worker 不是不够快,是“整块整块搬运”这种模式和流式数据的节奏对不上。
1.3 组合方案的核心性能模型
把两者组合起来,核心思路可以总结成一句话:
让主线程只负责“拿到流、放好流、接收小结果、渲染”;让 Worker 负责“消费流、解析、聚合、产生结果”。
这样设计后,各路分工变成这样:
- 主线程通过
fetch/ WebSocket 拿到一个ReadableStream; - 直接把流对象本身转移(transfer)给 Worker,字节不用中间复制;
- Worker 在流上接
TextDecoderStream、拆行TransformStream、解析聚合TransformStream; - Worker 只把“已经算好的结果”以较小频率、较小体积的消息回传;
- 主线程用
requestAnimationFrame做渲染合并,把消息变成一帧一帧刷新。
这里有两个关键转变:
- 数据路径从“网络→主线程→克隆→Worker”变成了“网络→Worker→小消息→主线程”;
- 数据搬运时间从“随块复制”变成了“转移引用 + 结果打包”。
后面你会看到,“转移 Stream”和“转移 ArrayBuffer”这些细节,才是决定这条路到底快不快、稳不稳的地方。
2. 跨线程传输、背压与消息协议设计
2.1 Transferable:真正零拷贝的搬运方式
要继续往下走,必须先搞懂postMessage里的 Transferable 机制。
常规的postMessage(msg),浏览器会把消息里的对象做结构化克隆:对象被序列化,然后在另一端重新构建。对大数据、高频场景来说,这个过程既有 CPU 成本,也有内存临时开销。
但如果对象是ArrayBuffer、MessagePort、ReadableStream/WritableStream/TransformStream这类 Transferable 对象,就可以把它“转移”而不是“复制”。
转移的意思是:对象的所有权从当前线程移交给另一个线程,原线程这边失去访问能力。底层内存不复制,只是句柄换了个持有者。postMessage的第二个参数接收一个转移列表:
worker.postMessage({ stream }, [stream]);这里stream会被转移,原线程再用它去.getReader()会直接抛异常。
对字节数据来说更常见的是转移ArrayBuffer:
worker.postMessage({ buf: arrayBuffer }, [arrayBuffer]);转移后,原来的Uint8Array视图会变成 detached,byteLength变成 0。这点极容易踩坑,后文会专门讲。
那什么时候适合转移?我的经验判断标准是:当这块内存你会整个交给另一边,且本线程之后不再碰它时,果断转移。如果你还要继续读它原有的内容,走转移反而麻烦,不如克隆或者立刻 copy 一份子视图。
2.2 背压到底压在哪
背压(backpressure)是流式处理里最容易懂但也最容易做错的概念。
在 Web Streams 里,每个ReadableStream内部都有一个队列和一个desiredSize。当消费者把数据读得够快,队列空,desiredSize是正数;当生产者enqueue太快、消费者没有及时read(),队列堆积,desiredSize会变负,甚至降到 0,这时上游就应该“停下来等”。
传统直觉是:背压只在“生产者给controller.enqueue的时候”才生效。但实际上,你往一个流里面塞数据时,如果队列超过 highWaterMark,enqueue()只是让队列继续涨,并不阻塞当前调用。真正的约束,往往是靠消费者端的读取节奏传递回去的。
放到我们的例子里:
fetch的响应体自带背压能力,你不读,网络层就不会无限推送内存;- 当 Worker 消费这个流时,Worker 的消费节奏直接作用于网络层;
- Worker 内部
pipeThrough出来的下游如果处理得慢,TransformStream队列会反映出负 desiredSize,间接让上游的读取变慢。
但有一个地方网络层管不着:自定义的可写侧、内存聚合、外部高频数据源(比如 WebSocket)。WebSocket 的onmessage推送不会因为你想休息就自己停下来,你得自己做有界缓冲、丢弃策略,或者通过降低处理频率来控制。这点在写实时组件时必须分清楚:哪些背压是框架帮你扛的,哪些只能自己扛。
2.3 消息协议:控制流与数据流分离
如果你在 Worker 里消费整个ReadableStream,主线程和 Worker 之间就不再是“你来一块,我回一块”的笨模式了,而是纯粹的控制消息 + 定时结果消息。
我建议的消息协议大概是:
| 方向 | 消息类型 | 载荷 | 说明 |
|---|---|---|---|
| 主线程 → Worker | init | MessagePort | 建立后续双工通信的端口 |
| 主线程 → Worker | stream-start | ReadableStream(转移) | 把流整体交给 Worker |
| 主线程 → Worker | abort | 空 | 主动终止流消费 |
| Worker → 主线程 | batch | rows 数组 + cursor | 一段时间内的聚合结果 |
| Worker → 主线程 | end | cursor | 源流正常结束 |
| Worker → 主线程 | error | 错误信息 | 消费或解析出错 |
这套协议的要点是:不要让 Worker 每解析完一条就给你发一条消息。消息本身有固定开销,小消息太频繁反而比写几行代码更浪费时间。宁可让 Worker 攒一个 100ms 窗口,用一条batch带回几十上百行聚合结果。
其实主线程也不需要在每一条 batch 到达时立刻操作 DOM。可以让主线程把 batch 先推进数组,然后在下一个requestAnimationFrame里统一渲染,自然就消掉了渲染毛刺。
3. 实操:一小时搭一个实时数据流 Web 组件
3.1 场景设定
我用一个“实时行情聚合表”来演示,因为这类需求很典型:后端提供一个application/x-ndjson的流式接口,每行是一条 JSON 数据,比如:
{"ts":1690000000000,"symbol":"AAA","price":12.34,"qty":100} {"ts":1690000001000,"symbol":"BBB","price":56.78,"qty":200}组件要做的不是逐单渲染每一条,而是按股票代码聚合:维护每个 symbol 的最新价、累计成交量、累计笔数,周期性刷新到表格里。这个场景天然合适 Worker:JSON 解析、Map 聚合、按时间窗口输出,全是内存内计算,不依赖 DOM,也不依赖主线程。
我们先定义组件的对外接口:一个自定义元素live-tick-table,通过src属性指定流地址。
<live-tick-table src="/api/v1/ticks.ndjson"></live-tick-table>3.2 主线程组件实现
组件主线程的职责非常克制:创建 Worker、发起流式请求、把流转移给 Worker、接收回传的聚合结果、按帧渲染。
class LiveTickTable extends HTMLElement { #worker = null; #port = null; #abort = new AbortController(); #rows = []; #rafId = 0; #cursor = 0; constructor() { super(); this.attachShadow({ mode: 'open' }); this.shadowRoot.innerHTML = `<div class="table"></div>`; } connectedCallback() { const src = this.getAttribute('src'); if (src) this.#start(src); } disconnectedCallback() { this.#teardown(); } async #start(src) { await this.#setupWorker(); const res = await fetch(src, { headers: { Accept: 'application/x-ndjson' }, signal: this.#abort.signal, }); if (!res.ok || !res.body) { this.#worker.postMessage({ type: 'error', message: `HTTP ${res.status}` }); return; } // 关键一步:整个 ReadableStream 转移给 Worker,主线程不再读取它 this.#worker.postMessage( { type: 'stream-start', stream: res.body }, [res.body] ); } async #setupWorker() { const channel = new MessageChannel(); this.#port = channel.port1; this.#port.onmessage = (event) => this.#onMessage(event.data); this.#worker = new Worker(new URL('./live-tick-worker.js', import.meta.url), { type: 'module', }); // init 时把端口 transfer 给 Worker this.#worker.postMessage({ type: 'init', port: channel.port2 }, [channel.port2]); } #onMessage(data) { if (data.type === 'batch') { this.#rows.push(...data.rows); this.#cursor = data.cursor; if (!this.#rafId) this.#scheduleRender(); } else if (data.type === 'end' || data.type === 'error') { this.#cursor = data.cursor || this.#cursor; this.#scheduleRender(); } } #scheduleRender() { this.#rafId = requestAnimationFrame(() => { this.#rafId = 0; this.#render(); }); } #render() { const container = this.shadowRoot.querySelector('.table'); const fragment = document.createDocumentFragment(); for (const row of this.#rows) { const el = document.createElement('div'); el.textContent = `${row.symbol} price=${row.price} qty=${row.qty} avg=${row.avgPrice}`; fragment.appendChild(el); } if (this.#rows.length) { this.#rows.length = 0; container.replaceChildren(fragment); } } #teardown() { this.#abort.abort(); this.#port?.close(); this.#worker?.terminate(); if (this.#rafId) cancelAnimationFrame(this.#rafId); } } customElements.define('live-tick-table', LiveTickTable);这里有一个非常容易被忽略的点:res.body被转移给 Worker 之后,主线程里如果还存着res.body这个引用,是不能再调res.body.cancel()或getReader()的。一旦fetch的AbortController触发或者 Worker 被终止,这个流自然会被清理。
另外setupWorker里使用的MessageChannel端口,主线程保留port1,Worker 保留port2。为了让 Worker 能够直接用端口回传消息,init消息里的channel.port2必须在转移列表里。
3.3 Worker 端消费管道实现
Worker 的代码是整个方案的“重头戏”。它要做三件事:接收流、拆行、聚合后按时间窗口输出。
// live-tick-worker.js let resultPort = null; self.onmessage = ({ data }) => { if (data.type === 'init') { resultPort = data.port; return; } if (data.type === 'stream-start') { // 流已经转移到了这个 Worker 的全局作用域 consumeStream(data.stream).catch((err) => { resultPort?.postMessage({ type: 'error', message: err.message }); }); } }; async function consumeStream(stream) { const reader = stream .pipeThrough(new TextDecoderStream()) .pipeThrough(new LineSplitTransformStream()) .pipeThrough(new AggregatorTransformStream(200, 200)) .getReader(); let cursor = 0; const emit = (msg) => { cursor = msg.cursor; resultPort?.postMessage(msg); }; while (true) { const { value, done } = await reader.read(); if (done) break; if (value && value.rows) emit(value); } resultPort?.postMessage({ type: 'end', cursor }); }这里的TextDecoderStream会把二进制流解码成字符串;LineSplitTransformStream负责把粘在一起的半行残留拼起来,按\n切出完整行;AggregatorTransformStream做真正的聚合和批量输出。
LineSplitTransformStream的关键是“残留 buffer”:
class LineSplitTransformStream extends TransformStream { #buffer = ''; constructor() { super({ transform: (chunk, controller) => { this.#buffer += chunk; let newlineIndex; while ((newlineIndex = this.#buffer.indexOf('\n')) >= 0) { const line = this.#buffer.slice(0, newlineIndex); this.#buffer = this.#buffer.slice(newlineIndex + 1); controller.enqueue(line); } }, flush: (controller) => { if (this.#buffer) controller.enqueue(this.#buffer); }, }); } }AggregatorTransformStream是一个带定时窗口的转换流。它不在外部setInterval里控制输出,而是在每次处理输入时检查“距上次输出是否超过了间隔”,这样保证只要有数据进来,窗口就会自动触发:
class AggregatorTransformStream extends TransformStream { #lastEmitAt = 0; #map = new Map(); constructor(intervalMs = 200, maxSymbols = 200) { super({ transform: (line, controller) => { if (!line.trim()) return; const tick = JSON.parse(line); const symbol = tick.symbol; const record = this.#map.get(symbol) || { symbol, price: 0, qty: 0, count: 0, }; record.price = tick.price; record.qty += tick.qty; record.count += 1; this.#map.set(symbol, record); const now = performance.now(); if (now - this.#lastEmitAt >= intervalMs || this.#map.size >= maxSymbols) { this.#emit(controller); this.#lastEmitAt = now; } }, flush: (controller) => { this.#emit(controller); }, }); } #emit(controller) { const rows = [...this.#map.values()]; this.#map.clear(); if (rows.length) { controller.enqueue({ rows, cursor: performance.now(), }); } } }有人会问:AggregatorTransformStream的输出也是ReadableStream,为什么不让主线程直接通过端口拿它?因为 transfer 一个流往往只能单向,而且链路会比较绕。实际项目里更实用的模式是:Worker 只回传“已经准备渲染的小型聚合结果”,主线程不需要跟流数据本身再发生关系。
3.4 主线程渲染优化:批量渲染与帧同步
主线程#onMessage收到的是聚合后的batch,处理起来非常简单。这里为了可读性我把渲染写成了直接replaceChildren,生产环境如果数据量大,应该用虚拟滚动或者只看尾部更新。
一个提升渲染体感的小细节:不要让每次batch消息都触发一次 DOM 更新。把新行先push进数组,然后用requestAnimationFrame合并同帧内的所有 batch。
#onMessage(data) { if (data.type === 'batch') { this.#rows.push(...data.rows); this.#cursor = data.cursor; if (!this.#rafId) this.#scheduleRender(); } }这样即使 100ms 内有 3 次 batch 到达,也只会在下一帧渲染一次。用户看到的是流畅的带数据更新的画面,而不是被高频消息敲打的 DOM。
再补一个细节:组件销毁时,不要只terminate()Worker。如果请求还在进行,AbortController.abort()也要调用,否则网络连接可能继续被占用;如果 Stream 已经转移给 Worker,terminate()会连带取消 Worker 正在读的流,但主动发一个abort消息给 Worker,让它做清理动作(比如清定时器)会更干净。
4. 性能实测与调参路线
4.1 先想清楚要观测哪些指标
网上很多性能优化文章最后都变成“我感觉快多了”,这种体验不可靠。我在做这类组件时,至少会盯以下几个指标:
| 指标 | 观测方式 | 说明 |
|---|---|---|
| 主线程长任务 | PerformanceObserver监听longtask | 长于 50ms 的任务会吃掉帧预算 |
| 主线程 JS 耗时占比 | DevTools Performance 面板 | 看有没有任务在解析/克隆上 |
| FPS / 帧时间 | requestAnimationFrame时间戳差值 | 插值明显跳变说明渲染受阻 |
| 消息往返耗时 | 在 Worker 发送时打点,主线程接收时打点 | 判断“网络→Worker”还是“Worker→主线程”慢 |
| 内存 | Performance 面板的 Memory 曲线 | 排查流队列是否无界增长、Map 是否无限累积 |
如果主线程longtask消失了,但帧时间还是不稳,那问题很可能在渲染层,不在数据处理层。先分清瓶颈在哪,再去调参。
4.2 值得反复调的三个位置
我实际调过几次后,发现参数优先级大概是:聚合窗口 > 回传频率 > 流内队列水位。
第一个是聚合窗口。窗口太长,UI 更新滞后;窗口太短,消息太碎。我一般从 100ms 起步,觉得 UI 跟不上就调到 200ms,数据密度极高时才考虑压回 50ms。
第二个是回传频率,它和聚合窗口基本等价,但要注意:即使你设置了窗口,如果每条消息体积太大,一样会卡。这时候要把“每条消息最多行数”和“时间窗口”同时约束住。上面代码里maxSymbols就是干这个的。
第三个是TransformStream的队列策略。ReadableStream默认 highWaterMark 是 1,这意味着队列里最多放 1 个 chunk,再往里面enqueue就会让desiredSize变负。对高吞吐且不排斥积压的场景,可以考虑适度调大:
new TransformStream({ // 队列允许积压更多 chunk,减少读取侧停顿 highWaterMark: 8, });我做过一次对照:把AggregatorTransformStream的highWaterMark从默认值调大,吞吐确实能提升,但内存也会上升。流量突发、UI 又要求低时延的场景可以加;如果后端是平稳推送,默认值一般就够。
4.3 什么情况下真的不需要上 Worker
组合方案不是万能膏药。有些实时数据处理,直接在主线程流式处理反而更快。
判断依据很朴素:如果单条数据的解析成本小于postMessage的传输成本,那么把每条数据都“搬”去 Worker 不划算。
我自己遇到过一个典型反例:数据源是每秒最多 50 条的 WebSocket 消息,每条只有几十字节,解析就是一次JSON.parse,耗时最多十几微秒。这种量级的主线程负担可以忽略不计,没必要引入 Worker,因为 Worker 的启动、消息封装、调度本身至少有个几十微秒的开销。
真正值得上 Worker 的组合特征是:单条数据虽小但总量极大、单条解析较重(比如 JSON.parse + 正则 + 嵌套聚合)、或者需要高频持续处理让主线程长时间不得空闲。只要命中其中一两条,组合方案的价值就出来了。
另一个容易被忽略的指标是TextDecoderStream。很多人以为在 Worker 里解析字符串就是免费的,实际上 UTF-8 解码也有成本,只不过它已经从主线程搬到 Worker 了。如果数据源已经是字符串(比如 WebSocket 默认返回 string),就别套一层TextDecoderStream,免得白白多做一次字符转换。
5. 六个高频坑与快速排查表
5.1 我踩过的大多数坑,基本都长这样
第一个坑是transfer 后对象被清空。postMessage第二个参数里写的转移对象,发送完就 detached。如果同一段代码在发送后还想读chunk.byteLength,会发现变成了 0。正确做法是:所有需要用的字段,在发送前先取出来,发送后不要碰原对象。
第二个坑是Stream 被 transfer 了,主线程又去 cancel 它。我之前写组件销毁逻辑时,直接在disconnectedCallback里调stream.cancel(),结果抛TypeError。因为流已经转移给 Worker,调用点已经没有访问权限。要么在转移之前把 cancel 逻辑交给 Worker 服务端处理,要么靠AbortController从源头终止,别在持有旧引用的地方硬调。
第三个坑是半行残留。TextDecoderStream输出的一轮字符串不一定正好以\n结尾,如果你不做残留 buffer,最后一行数据经常被丢。这个坑在本地测小数据时可能完全不出现,一到生产大数据量就频繁丢尾巴。所以LineSplitTransformStream绝不偷懒,flush里一定要把残留的#buffer输出掉。
第四个坑是Worker 初始化太慢导致首屏等待。如果你的 Worker 里加载了很大的依赖,或者并行工具库初始化慢,主线程的fetch可能已经拿到数据了,但 Worker 还没准备好init。我习惯在组件connectedCallback时立刻创建 Worker 并发起init,同时再发fetch,让两者并行。
第五个坑是定期器在 TransformStream 里不自律。不要在TransformStream的构造器启动一个setInterval,然后在外部闭包里试图往外拿 controller。控制器是在transform回调里才拿到的,时间窗口应该由“处理数据时判断时间差”来驱动。上面代码那种写法的好处是,没数据时不产生空 tick,有数据时又能以窗口节奏输出。
第六个坑是消息回传频率跟渲染频率没对齐。即使是 Worker 算完的聚合结果,如果每 10ms 发一次,主线程也会被消息处理淹没。正确的做法是让 Worker 输出频率落在 100ms~250ms 这个区间,再用requestAnimationFrame把同一帧内的 batch 合并渲染。你完全可以把“渲染频率上限”理解成一个天然的节流器。
5.2 排查速查表
| 现象 | 常见原因 | 排查思路 |
|---|---|---|
| 页面仍然卡顿,但 Worker 占用率很高 | 聚合逻辑太重或队列无界增长 | 检查desiredSize、聚合 Map 大小、是否每帧都回传全量数据 |
| 页面只卡一下,但数据到达没问题 | 主线程在消息回调里做了 DOM 高频操作 | 看longtask是否发生在port.onmessage回调附近 |
出现TypeError说流不可读 | 流已经被 transfer 或已锁 | 不要对转移后的流再调getReader()/cancel() |
| 最后几行数据丢失 | 行拆分器没做残留输出 | 检查flush里是否controller.enqueue残留字符串 |
| UI 更新延迟明显 | 聚合窗口太长或主线程渲染排队 | 调小intervalMs,检查是否被重任务阻塞 |
| Worker 内存不断上涨 | Transform 队列或 Map 没有做有界控制 | 给聚合 Map 设置 maxSymbols,并注意enqueue节奏 |
| 组件销毁后请求仍在跑 | 没有调用AbortController.abort() | disconnectedCallback里先 abort,再 terminate Worker |
这套速查表基本覆盖了我自己踩过的 80% 问题,剩下 20% 大多跟具体业务数据格式有关,比如 JSON 里出现了异常字符、字段类型不一致、时间戳格式不同等,建议在 Worker 的JSON.parse外面套一层 try/catch,至少把单行错误隔离,不让整条流水线崩掉。
最后说点实在的
我这个组件做了几轮迭代后,最大的体会不是 API 本身多快,而是“让主线程只做最少的活”这个原则确实经得起流量考验。后来把聚合窗口从 100ms 调到 200ms,UI 的帧时间反而更稳定,原因是消息频率降下来了,主线程每帧只处理一次渲染,而不需要反复响应高频 batch。
如果你现在要做的实时组件碰上了类似瓶颈,建议第一步别急着上 Worker,先把渲染频率和消息频率分开看;第二步再考虑把解析、聚合计费逻辑转移给 Worker;第三步才去琢磨各种 Transferable 的极限优化。方向对了,调整参数才有意义。
后面我还在调研一个扩展方向:把JSON.parse换成 WebAssembly 里的加速解析器,让 Worker 内的单条解析成本再降一截。等实现稳定了再回来填坑。