你在处理大文件上传的时候用过ReadableStream吗?写过transformer吗?有没有觉得这玩意儿用起来别扭,文档看得云里雾里,代码写得一头包?

别急,问题出在API本身的设计上。James Snell,Node.js技术指导委员会成员,Cloudflare Workers的核心贡献者,最近写了一篇长文,把Web Streams API的问题扒了个底朝天,还提出了一套替代方案。

这套方案用async iterables作为基础,跑分显示性能提升2到120倍。

流到底是什么东西

先把概念理清楚。流就是一堆数据,但不是一次性全给你,而是一点一点往外吐。

想象你在星巴克排队买咖啡。流就像那个咖啡师做咖啡的过程:磨豆子、萃取、打奶泡、拉花,一步一步来。你不需要等咖啡师把全球的咖啡豆都磨完才能喝到第一口,做完一杯给一杯。

数据太大放不进内存?没关系,流一点处理一点。网络慢?来多少处理多少。

JavaScript里的流处理主要用在几个场景:文件读写、网络请求、数据处理管道。这些东西的共同点是数据量大、耗时长、需要分块处理。

Web Streams API的四大槽点

现在的Web Streams API是WHATWG制定的标准,浏览器和Node.js都实现了。表面看挺完善:ReadableStreamWritableStreamTransformStream三件套齐活。但真用起来,坑一个接一个。

槽点一:锁机制像防贼一样

Web Streams有个奇怪的"锁"概念。一旦你调用了reader = stream.getReader(),这个流就被锁住了。别人想读?不行,得等你释放。

const stream = new ReadableStream({...});
const reader = stream.getReader();

// 这时候stream已经被锁住了
const anotherReader = stream.getReader(); // 报错!

这设计初衷是防止多个消费者同时读同一个流导致数据混乱。但问题在于,这种锁机制让流的组合变得极其困难。

你想把一个流同时传给两个处理器?不好意思,得先搞个tee()把流分成两份。这玩意儿用起来像是在办公室装两个饮水机,仅仅因为老板说"一个人用水的时候别人不能用"。

更扯的是,锁的释放还不直观。你得记住调用reader.releaseLock(),不然这个流就废了。忘记释放?流就永远锁着。用try-finally包裹?那是应该的,但谁记得住啊。

槽点二:BYOB读法像做数学题

BYOB是"Bring Your Own Buffer"的缩写,意思是你自己准备一个缓冲区,让流往里面填数据。

听起来挺合理,用起来像做奥数题。

const reader = stream.getReader({ mode: 'byob' });
const buffer = new ArrayBuffer(1024);

// 读操作
const { value, done } = await reader.read(buffer);
// value可能是buffer的一部分视图
// 也可能是一个新的buffer
// 取决于流的实现

问题在哪?你传入一个buffer,返回的value可能不是你传进去那个。可能是它的一个视图,也可能是一个全新的buffer。你得检查value的类型、长度、偏移量,搞清楚数据到底在哪儿。

这种设计让代码变得脆弱。你原本想复用buffer减少GC压力,结果API的设计让你不得不写一堆防御性代码。

槽点三:背压处理像在走钢丝

背压是流处理里的经典问题。下游处理慢,上游还拼命发数据,内存就爆了。Web Streams API提供了背压机制,但用起来像在走钢丝。

你需要实现一个underlyingSource,在pull方法里控制数据的生产节奏。pull什么时候被调用?当内部队列不满的时候。队列什么时候满?看highWaterMark配置。highWaterMark设多少?看你的业务场景。

这套机制理论上能解决问题,但调试起来简直是噩梦。数据积压了?不知道是哪一环出了问题。上游发太快?下游处理太慢?还是中间某个transform堵住了?

槽点四:Promise开销像收过路费

Web Streams是建立在Promise之上的。每次read()返回一个Promise,每个chunk都涉及Promise的创建和解析。

这有什么问题?单个Promise的开销很小,几微秒。但流处理动辄处理上百万个chunk,这些微秒加起来就很可观了。

James Snell做了个基准测试。处理大量小数据块时,Promise开销能占到总时间的30%以上。这就像你开车去隔壁小区,每过一个减速带都要停车交费,一次5毛钱,过100个减速带就是50块。

替代方案:Async Iterables

Snell提出的替代方案很简单:用async iterables作为流的基础抽象。

什么是async iterable

ES2018引入了异步迭代器,允许你用for await...of语法遍历异步数据源。

async function* generateData() {
  for (let i = 0; i < 100; i++) {
    yield await fetchData(i);
  }
}

for await (const chunk of generateData()) {
  console.log(chunk);
}

async iterable本质上是拉式的。消费者主动请求数据,生产者才生产。这和Web Streams的推式模型形成对比。

推式模型是生产者主动推送数据,消费者被动接收。听起来效率高,但背压处理复杂,因为生产者不知道消费者的处理速度。

拉式模型是消费者主动拉取数据。消费者处理完一个,再请求下一个。天然的背压控制:消费者处理不过来,就不请求下一个,生产者自然就停了。

为什么拉式更好

用排队买奶茶做比喻。

推式模型就像奶茶店不管你喝不喝得完,拼命给你做。你手里拿着一杯正在喝,店员又塞给你一杯,再塞一杯。你喝不完了,杯子堆在手里,最后撒了一地。

拉式模型就像你喝完一杯,再去点一杯。店员做一杯,你喝一杯。永远不会有杯子堆积的问题。

Web Streams是推式的,但用各种机制来模拟拉式行为(比如pull方法、背压信号)。async iterables天生就是拉式的,不需要额外的机制。

背压策略的灵活性

Snell的方案还提供了多种背压策略:

  • strict:严格等待消费者请求。最安全,但可能不是最快的。
  • block:当队列满时阻塞生产者。简单直接。
  • drop-oldest:队列满时丢弃最老的数据。适合实时数据。
  • drop-newest:队列满时丢弃最新的数据。适合某些监控场景。

不同的策略适用于不同的场景。Web Streams的背压策略比较单一,想换一个?没门。

Web Streams与Async Iterables性能对比

James Snell做了一个详细的性能基准测试,对比Web Streams API和他提出的替代方案。

测试场景包括:简单传递、转换操作、多路复用、大数据处理等。结果显示,替代方案在几乎所有场景下都更快。

最夸张的场景下,替代方案比Web Streams快120倍。最保守的估计也有2倍提升。

为什么差距这么大?

Promise开销被大幅削减。async iterables虽然也用Promise,但实现方式更高效,避免了大量不必要的Promise创建。数据复制也减少了——BYOB那一套复杂性被扔掉,数据处理路径变得直接。

锁机制?没了。不需要维护锁的状态,不需要处理锁冲突,代码路径短了一大截。

当然,这些都是实现层面的差异。理论上Web Streams也可以优化到接近的水平。但API设计层面的复杂性(锁、BYOB、背压信号)决定了优化空间有限。

Web Streams API的未来与开发者选择

首先,Web Streams API短期内不会消失。它是浏览器标准,有大量的代码依赖它。但了解它的局限性,能帮你避免踩坑。

其次,如果你在写Node.js服务端代码,可以考虑用async iterables替代Web Streams。Node.js对async iterables的支持很好,性能也更好。

第三,关注这个提案的进展。James Snell的方案目前是一个实验性的参考实现,但已经在社区引发了讨论。如果最终进入标准,你的代码可能需要调整。

最后,理解推式和拉式的区别,对设计数据处理系统很有帮助。不是所有场景都需要流,也不是所有流都需要Web Streams API。

Web Streams API使用建议

如果你的项目正在用Web Streams API,可以考虑以下优化:

  1. 减少锁的持有时间:获取reader后尽快读完,及时释放。

  2. 避免不必要的BYOB:如果性能要求不是特别高,用普通的read模式更简单。

  3. 合理设置highWaterMark:根据你的数据处理速度调整缓冲区大小。

  4. 考虑使用async iterables包装:很多场景下,把Web Streams转换成async iterables会让代码更清晰。

// Web Streams to Async Iterable
async function* streamToAsyncIterable(stream) {
  const reader = stream.getReader();
  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) return;
      yield value;
    }
  } finally {
    reader.releaseLock();
  }
}

这个包装函数帮你处理了锁的获取和释放,让你可以用for await...of语法遍历流数据。

流处理的新方向

Web Streams API的问题不是实现的问题,是设计哲学的问题。它试图用推式模型实现流处理,然后用复杂的机制来模拟拉式行为。这就像非要让一辆轿车去越野,然后给它加装各种改装件。

async iterables代表了一种更简洁的思路:拥抱拉式模型,让消费者控制节奏。这不是银弹,但在很多场景下确实更合适。

James Snell的提案能否成为标准,取决于很多因素:浏览器厂商的态度、社区的反馈、向后兼容性的考虑。但作为一个技术参考,它至少证明了一件事:JavaScript的流处理可以做得更好。


常见问题

Q: Web Streams API会被废弃吗?

不太可能。它是浏览器标准,有大量的Web应用依赖。更可能的演进方向是提供更好的async iterables支持,让开发者有选择。

Q: 我现在应该用async iterables替代Web Streams吗?

看场景。服务端Node.js环境可以考虑,浏览器环境目前还是Web Streams更稳定。可以用包装函数兼容两种模式。

Q: 背压到底是什么?为什么这么重要?

背压就是下游告诉上游"慢点发"的机制。没有背压,上游发数据太快,下游处理不过来,内存就会爆。想象一个漏斗,倒水太快就会溢出来。

Q: BYOB模式什么时候该用?

当你需要极致性能、减少GC压力时。比如处理二进制数据流、音视频编解码。普通业务代码用默认模式就够了。

Q: James Snell的参考实现在哪里?

GitHub上有:https://github.com/jasnell/new-streams。目前是实验性质的,不建议生产环境使用,但可以作为学习参考。