说实话,看到这个标题,你可能觉得我在危言耸听。

但这是事实。而且,说这话的人不是无名之辈,他是 James Snell,Node.js 技术指导委员会(TSC)的核心成员,也是 Cloudflare 的架构师。

他在最近的博客里直接把 WHATWG 的 Web Streams 标准扒了个底朝天。结论很简单粗暴:这个标准不仅难用,而且慢得离谱。

AI配图

有多慢?

在同样的硬件环境下,如果换一种设计思路,处理流的性能可以提升 2 倍到 120 倍

AI配图

这不是靠优化代码挤出来的性能,而是因为现有的 Web Streams API 在设计之初,就埋下了致命的隐患。

生不逢时的“标准”

Web Streams API(也就是 WHATWG Streams Standard)诞生于 2014 年到 2016 年间。

那时候的 JavaScript 还没有现在的光景。最尴尬的是,当时 ES2018 还没发布,for await...of 这种异步迭代语法甚至还不存在。

这就导致了一个巨大的历史遗留问题:标准委员会不得不发明一套自己的“读者/写者”模型来处理异步数据流。

这套模型被各大浏览器、Node.js、Deno、Bun 等运行时广泛采纳,成为了事实标准。甚至 fetch() API 的底层也是基于它构建的。

看似繁荣,实则埋雷。 当年为了解决“没有异步迭代”而设计的复杂锁机制、状态机,在今天看来,就像是在法拉利引擎上装了一个马车的轮子。

Image 2

令人崩溃的“锁”与“仪式感”

如果你平时不怎么接触底层流处理,可能对“锁”这个概念无感。

但在 Web Streams 里,锁简直就是开发者的噩梦。

按照标准,当你调用 stream.getReader() 时,流就被“锁”住了。这时候,除了持有 reader 的代码,其他任何人都不能读这个流,甚至不能取消它。

这听起来挺安全,对吧?

但实际上,这非常容易出错。 看看这个经典的“翻车”案例:

async function peekFirstChunk(stream) {
const reader = stream.getReader();
const { value } = await reader.read();
// 糟糕!忘了调用 reader.releaseLock()
return value;
}

const first = await peekFirstChunk(stream);
// 报错:TypeError: Cannot obtain lock — stream is permanently locked
for await (const chunk of stream) { /* 永远不会运行 */ }

你看,仅仅是忘记了一行 releaseLock(),整个流就废了。永久锁定,无法恢复。

更讽刺的是,现在的 JavaScript 已经有了 for await...of,本来可以像遍历数组一样遍历流,但因为 Web Streams 的底层设计无法完全适配,导致开发者一旦遇到复杂情况,还是得掉回那个充满“仪式感”的 reader 模式里去处理锁、处理状态。

这不是开发者笨,是 API 在反人类。

背压机制:看起来很美,用起来很坑

流处理中有一个核心概念叫“背压”。

简单说,就是下游消费者处理不过来了,得告诉上游生产者:“哥们慢点,我这里堵了。”

Web Streams 确实设计了背压机制,理论上很完美。

但在实践中?它几乎是摆设。

标准里有一个 desiredSize 属性,用来告诉生产者现在的缓冲区状态。但这只是个“建议”。如果你是个暴躁的生产者,完全可以选择无视它,疯狂地往里塞数据。

new ReadableStream({
start(controller) {
// 没有什么能阻止你这么做
while (true) {
controller.enqueue(generateData()); // desiredSize: -999999
}
}
});

更可怕的是 tee() 操作。这个 API 可以把一个流拆成两个。

如果其中一个消费者读得快,另一个读得慢,数据就会在内存里无限堆积,直到把内存撑爆。标准甚至没有规定缓冲区的上限。

James Snell 在文章里直言:这就是一个内置的内存泄漏隐患。

Image 3

Promise 狂魔:性能杀手

如果你觉得上面的只是代码难写点,那接下来这点就是实打实的性能灾难。

Web Streams API 对 Promise 有着近乎疯狂的依赖。

每一次 read(),每一次背压检查,内部都会创建大量的 Promise。对于视频流、网络包这种高频 I/O 场景,这种开销是巨大的。

James Snell 提到了一个极其典型的场景:服务端渲染(SSR)。

在 SSR 中,页面被拆成成千上万个小片段流式传输。每一个片段都要经过 Web Streams 的层层 Promise 包裹。

结果呢?JavaScript 引擎把大量的 CPU 时间都花在了垃圾回收(GC)上。 有时候 GC 甚至占用了超过 50% 的 CPU 时间。

这简直是本末倒置。

Vercel 的技术总监 Malte Ubl 也在最近的一篇博客里吐槽了同样的问题。他们发现,在 Node.js 里用原生的 Web Streams 管道传输数据,速度只有 630 MB/s;而用传统的 Node.js pipeline,速度能达到 7900 MB/s。

整整 12 倍的差距。

原因很简单:全是 Promise 和对象分配的开销。

Image 4

推倒重来:一个“叛逆”的新方案

既然修修补补已经没救了,James Snell 决定搞个“大动作”。

他提出了一个全新的设计原型,核心思想非常“激进”:回归 JavaScript 语言本身。

1. 流就是异步迭代器

别再搞什么 ReadableStreamgetReader() 了。流,本质上就是一个异步序列。

既然 JavaScript 有了 AsyncIterable,为什么不用?

// 新 API:简单得令人发指
import { Stream } from 'new-streams';

const { writer, readable } = Stream.push();
await writer.write("Hello, World!");
await writer.end();

// 直接消费,没有锁,没有 reader
const text = await Stream.text(readable);

2. 拉取式转换

现在的 Web Streams 是“推”式的,数据像洪水一样推过来,不管你消不消费得了。

新方案改为“拉”式。你不读,它就不动。

这彻底解决了背压问题。消费者处理不过来,上游自然就停了,根本不需要复杂的信号通知。

3. 显式的背压策略

别猜了,直接选吧。

新 API 提供了四种显式策略:

  • strict:缓冲区满了直接报错(默认)。
  • block:等着,直到有空位。
  • drop-oldest:扔掉旧数据(适合直播流)。
  • drop-newest:扔掉新数据。

把选择权还给开发者,而不是在后台默默地吃掉内存。

4. 同步路径

这是一个巨大的性能优化点。

很多流处理其实完全是同步的(比如内存里的数据压缩)。Web Streams 强制你异步,新 API 允许你走同步通道:Stream.pullSync()

没有 Promise,没有微任务队列,就是纯粹的函数调用。

Image 5

数据不会骗人

光说不练假把式。James Snell 给出了基准测试数据。

在链式转换场景下,新方案比 Web Streams 快 80 到 90 倍

在高频小数据块场景下,快 25 倍

即使在浏览器里,简单的迭代消费场景,新方案也能快 40 到 100 倍

值得注意的是,这个新方案是用纯 TypeScript/JavaScript 写的,而对比的 Web Streams 是各运行时用 C++/Rust 写的原生实现。

用 JS 跑赢了原生代码,这本身就是对现有设计最大的嘲讽。

Image 6

结语

James Snell 在文章最后强调,这还只是一个“讨论的起点”,不是最终标准。

但他提出的每一个问题,都是无数开发者在深夜调试流处理时遭遇过的真实痛点。

Web Streams 曾是一个雄心勃勃的项目,它填补了 Web 平台的空白。但十年过去了,JavaScript 进化了,我们的工具也该进化了。

标准不该是束缚手脚的枷锁,而应是助人飞翔的翅膀。

AI配图

如果现在的翅膀太重,也许真的是时候换一对新的了。

GitHub 上已经放出了参考实现,感兴趣的开发者可以去看看:new-streams

不知道 Node.js 和浏览器厂商这次会不会听进去。

参考链接:
https://blog.cloudflare.com/a-better-web-streams-api/