编程 Readable.toWeb() 会暂停原流、还会双缓冲:Node 两套流互转的坑

2026-10-01 00:04:22

Readable.toWeb() 会暂停原流、还会双缓冲:Node 两套流互转的坑

什么时候会撞上

服务里常见这种拼接:上游是 node:stream(fs.createReadStream、PassThrough、zlib 管道),下游却要 Web Streams(fetch 的 Response body、Worker、Edge runtime、Deno/Bun 侧代码)。两边的对象都叫 Readable,方法却对不上,pipeTo 和 pipe 也不通用。互转就是靠 node:stream 上挂的 toWeb / fromWeb——但它们有几个容易踩到行为。

两套流是什么关系

WHATWG Streams Standard(Living Standard,最后更新 2026-05-18)定义三种对象:ReadableStream、WritableStream、TransformStream。它出现比 node:stream 晚,现在已是跨 JS 运行时的标准流 API。

Node 的实现进度(见 nodejs.org/api/webstreams.html):

  • 加于 v16.5.0
  • v18.0.0 起 ReadableStream 挂到全局对象,使用不再发运行时警告
  • v21.0.0 起不再标记 experimental,当前 Stability: 2 - Stable

node:stream 没有退场计划,两边通过互操作方法对接:stream 文档。

toWeb / fromWeb 怎么用

六个方法,分两个方向:

const { Readable, Writable, Duplex } = require('node:stream');

// node:stream -> web streams
Readable.toWeb(nodeReadable);   // => ReadableStream
Writable.toWeb(nodeWritable);   // => WritableStream
Duplex.toWeb(nodeDuplex);       // => { readable, writable }

// web streams -> node:stream
Readable.fromWeb(webReadableStream);
Writable.fromWeb(webWritableStream);
Duplex.fromWeb({ readable, writable });

消费 web stream 有 node:stream/consumers 里的辅助函数:arrayBuffer / blob / buffer / bytes / json / text,一次读成对应类型。读取侧还有 getReader()(默认或 {mode:'byob'})、for await...of 异步迭代、readableStream.values([{preventCancel:true}])。

两个补充 API:

  • ReadableStream.from(iterable):加于 v20.6.0,从可迭代对象(含 asyncIterator)造 ReadableStream。要 pipeTo 到 WritableStream 时,iterable 应 yield Buffer/TypedArray/DataView。
  • ReadableStreamTee(stream[, cloneForBranch2]):加于 v26.5.0,Stability 1 Experimental。与 readableStream.tee() 的差别只在 cloneForBranch2=true:tee() 恒传 false,Fetch body clone 等平台规范传 true,第二个分支拿到克隆块,消费一个分支不会改动另一个分支看到的块。

ReadableStream 也可以被 postMessage 转移到 MessagePort / Worker。

坑 1:toWeb 双缓冲,内存翻倍

Node.js issue #48636:Readable.toWeb(readable) 转换后,缓冲数据量是预期的两倍。Node Readable 自身有内部缓冲,附加的 ReadableStream 又有一层,而 toWeb 还把 Node Readable 的 highWaterMark 复制给了 web stream 的 queuing strategy。

Readable.from(gen(), { objectMode: true, highWaterMark: 8 })
// 经 toWeb 后,缓冲量翻倍

issue #47128 是更严重的一版:

fs.createReadStream('/dev/urandom', { highWaterMark: 5556 })

转成 web stream 后,队列里堆了 5556 个元素、每个 5556 字节,总计约 30.8MB,而非期望的 5556 字节。根因是 fallback 策略只传了 {highWaterMark}(字节数),没有 size 算法,默认 size 为 () => 1,desiredSize 于是按「块数」计数:要 65536 块才归零,等于 4GiB 才触发背压,onData 永远不停,源被一口气读完。

Node 在 PR #46347 把 fallback 改成按字节计量的策略。

规避:给 toWeb 显式传高水位(如 {highWaterMark: 0} 或较小值);字节流用 {type:'bytes'} 且不传 size——规范禁止字节流带 size 算法。

坑 2:toWeb 会暂停原始 readable

Node.js issue #45545:Readable.toWeb(readable) 会 pause 原始 readable。之后再在原 readable 上挂 readable.on('data', ...) 收不到数据。

这是 intentional,维护者 mcollina 确认过。node:stream 的官方建议是「choose one API style」:单个流只选一种消费方式,on('data') / on('readable') / pipe() / 异步迭代混用会有反直觉行为。所以 toWeb 之后就不要再从 Node 侧接口消费同一个流。

坑 3:背压暂停/恢复期间被取消,抛未捕获异常

Node.js issue #64529:把 PassThrough 经 Readable.toWeb() 转成 web ReadableStream,pipe 给慢速 sink 产生背压 pause → pull() → resume() 循环,中途取消/中止传输(HTTP 客户端断连、服务端 new Response(Readable.toWeb(nodeStream)) 流式返回)时会抛出无法捕获的 uncaughtException:

TypeError [ERR_INVALID_STATE]: Invalid state: Controller is already closed
    at ReadableStreamDefaultController.enqueue (node:internal/webstreams/readablestream:1077:13)
    at PassThrough.onData (node:internal/webstreams/adapters:468:16)

复现:v22.23.1 上 400/400 稳定复现;main / v24.18.0 仍存在。根因在 lib/internal/webstreams/adapters.js 的 newReadableStreamFromStreamReadable:取消时 onData 仍无保护地调用 controller.enqueue(chunk)。按规范 ReadableStreamCancel 会先把状态设为 closed 再调 cancel(),而 'data' 监听在取消时未被移除,一个已排期的 resume_ 流 tick 会在 controller 关闭后、destroy() 生效前再投递一次 'data',enqueue 抛错。

规避/替代:改用基于 pull 的 ReadableStream.from(nodeReadable) 替代 Readable.toWeb()——同一 harness 下 0/400,取消还能通过 async iterator 的 return() 把 destroy 传到源。

旁证:Deno PR #36321 修的是同一类背压 bug,并指出 Node v25.9.0 对 Readable.toWeb(new Readable({encoding:'utf8'})) 有回归——字符串块入队时抛 RangeError [ERR_INVALID_ARG_VALUE]: The argument 'size' is invalid. Received NaN(因为 "...".byteLength 是 undefined)。

该用什么、不该用什么

  • 需要把 Node 流交给 web 侧消费:优先 ReadableStream.from(nodeReadable),它在取消路径上表现正常,背压下也不炸。
  • 非要用 Readable.toWeb():显式传 queuing strategy;字节流用 {type:'bytes'} 且不加 size;转换后不要再碰原 readable。
  • 单个流只保留一种消费风格,别把 on('data') 和 getReader() 混在同一个对象上。
  • 构造 web stream 的辅助函数放在 node:stream/consumers,不要自己手写 chunk 拼装。

参考:MDN Streams API、web.dev 的 Streams—The definitive guide。

小结

toWeb / fromWeb 能跑通不代表在取消和背压路径上安全。双缓冲来自 highWaterMark 的复制与缺失的 size 算法,暂停原流是刻意设计,ERR_INVALID_STATE 则是 onData 在取消后仍 enqueue。涉及流式响应、客户端断连这类场景时,ReadableStream.from() 比 toWeb() 更稳。

推荐文章

程序员茄子在线接单