← 回總覽

【第 3668 期】从 Web Streams 到 Async Iterable:重新思考 JavaScript 流式 API

📅 2026-03-12 09:02 前端早读课 软件编程 26 分鐘 31398 字 評分: 91
Web Streams Async Iterable Node.js 性能优化 流式处理
📌 一句话摘要 本文深入剖析了 Web Streams API 的设计缺陷与性能瓶颈,并提出一种基于 Async Iterable 的高性能、低开销流式处理新模型。 📝 详细摘要 文章由 Node.js 核心贡献者 James M Snell 撰写,指出 Web Streams API 在 Promise 开销、锁机制复杂度、背压失效及 SSR 场景下的 GC 压力等方面存在根本性设计问题。作者认为这些决策在十年前虽合理,但已不符合现代 JS 开发习惯。文中提出了一种回归语言原语的替代方案:将流视为 Async Iterable,采用拉取式转换、批量数据块处理以及显式的背压策略。该方案在基

James M Snell 2026-03-12 09:02 福建

!Image 1

Web Streams 已成为浏览器和 Node.js 中处理流式数据的标准 API,但在实际开发中,它在性能、可用性和复杂度上暴露出不少问题。

前言

Web Streams 已成为浏览器和 Node.js 中处理流式数据的标准 API,但在实际开发中,它在性能、可用性和复杂度上暴露出不少问题。从 Promise 开销、背压机制到 transform 管道的隐藏成本,这些设计在高频数据处理和 SSR 场景中尤为明显。本文通过真实案例和基准测试,深入分析 Web Streams 的局限,并介绍一种基于 Async Iterable 的全新 Streams API 设计思路,探讨更简单、更高性能的流式处理模型。

今日前端早读课文章由 @James M Snell 分享,@飘飘编译。

译文从这开始~~

!Image 2

#### 流式数据处理是构建应用的基石

流式数据处理是我们构建应用程序的根基所在。为了让流式处理在各种环境中通用,WHATWG 制定了 Streams 标准(俗称 "Web 流"),旨在建立一套跨浏览器和服务器的通用 API。该标准先在浏览器中落地,随后被 Cloudflare Workers、Node.js、Deno 和 Bun 相继采纳,并成为fetch()等 API 的底层基础。这是一项意义深远的工程,当年参与设计的人们在当时的技术条件和约束下,解决了一系列极具挑战性的问题。 【第3571期】调试神秘的 HTTP 流式传输问题

然而,在多年围绕 Web 流的实践中 —— 先后在 Node.js 和 Cloudflare Workers 中实现它,为客户和运行时排查生产环境的故障,帮助开发者跨过数不清的常见陷阱 —— 我逐渐认识到,这套标准 API 在易用性和性能方面存在根本性的问题,仅靠渐进式改良难以修补。这些问题并非 bug,而是设计决策带来的必然结果。这些决策在十年前或许合情合理,但已与当下 JavaScript 开发者的编码方式格格不入。

本文将深入探讨我所观察到的 Web 流的若干根本性问题,并提出一种基于 JavaScript 语言原语构建的替代方案,以证明更好的路径是存在的。

在基准测试中,这一替代方案在我测试过的所有运行时(包括 Cloudflare Workers、Node.js、Deno、Bun 以及各主流浏览器)上,速度可达 Web 流的 2 到 120 倍。这种提升并非源于什么精巧的优化技巧,而是源于从根本上不同的设计选择 —— 这些选择更有效地利用了现代 JavaScript 语言特性。我无意贬低前人的成果;我只想开启一场讨论,探讨未来的可能性。

#### 从何说起

Streams 标准于 2014 年至 2016 年间开发,怀揣着一个宏大的目标:提供 "用于创建、组合和消费数据流的 API,使其能够高效地映射到底层 I/O 原语"。在 Web 流出现之前,Web 平台没有任何处理流式数据的标准方式。 【第3290期】使用 HTTP 流式传输提升性能

当时 Node.js 已经有了自己的流式 API,并且已被移植到浏览器端使用,但 WHATWG 选择不以此为起点,因为其章程规定只考虑 Web 浏览器的需求。服务端运行时是后来才采纳 Web 流的 —— 在 Cloudflare Workers 和 Deno 各自以一等公民的方式支持 Web 流、跨运行时兼容性成为优先事项之后。

Web 流的设计早于 JavaScript 的异步迭代特性。for await...of语法直到 ES2018 才正式发布,比 Streams 标准初版定稿晚了整整两年。这意味着该 API 在设计之初无法利用后来成为消费异步序列惯用方式的语法特性。取而代之的是,规范引入了自己的一套读取器 / 写入器获取模型,而这一决策的影响波及了 API 的方方面面。

!Image 3

#### 对普通操作过于讲究形式

流式处理中最常见的任务就是将流读取到底。用 Web 流来写,是这样的: 【第3632期】真相揭秘:JavaScript 中根本没有真正的“取消异步” // 首先,获取一个读取器,它会对流加上排他锁…… const reader = stream.getReader(); const chunks = []; try {   // 然后,反复调用 read 并 await 返回的 Promise,   // 要么得到一块数据,要么得到读取结束的信号。   while (true) {     const { value, done } = await reader.read();     if (done) break;     chunks.push(value);   } } finally {   // 最后,释放流上的锁   reader.releaseLock(); } 你可能以为这种写法是流式处理的固有模式。其实不然。获取读取器、管理锁、{ value, done }协议 —— 这些都只是设计选择,而非必要条件。它们是 Web 流规范编写的时代和方式留下的产物。异步迭代的存在本就是为了处理随时间到达的序列,只是在规范编写时异步迭代尚未问世。这里的复杂性纯属 API 层面的额外开销,而非本质上的必然。

来看看如今 Web 流支持for await...of之后的替代写法: const chunks = []; for await (const chunk of stream) {   chunks.push(chunk); } 这确实好多了,样板代码大幅减少,但并没有解决所有问题。异步迭代是后来嫁接到一个并非为其设计的 API 上的,这一点显而易见。像 BYOB(自带缓冲区)读取这样的功能无法通过迭代来使用。读取器、锁和控制器这些底层复杂性依然存在,只是被隐藏了起来。一旦出了问题,或者需要用到 API 的其他功能,开发者就会发现自己又陷入了原始 API 的泥沼 —— 试图弄清楚为什么流被 "锁定" 了,为什么releaseLock()没有按预期工作,或者在自己无法控制的代码中追踪性能瓶颈。

#### 锁的困局

Web 流使用锁模型来防止多个消费者交错读取。当你调用getReader()时,流就会被锁定。锁定期间,其他任何代码都无法直接读取该流、对其进行管道操作,甚至无法取消它 —— 只有持有读取器的代码才有这个权限。 【早阅】复杂代码库中的AI提效秘籍:如何通过上下文工程避开大模型的“愚蠢区”

这听起来合情合理,直到你看到它有多容易出问题: 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 // TypeError: 无法获取锁——流已被永久锁定 for await (const chunk of stream) { / 永远不会执行 / } 忘记调用releaseLock()就会永久性地损坏整个流。locked属性能告诉你流被锁定了,却无法告诉你被谁锁定、为何锁定,以及这把锁是否还可用。管道操作在内部会获取锁,导致流在管道操作期间以不易察觉的方式变得不可用。

在有待处理的读取请求时释放锁的语义,多年来也一直模糊不清。如果你调用了read()但没有await它,然后又调用了releaseLock(),会发生什么?规范最近才明确规定:释放锁时会取消待处理的读取请求 —— 但各实现之间行为不一,依赖先前未明确行为的代码可能会因此出错。

话说回来,锁机制本身并非洪水猛兽。它确实发挥着重要作用,确保应用程序有序且正确地消费或生产数据。问题的关键在于最初通过getReader()releaseLock()等 API 手动管理锁的实现方式。随着异步可迭代对象带来的自动锁和读取器管理,从用户的角度来看,处理锁变得容易多了。

但对于实现者而言,锁模型带来了大量非同小可的内部簿记工作。每个操作都必须检查锁状态,读取器必须被追踪,而锁、取消和错误状态之间的交互则催生出一个必须全部正确处理的边界情况矩阵。

#### BYOB:复杂却收效甚微

BYOB(Bring Your Own Buffer,自带缓冲区)读取的设计初衷是让开发者在读取流时能够复用内存缓冲区,这对高吞吐量场景而言是一项重要优化。理念本身很好:不必为每块数据分配新的缓冲区,而是由你提供自己的缓冲区,让流来填充它。

然而在实际使用中(当然,总能找到例外),BYOB 很少能带来可观的收益。它的 API 比默认读取复杂得多,需要使用单独的读取器类型(ReadableStreamBYOBReader)和其他专用类(如ReadableStreamBYOBRequest),需要精心管理缓冲区的生命周期,还需要理解ArrayBuffer的分离(detachment)语义。当你把缓冲区传给 BYOB 读取时,该缓冲区会被分离 —— 转移给流 —— 而你拿回的是一个可能指向完全不同内存区域的新视图。这种基于转移的模型极易出错且令人费解: const reader = stream.getReader({ mode: 'byob' }); const buffer = new ArrayBuffer(1024); let view = new Uint8Array(buffer); const result = await reader.read(view); // 'view' 此时应该已被分离,不可再用 // (但并非所有实现都能保证这一点) // result.value 是一个新的视图,可能指向不同的内存 view = result.value; // 必须重新赋值 BYOB 既不能与异步迭代配合使用,也不能与TransformStream配合使用,因此想要零拷贝读取的开发者不得不退回到手动的读取器循环中。

对于实现者来说,BYOB 带来了巨大的复杂性。流必须追踪待处理的 BYOB 请求,处理部分填充的情况,正确管理缓冲区分离,并协调 BYOB 读取器与底层数据源之间的关系。Web 平台测试中针对可读字节流的部分包含了专门的测试文件,仅仅用来覆盖 BYOB 的各种边界情况:已分离的缓冲区、非法视图、入队后响应的顺序问题,等等。

BYOB 对使用者和实现者来说都很复杂,但在实践中的采用率却寥寥无几。大多数开发者选择使用默认读取方式,坦然接受内存分配的开销。 【第3655期】浏览器即运行时:一个零后端 AI 应用的前端架构实践

大多数用户自定义的ReadableStream实现通常不会费心去正确实现在单个流中同时支持默认读取和 BYOB 读取的全部仪式 —— 这完全可以理解。要做对非常困难,而且绝大多数消费端代码最终都会走默认读取路径。下面的示例展示了一个 "正确" 的实现需要做些什么。又长又复杂又容易出错,这种复杂度绝非普通开发者愿意面对的: new ReadableStream({     type: 'bytes',     async pull(controller: ReadableByteStreamController) {       if (offset >= totalBytes) {         controller.close();         return;       }       // 优先检查是否有 BYOB 请求       const byobRequest = controller.byobRequest;       if (byobRequest) {         // === BYOB 路径 ===         // 消费者提供了缓冲区——我们必须填充它(全部或部分)         const view = byobRequest.view!;         const bytesAvailable = totalBytes - offset;         const bytesToWrite = Math.min(view.byteLength, bytesAvailable);         // 在消费者的缓冲区上创建视图并填充数据         // 非必需,但当 bytesToWrite != view.byteLength 时更安全         const dest = new Uint8Array(           view.buffer,           view.byteOffset,           bytesToWrite         );         // 用顺序递增的字节填充(模拟"数据源")         // 这里可以替换为任何向视图中写入数据的逻辑         for (let i = 0; i < bytesToWrite; i++) {           dest[i] = (offset + i) & 0xFF;         }         offset += bytesToWrite;         // 告知写入了多少字节         byobRequest.respond(bytesToWrite);       } else {         // === 默认读取器路径 ===         // 没有 BYOB 请求——分配并入队一块数据         const bytesAvailable = totalBytes - offset;         const chunkSize = Math.min(1024, bytesAvailable);         const chunk = new Uint8Array(chunkSize);         for (let i = 0; i < chunkSize; i++) {           chunk[i] = (offset + i) & 0xFF;         }         offset += chunkSize;         controller.enqueue(chunk);       }     },     cancel(reason) {       console.log('Stream canceled:', reason);     }   }); 当宿主运行时自身提供面向字节的ReadableStream时 —— 例如作为fetch响应的body—— 运行时内部提供优化的 BYOB 读取实现通常要容易得多,但这些实现仍然需要能够同时处理默认读取和 BYOB 读取两种模式,而这一要求本身就带来了相当可观的复杂性。

#### 背压:理论上可行,实践中却行不通

背压 —— 让慢速消费者向快速生产者发出信号使其减速的能力 —— 在 Web 流中被当作一等概念来对待。至少理论上是这样。但在实践中,这套模型存在一些严重的缺陷。

主要的信号是控制器上的desiredSize。它可以是正值(需要数据)、零(已满)、负值(超载)或null(已关闭)。生产者理应检查这个值,当它不为正时就停止入队。但没有任何机制强制执行这一点:controller.enqueue()总是会成功,即使desiredSize已经是一个很大的负数。 new ReadableStream({   start(controller) {     // 没有任何东西能阻止你这样做     while (true) {       controller.enqueue(generateData()); // desiredSize: -999999     }   } }); 流的实现方完全可以 —— 而且确实会 —— 无视背压;某些规范定义的功能甚至明确地破坏了背压机制。以tee()为例,它从单个流中创建两个分支。如果一个分支的读取速度快于另一个,数据就会在内部缓冲区中无限制地堆积。快速消费者可能导致内存无限增长,而慢速消费者还在慢慢追赶,你既无法配置这一行为,也无法选择退出,除非取消那个较慢的分支。

Web 流确实提供了调节背压行为的明确机制,包括highWaterMark选项和可自定义的大小计算函数,但这些机制和desiredSize一样容易被忽略,许多应用程序根本不去理会它们。 【第3556期】JavaScript 的作用域提升机制是有问题的 WritableStream端也存在同样的问题。WritableStreamhighWaterMarkdesiredSize。还有一个writer.readyPromise,数据生产者理应关注它,但往往并不会。 const writable = getWritableStreamSomehow(); const writer = writable.getWriter(); // 生产者应当等待 writer.ready // 这是一个 Promise,当它 resolve 时,表明 // 可写流内部的背压已经消除, // 可以继续写入更多数据 await writer.ready; await writer.write(...); 对于实现者而言,背压增加了复杂性,却没有提供任何保证。追踪队列大小、计算desiredSize、在正确的时机调用pull()—— 这些机制都必须正确实现。然而,由于这些信号只是建议性的,所有这些工作实际上并不能真正防止背压本应解决的那些问题。

#### Promise 的隐性代价

Web 流规范要求在许多地方创建 Promise,往往是在热路径上,而且对用户来说完全不可见。每次read()调用不仅仅返回一个 Promise;在内部,实现还会为队列管理、pull()协调和背压信号创建额外的 Promise。

这种开销是规范本身决定的 —— 它依赖 Promise 来处理缓冲区管理、完成信号和背压通知。虽然其中一部分与具体实现有关,但如果严格遵循规范,大部分开销是不可避免的。对于高频流式场景 —— 视频帧、网络数据包、实时数据 —— 这种开销相当可观。

在管道链中,问题会层层叠加。每个TransformStream都在源和目标之间增加了一层 Promise 机制。规范没有定义同步快速路径,因此即使数据已经就绪,Promise 机制依然照常运转。

对于实现者来说,这种重度依赖 Promise 的设计严重限制了优化空间。规范对 Promise 的 resolve 顺序有明确要求,使得批量操作或跳过不必要的异步边界变得困难重重,稍有不慎就可能引发微妙的合规性问题。实现者确实会做许多隐藏的内部优化,但这些优化往往复杂且容易出错。

在我撰写这篇文章时,Vercel 的 Malte Ubl 发表了他们自己的博客文章,介绍了 Vercel 围绕提升 Node.js Web 流实现性能所做的一些研究工作。文中他们谈到了每个 Web 流实现都面临的同一个根本性能优化难题:

" 再看看pipeTo()。每个数据块都要经过一条完整的 Promise 链:读取、写入、检查背压、循环往复。每次读取都要分配一个{value, done}结果对象。错误传播还会产生额外的 Promise 分支。

这些本身都没有错。在浏览器中,流会跨越安全边界,取消语义必须滴水不漏,管道的两端你无法同时控制,这些保证至关重要。但在服务器端,当你把 React Server Components 以 1KB 大小的数据块通过三个转换流进行管道传输时,这些开销就会积少成多。

我们对原生 WebStream 的pipeThrough进行了基准测试,1KB 数据块的吞吐量为 630 MB/s。而 Node.js 的pipeline()使用相同的透传转换流:约 7,900 MB/s。差距达 12 倍,而这个差异几乎完全来自 Promise 和对象分配的开销。"

来自:https://vercel.com/blog/we-ralph-wiggumed-webstreams-to-make-them-10x-faster

作为研究的一部分,他们针对 Node.js 的 Web 流实现提出了一组改进方案,将在某些代码路径中消除 Promise,从而带来高达 10 倍的显著性能提升 —— 这恰恰印证了我的观点:Promise 虽然有用,但带来的开销不容小觑。作为 Node.js 的核心维护者之一,我期待着帮助 Malte 和 Vercel 的同事们将这些改进落地!

在最近对 Cloudflare Workers 的一次更新中,我对一条内部数据管道做了类似的修改,在某些应用场景下将创建的 JavaScript Promise 数量减少了多达 200 倍。结果是这些应用的性能获得了数个数量级的提升。

#### 现实中的翻车案例

##### 未消费的响应体导致资源耗尽

fetch()返回响应时,body是一个ReadableStream。如果你只检查了状态码,既没有消费也没有取消响应体,会发生什么?答案因实现而异,但常见的后果是资源泄漏。 async function checkEndpoint(url) {   const response = await fetch(url);   return response.ok; // 响应体既未被消费,也未被取消 } // 在循环中,这可能耗尽连接池 for (const url of urls) {   await checkEndpoint(url); } 这种模式曾在使用 undici(Node.js 内置的fetch()实现)的 Node.js 应用中导致连接池耗尽,其他运行时也出现过类似问题。流持有对底层连接的引用,如果不显式消费或取消,连接可能会一直滞留到垃圾回收时才释放 —— 而在高负载下,垃圾回收可能来得不够及时。

隐式创建流分支的 API 使这个问题雪上加霜。Request.clone()Response.clone()会对 body 流执行隐式的tee()操作 —— 这个细节很容易被忽视。为了日志记录或重试逻辑而克隆请求的代码,可能在不知不觉中创建了需要独立消费的分支流,成倍增加了资源管理的负担。

当然,必须说明的是,这类问题本质上是实现层面的 bug。连接泄漏确实是 undici 自身实现需要修复的问题,但规范的复杂性使得处理这类问题绝非易事。

Matteo Collina 博士,Platformatic 联合创始人兼 CTO,Node.js 技术指导委员会主席:

" 在 Node.js 的fetch()实现中,克隆流远比看上去复杂。当你克隆一个请求或响应的 body 时,实际上是在调用tee()—— 它将单个流拆分成两个分支,两者都需要被消费。如果一个消费者读取速度快于另一个,数据就会在内存中无限缓冲,等待慢速分支来读取。如果你没有正确消费两个分支,底层连接就会泄漏。两个读取器共享同一个源所需的协调工作,使得意外破坏原始请求或耗尽连接池变得轻而易举。这是一个看似简单的 API 调用,背后却隐藏着极其复杂且难以正确实现的底层机制。"

#### tee()的内存悬崖:一脚踩空 tee()将一个流拆分为两个分支。看似简单直白,但实现时需要缓冲:如果一个分支的读取速度快于另一个,数据就必须暂存在某处,等待较慢的分支赶上来。 const [forHash, forStorage] = response.body.tee(); // 哈希计算很快 const hash = await computeHash(forHash); // 存储写入很慢——与此同时,整个流的数据 // 可能都缓冲在内存中,等着这个分支来读取 await writeToStorage(forStorage); 规范并未为tee()规定缓冲区上限。公平地说,规范允许实现方以任何方式来实现tee()及其他 API 的内部机制,只要满足规范中可观测的规范性要求即可。但如果实现方选择按照流规范所描述的特定方式来实现tee(),那么tee()就会天然地带有一个难以规避的内存管理问题。

各实现方不得不各自想办法应对。Firefox 最初采用了链表方式,导致内存增长与两端消费速率差成 O (n) 的正比关系。在 Cloudflare Workers 中,我们选择实现了共享缓冲区模型,由最慢的消费者而非最快的消费者来发出背压信号。

!Image 4

#### 转换流的背压缺口 TransformStream创建了一对可读 / 可写流,中间夹着处理逻辑。transform()函数在写入时执行,而非在读取时。转换处理是急切模式 —— 数据一到就立刻执行,不管下游消费者是否准备好了。当消费者速度较慢时,这会造成不必要的工作,而且两端之间的背压传导存在缺口,在高负载下可能导致无限制的缓冲区膨胀。规范的预期是,被转换数据的生产者应该关注转换流可写端的writer.ready信号,但生产者往往直接无视它。

如果转换流的transform()操作是同步的,且总是立即将输出入队,那么即使下游消费者很慢,它也永远不会向可写端回传背压信号。这是规范设计的必然结果,却被许多开发者完全忽视。在浏览器中,只有单个用户,任意时刻活跃的流管道数量通常也很少,这类隐患往往无关紧要;但在服务端或边缘运行时中,面对成千上万的并发请求,其影响不可小觑。 const fastTransform = new TransformStream({   transform(chunk, controller) {     // 同步入队——永远不会施加背压     // 即使可读端的缓冲区已满,这里也照样成功     controller.enqueue(processChunk(chunk));   } }); // 将快速源通过转换流管道传输到慢速目标 fastSource   .pipeThrough(fastTransform)   .pipeTo(slowSink);  // 缓冲区无限增长 TransformStream本应做的是检查控制器上的背压状态,并通过 Promise 将其传达回写入端: const fastTransform = new TransformStream({   async transform(chunk, controller) {     if (controller.desiredSize <= 0) {       // 以某种方式等待背压消除     }     controller.enqueue(processChunk(chunk));   } }); 然而,难点在于TransformStreamDefaultController不像 Writer 那样有readyPromise 机制;因此转换流的实现需要自行搭建一个轮询机制,定期检查controller.desiredSize何时重新变为正值。

在管道链中,问题会进一步恶化。当你串联多个转换流 —— 比如解析、转换、然后序列化 —— 每个TransformStream都有自己的内部可读和可写缓冲区。如果实现者严格遵循规范,数据会以推送驱动的方式在这些缓冲区之间级联流转:源推送给转换 A,A 推送给转换 B,B 再推送给转换 C,每一级都在中间缓冲区中堆积数据,而最终消费者甚至还没开始拉取。三个转换流,就可能有六个内部缓冲区同时填满。

使用流 API 的开发者本应记得在创建源、转换流和可写目标时使用highWaterMark等选项,但他们要么忘了,要么干脆选择忽略。 source   .pipeThrough(parse)      // 缓冲区在填充……   .pipeThrough(transform)  // 更多缓冲区在填充……   .pipeThrough(serialize)  // 还有更多缓冲区……   .pipeTo(destination);    // 消费者还没开始读呢 各实现方已经找到了优化转换管道的方法:折叠恒等转换、短路不可观测的路径、延迟缓冲区分配,或者回退到完全不执行 JavaScript 的原生代码。Deno、Bun 和 Cloudflare Workers 都成功实现了 "原生路径" 优化以消除大量开销,Vercel 最近的 fast-webstreams 研究也在为 Node.js 探索类似的优化。但这些优化本身增加了显著的复杂性,而且仍然无法完全摆脱TransformStream固有的推送驱动模型。

!Image 5

#### 服务端渲染中的 GC 风暴

流式服务端渲染(SSR)是一个尤为痛苦的场景。一个典型的 SSR 流可能需要渲染数千个小型 HTML 片段,每一个都要经过流的处理机制: // 每个组件入队一小块数据 function renderComponent(controller) {   controller.enqueue(encoder.encode(

${content}
)); } // 成百上千个组件 = 成百上千次 enqueue 调用 // 每一次都会在内部触发 Promise 机制 for (const component of components) {   renderComponent(controller);  // 创建 Promise,分配对象 } 每个片段都意味着:为read()调用创建 Promise,为背压协调创建 Promise,中间缓冲区的内存分配,以及{ value, done }结果对象 —— 其中绝大多数几乎立刻就变成了垃圾。

在高负载下,这会产生巨大的 GC 压力,足以摧毁吞吐量。JavaScript 引擎把大量时间花在回收短命对象上,而不是做真正有用的工作。延迟变得不可预测,因为 GC 暂停会打断请求处理。我见过一些 SSR 工作负载,垃圾回收占据了每个请求总 CPU 时间的相当大比例(高达甚至超过 50%)。那些时间本可以用来真正渲染内容。

讽刺的是,流式 SSR 的初衷恰恰是通过增量发送内容来提升性能。但流处理机制本身的开销却可能抵消这些收益,尤其是对于包含大量小型组件的页面。开发者有时会发现,把整个响应缓冲起来一次性发送,实际上比通过 Web 流进行流式传输更快 —— 这完全违背了初衷。 【早阅】Axios 请求可能存在 SSRF和凭据泄露风险

#### 永无止境的优化跑步机

为了达到可用的性能水平,每个主要运行时都不得不为 Web 流求助于非标准的内部优化。Node.js、Deno、Bun 和 Cloudflare Workers 都各自开发了自己的变通方案。对于与系统级 I/O 关联的流来说尤其如此 —— 在那里,大量内部机制对外不可观测,因此可以被短路绕过。

发现这些优化机会本身就是一项浩大的工程。它需要对规范有端到端的透彻理解,才能辨别哪些行为是可观测的、哪些可以安全地省略。即便如此,某个优化是否真正符合规范,往往也不甚明朗。实现者必须自行判断哪些语义可以放松而不破坏兼容性。这给运行时团队施加了巨大的压力 —— 他们必须成为规范专家,仅仅是为了达到可接受的性能水平。

这些优化实现难度大、极易出错,且导致各运行时之间行为不一致。Bun 的 "Direct Streams" 优化刻意采取了可观测的非标准路径,绕过了规范的大量机制。Cloudflare Workers 的IdentityTransformStream为透传转换提供了快速路径,但它是 Workers 特有的,实现了对TransformStream来说并不标准的行为。每个运行时都有自己的一套技巧,而且自然而然地倾向于非标准方案,因为那往往是让性能过关的唯一出路。

这种碎片化损害了可移植性。在一个运行时上性能优异的代码,在另一个运行时上可能表现迥异(甚至很差),尽管使用的是 "标准" API。运行时实现者承受着沉重的复杂性负担,而细微的行为差异给试图编写跨运行时代码的开发者带来了重重阻碍 —— 对于那些维护必须在多个运行时环境中高效运行的框架的开发者来说,尤为如此。

还有必要强调的是,许多优化只有在规范中对用户代码不可观测的部分才有可能实现。另一条路 —— 像 Bun 的 "Direct Streams" 那样 —— 则是有意偏离规范定义的可观测行为。这意味着优化往往给人一种 "不完整" 的感觉。它们在某些场景下有效,在另一些场景下无效;在某些运行时可以,在另一些运行时不行,等等。每一个这样的案例都在加剧 Web 流方案整体上不可持续的复杂性 —— 这也是为什么大多数运行时实现者一旦通过了一致性测试,就很少再在流的实现上投入更多精力去做进一步改进。

实现者不应该被迫跨越这些障碍。当你发现必须放松或绕过规范语义才能达到合理的性能时,这本身就说明规范出了问题。一套设计良好的流式 API 应当天然高效,而不是让每个运行时各自发明自己的逃生通道。

#### 合规性的重负

复杂的规范催生复杂的边界情况。Web 平台针对流的测试横跨 70 多个测试文件,虽然全面的测试本身是好事,但真正耐人寻味的是 —— 到底哪些东西需要被测试。

来看看实现方必须通过的一些较为冷僻的测试: 原型链污染防御:

有一个测试会修补Object.prototype.then来拦截 Promise 的 resolve 过程,然后验证pipeTo()tee()操作不会通过原型链泄漏内部值。这是在测试一个安全属性,而该属性之所以存在,恰恰因为规范中大量使用 Promise 的内部机制制造了攻击面。 WebAssembly 内存拒绝:

BYOB 读取必须显式拒绝由 WebAssembly 内存支撑的ArrayBuffer—— 它们看起来和普通缓冲区一模一样,却无法被转移。这个边界情况的存在源于规范的缓冲区分离模型 —— 一个更简洁的 API 根本无需处理它。 状态机冲突的崩溃回归测试:

有一个测试专门检查在enqueue()之后调用byobRequest.respond()不会导致运行时崩溃。这一调用序列会在内部状态机中制造冲突 ——enqueue()已经满足了待处理的读取请求,本应使byobRequest失效,但实现方必须优雅地处理随后的respond()调用,而不是破坏内存 —— 因为开发者没有正确使用这套复杂 API 的可能性极高。

这些绝非测试编写者凭空杜撰的场景。它们是规范设计的必然产物,反映的是真实存在的 bug。

对于运行时实现者来说,通过 WPT 测试套件意味着要处理大多数应用代码永远不会遇到的精密边界情况。这些测试编码的不仅是正常路径,而是读取器、写入器、控制器、队列、策略以及将它们串联起来的 Promise 机制之间的完整交互矩阵。

一套更简洁的 API 意味着更少的概念、更少的概念间交互、更少需要处理正确的边界情况,从而让人们对各实现确实行为一致更有信心。

#### 要点总结

Web 流对用户和实现者来说都很复杂。规范的问题不是 bug。它们出现在你完全按照设计使用 API 的时候。它们不是仅靠渐进式改良就能修复的问题。它们是根本性设计决策的必然结果。要想改善,我们需要不同的根基。

#### 更好的流式 API 是可能的

在不同运行时中多次实现 Web 流规范、亲身体验了种种痛点之后,我决定是时候探索一下:如果从今天的第一性原理出发重新设计,一套更好的替代流式 API 应该是什么样子。

接下来展示的是一个概念验证:它不是成型的标准,不是可用于生产的库,甚至不一定是某个新事物的具体提案,而是一个供讨论的起点 —— 用以证明 Web 流的问题并非流式处理本身固有的,而是特定设计选择的产物,而这些选择完全可以做得不同。这套 API 本身是否就是正确答案并不那么重要,重要的是它能否引发一场富有成效的讨论:我们究竟需要流式原语提供什么。

#### 流是什么?

在深入 API 设计之前,值得先问一个问题:流到底是什么?

本质上,流就是一组随时间到达的数据序列。你不会一次拿到全部。你在数据可用时逐步处理它。

Unix 管道或许是这一理念最纯粹的表达: cat access.log | grep "error" | sort | uniq -c 数据从左向右流动。每个阶段读取输入、完成处理、写出结果。无需获取管道读取器,无需管理控制器锁。如果下游阶段速度慢,上游阶段自然也会慢下来。背压隐含在模型之中,而非一套需要单独学习(或被忽视)的独立机制。

在 JavaScript 中,"随时间到达的事物序列" 这一天然原语已经存在于语言之中:异步可迭代对象。你用for await...of来消费它。你通过停止迭代来停止消费。

这正是新 API 试图保留的直觉:流应该给人迭代的感觉,因为流本来就是迭代。Web 流的复杂性 —— 读取器、写入器、控制器、锁、队列策略 —— 遮蔽了这种根本性的简洁。更好的 API 应该让简单的场景保持简单,只在真正需要的地方引入复杂性。

!Image 6

#### 设计原则

我基于一套不同的原则构建了这个概念验证方案。 流即可迭代对象。

没有带隐藏内部状态的自定义ReadableStream类。可读流就是一个AsyncIterable<Uint8Array[]>。你用for await...of来消费它。无需获取读取器,无需管理锁。 拉取式转换。

转换不会在消费者拉取之前执行。没有急切求值,没有隐藏的缓冲。数据按需从源头经过转换流向消费者。你停止迭代,处理就停止。

!Image 7 显式背压。

背压默认是严格的。当缓冲区满时,写入会被拒绝,而非默默堆积。你可以配置替代策略 —— 阻塞直到有空间、丢弃最旧的、丢弃最新的 —— 但你必须显式选择。内存的隐性膨胀不复存在。 批量数据块。

流在每次迭代中不是只产出一个数据块,而是产出Uint8Array[]:数据块的数组。这样就把异步开销分摊到了多个数据块上,减少了热路径中 Promise 的创建和微任务延迟。 纯字节流。

该 API 只处理字节(Uint8Array)。字符串会自动进行 UTF-8 编码。不存在 "值流" 与 "字节流" 的二分法。如果你想流式传输任意 JavaScript 值,直接使用异步可迭代对象即可。虽然 API 使用Uint8Array,但它将数据块视为不透明的整体。没有部分消费,没有 BYOB 模式,流处理机制本身不进行字节级操作。数据块进来,数据块出去,原封不动 —— 除非某个转换显式地修改了它们。 同步快速路径至关重要。

该 API 认识到同步数据源既有必要又很常见。不应仅仅因为异步调度是唯一可用的选项,就强迫应用始终承受其性能代价。与此同时,混合同步和异步处理可能带来风险。同步路径应该始终是一个可选项,且始终是显式的。

#### 新 API 实战

##### 创建和消费流

在 Web 流中,创建一个简单的生产者 / 消费者对需要TransformStream、手动编码以及小心翼翼的锁管理: const { readable, writable } = new TransformStream(); const enc = new TextEncoder(); const writer = writable.getWriter(); await writer.write(enc.encode("Hello, World!")); await writer.close(); writer.releaseLock(); const dec = new TextDecoder(); let text = ''; for await (const chunk of readable) {   text += dec.decode(chunk, { stream: true }); } text += dec.decode(); 即便是这个相对简洁的版本,也需要:一个TransformStream、手动使用TextEncoderTextDecoder,以及显式释放锁。

下面是新 API 的等价写法: import { Stream } from 'new-streams'; // 创建一个推送流 const { writer, readable } = Stream.push(); // 写入数据——背压被强制执行 await writer.write("Hello, World!"); await writer.end(); // 以文本形式消费 const text = await Stream.text(readable); readable就是一个异步可迭代对象。你可以把它传给任何接受异步可迭代对象的函数,包括Stream.text()—— 它会收集并解码整个流。

Writer 的接口非常简洁:write()写入数据,writev()批量写入,end()表示完成,abort()处理错误。基本上就这些了。

Writer 不是一个具体的类。任何实现了write()end()abort()的对象都可以充当 Writer,这使得适配现有 API 或创建专用实现变得轻而易举,无需继承子类。这里没有复杂的UnderlyingSink协议 —— 不需要start()write()close()abort()回调通过一个生命周期和状态独立于其所绑定的WritableStream的控制器来协调。

下面是一个简单的内存 Writer,用于收集所有写入的数据: // 一个最简的 Writer 实现——就是一个带方法的对象 function createBufferWriter() {   const chunks = [];   let totalBytes = 0;   let closed = false;   const addChunk = (chunk) => {     chunks.push(chunk);     totalBytes += chunk.byteLength;   };   return {     get desiredSize() { return closed ? null : 1; },     // 异步变体     write(chunk) { addChunk(chunk); },     writev(batch) { for (const c of batch) addChunk(c); },     end() { closed = true; return totalBytes; },     abort(reason) { closed = true; chunks.length = 0; },     // 同步变体返回布尔值(true = 已接受)     writeSync(chunk) { addChunk(chunk); return true; },     writevSync(batch) { for (const c of batch) addChunk(c); return true; },     endSync() { closed = true; return totalBytes; },     abortSync(reason) { closed = true; chunks.length = 0; return true; },     getChunks() { return chunks; }   }; } // 使用它 const writer = createBufferWriter(); await Stream.pipeTo(source, writer); const allData = writer.getChunks(); 无需继承基类,无需实现抽象方法,无需与控制器协调。只是一个具备正确结构的对象,仅此而已。

#### 拉取式转换

在新的 API 设计下,转换在数据被消费之前不应执行任何工作。这是一条根本性原则。 // 迭代开始之前,什么都不会执行 const output = Stream.pull(source, compress, encrypt); // 转换随迭代按需执行 for await (const chunks of output) {   for (const chunk of chunks) {     process(chunk);   } } Stream.pull()创建的是一条惰性管道。compressencrypt转换在你开始迭代output之前不会运行。每次迭代都是按需将数据从管道中拉取出来。

这与 Web 流的pipeThrough()有着本质区别 —— 后者在你建立管道的那一刻就开始主动将数据从源推送到转换流。拉取语义意味着你掌控处理发生的时机,停止迭代就停止处理。

转换可以是无状态的,也可以是有状态的。无状态转换就是一个接收数据块并返回转换后数据块的函数: // 无状态转换——一个纯函数 // 接收数据块或 null(冲刷信号) const toUpperCase = (chunks) => {   if (chunks === null) return null; // 流结束   return chunks.map(chunk => {     const str = new TextDecoder().decode(chunk);     return new TextEncoder().encode(str.toUpperCase());   }); }; // 直接使用 const output = Stream.pull(source, toUpperCase); 有状态转换则是带有成员函数的简单对象,跨调用维护状态: // 有状态转换——一个包装源的生成器 function createLineParser() {   // 拼接 Uint8Array 的辅助函数   const concat = (...arrays) => {     const result = new Uint8Array(arrays.reduce((n, a) => n + a.length, 0));     let offset = 0;     for (const arr of arrays) { result.set(arr, offset); offset += arr.length; }     return result;   };   return {     async *transform(source) {       let pending = new Uint8Array(0);       for await (const chunks of source) {         if (chunks === null) {           // 冲刷:产出所有剩余数据           if (pending.length > 0) yield [pending];           continue;         }         // 将待处理数据与新数据块拼接         const combined = concat(pending, ...chunks);         const lines = [];         let start = 0;         for (let i = 0; i < combined.length; i++) {           if (combined[i] === 0x0a) { // 换行符             lines.push(combined.slice(start, i));             start = i + 1;           }         }         pending = combined.slice(start);         if (lines.length > 0) yield lines;       }     }   }; } const output = Stream.pull(source, createLineParser()); 对于需要在中止时进行清理的转换,添加一个 abort 处理函数即可: // 带资源清理的有状态转换 function createGzipCompressor() {   // 假设的压缩 API……   const deflate = new Deflater({ gzip: true });   return {     async *transform(source) {       for await (const chunks of source) {         if (chunks === null) {           // 冲刷:完成压缩           deflate.push(new Uint8Array(0), true);           if (deflate.result) yield [deflate.result];         } else {           for (const chunk of chunks) {             deflate.push(chunk, false);             if (deflate.result) yield [deflate.result];           }         }       }     },     abort(reason) {       // 在出错或取消时清理压缩器资源     }   }; } 对于实现者而言,这里没有带start()transform()flush()方法的 Transformer 协议,也不需要通过控制器协调并传入一个拥有自己隐藏状态机和缓冲机制的TransformStream类。转换就是函数或简单对象 —— 实现和测试都简单得多。

#### 显式背压策略

当有界缓冲区填满而生产者还想继续写入时,能做的事情其实屈指可数:

* 拒绝写入: 不再接受更多数据

* 等待: 阻塞直到有可用空间

* 丢弃旧数据:淘汰已缓冲的内容以腾出空间

* 丢弃新数据:扔掉新到达的内容

就这些了。任何其他应对方式要么是这几种的变体(比如 "调整缓冲区大小",本质上只是推迟了选择),要么是不属于通用流式原语的领域特定逻辑。Web 流目前默认始终选择等待。

!Image 8

新 API 要求你从这四种策略中显式选择其一:

* strict(默认):当缓冲区已满且有过多待处理写入时,拒绝写入。专门用来捕获生产者无视背压的 "发射即忘" 模式。

* block:写入会等待直到缓冲区有空间。适用于你信任生产者会正确 await 写入操作的场景。

* drop-oldest:丢弃缓冲区中最旧的数据以腾出空间。适用于过时数据会失去价值的实时数据流。

* drop-newest:缓冲区满时丢弃新到达的数据。适用于你只想处理已有数据而不希望被淹没的场景。 const { writer, readable } = Stream.push({   highWaterMark: 10,   backpressure: 'strict' // 或 'block'、'drop-oldest'、'drop-newest' }); 不必再寄望于生产者的配合。你选择的策略决定了缓冲区满时会发生什么。

以下是当生产者写入速度快于消费者读取速度时,各策略的具体表现: // strict:捕获无视背压的"发射即忘"式写入 const strict = Stream.push({ highWaterMark: 2, backpressure: 'strict' }); strict.writer.write(chunk1);  // 正常(未 await) strict.writer.write(chunk2);  // 正常(填满数据槽缓冲区) strict.writer.write(chunk3);  // 正常(进入待处理队列) strict.writer.write(chunk4);  // 正常(待处理缓冲区填满) strict.writer.write(chunk5);  // 抛出异常!待处理写入过多 // block:等待空间(无界待处理队列) const blocking = Stream.push({ highWaterMark: 2, backpressure: 'block' }); await blocking.writer.write(chunk1);  // 正常 await blocking.writer.write(chunk2);  // 正常 await blocking.writer.write(chunk3);  // 等待消费者读取 await blocking.writer.write(chunk4);  // 等待消费者读取 await blocking.writer.write(chunk5);  // 等待消费者读取 // drop-oldest:丢弃旧数据以腾出空间 const dropOld = Stream.push({ highWaterMark: 2, backpressure: 'drop-oldest' }); await dropOld.writer.write(chunk1);  // 正常 await dropOld.writer.write(chunk2);  // 正常 await dropOld.writer.write(chunk3);  // 正常,chunk1 被丢弃 // drop-newest:缓冲区满时丢弃新到达的数据 const dropNew = Stream.push({ highWaterMark: 2, backpressure: 'drop-newest' }); await dropNew.writer.write(chunk1);  // 正常 await dropNew.writer.write(chunk2);  // 正常 await dropNew.writer.write(chunk3);  // 静默丢弃 #### 显式多消费者模式 // 通过显式缓冲区管理实现共享 const shared = Stream.share(source, {   highWaterMark: 100,   backpressure: 'strict' }); const consumer1 = shared.pull(); const consumer2 = shared.pull(decompress); 不再是tee()及其隐藏的无界缓冲区,取而代之的是显式的多消费者原语。Stream.share()基于拉取模式:消费者从共享源中按需拉取,缓冲区限制和背压策略在一开始就配置好。

此外还有Stream.broadcast(),用于基于推送的多消费者场景。两者都要求你提前思考当消费者运行速度不一致时会发生什么 —— 因为这是一个不应被掩盖的真实问题。

#### 同步与异步的分离

并非所有流式工作负载都涉及 I/O。当你的数据源在内存中,转换函数又是纯函数时,异步机制只会徒增开销而毫无益处。你在为根本不需要的 "等待" 协调付出代价。

新 API 提供了完整的同步对应版本:Stream.pullSync()Stream.bytesSync()Stream.textSync(),等等。如果你的数据源和转换都是同步的,就可以在整条管道中不创建任何一个 Promise。 // 异步——当数据源或转换可能是异步的 const textAsync = await Stream.text(source); // 同步——当所有组件都是同步的 const textSync = Stream.textSync(source); 下面是一条完整的同步管道 —— 压缩、转换和消费,异步开销为零: // 从内存数据创建同步数据源 const source = Stream.fromSync([inputBuffer]); // 同步转换 const compressed = Stream.pullSync(source, zlibCompressSync); const encrypted = Stream.pullSync(compressed, aesEncryptSync); // 同步消费——没有 Promise,没有事件循环调度 const result = Stream.bytesSync(encrypted); 整条管道在单个调用栈内执行完毕。不创建任何 Promise,不触发微任务队列调度,也不会因短命的异步机制产生 GC 压力。对于解析、压缩或内存数据转换等 CPU 密集型工作负载来说,这可以比等价的 Web 流代码快上许多 —— 后者即使每个组件都是同步的,也要强制引入异步边界。

Web 流没有同步路径。即便你的数据源已经准备好了数据,转换也是纯函数,你依然要为每个操作付出 Promise 创建和微任务调度的代价。Promise 在确实需要等待的场景下非常出色,但等待并非总是必要的。新 API 让你在需要同步的时候就能留在同步的世界里。

#### 与 Web 流之间的桥梁

基于异步迭代器的方案在这一替代方案与 Web 流之间搭建了一座天然的桥梁。从ReadableStream过渡到新方案时,只需将 readable 作为输入传入即可 —— 前提是该ReadableStream产出的是字节流: const readable = getWebReadableStreamSomehow(); const input = Stream.pull(readable, transform1, transform2); for await (const chunks of input) {   // 处理数据块 } 当需要适配回ReadableStream时,由于新方案产出的是批量数据块,需要多做一步工作,但适配层同样简单直观: async function* adapt(input) {   for await (const chunks of input) {     for (const chunk of chunks) {       yield chunk;     }   } } const input = Stream.pull(source, transform1, transform2); const readable = ReadableStream.from(adapt(input)); #### 如何解决前文提到的现实问题 未消费的响应体:

拉取语义意味着不迭代就什么都不会发生。没有隐藏的资源占用。如果你不消费一个流,就不会有后台机制持有连接不放。 tee()的内存悬崖: Stream.share()要求显式配置缓冲区。你在一开始就选定highWaterMark和背压策略 —— 当消费者速度不一致时,不会再有悄无声息的无限增长。 转换流的背压缺口:

拉取式转换按需执行。数据不会在中间缓冲区中级联堆积;只有在消费者拉取时才会流动。停止迭代,处理就停止。 SSR 中的 GC 风暴:

批量数据块(Uint8Array[])分摊了异步开销。通过Stream.pullSync()实现的同步管道彻底消除了 CPU 密集型工作负载中的 Promise 分配。

#### 性能

设计选择直接影响性能表现。以下是这一替代方案的参考实现与 Web 流的基准测试对比(Node.js v24.x,Apple M1 Pro,10 次运行取平均值):

| 场景 | 替代方案 | Web 流 | 差距 | | --- | --- | --- | --- | | 小数据块(1KB × 5000) | ~13 GB/s | ~4 GB/s | ~3 倍 | | 微小数据块(100B × 10000) | ~4 GB/s | ~450 MB/s | ~8 倍 | | 异步迭代(8KB × 1000) | ~530 GB/s | ~35 GB/s | ~15 倍 | | 三级链式转换(8KB × 500) | ~275 GB/s | ~3 GB/s | ~80–90 倍 | | 高频场景(64B × 20000) | ~7.5 GB/s | ~280 MB/s | ~25 倍 |

链式转换的结果尤为惊人:拉取语义消除了困扰 Web 流管道的中间缓冲问题。数据不再是每个TransformStream急切地填满自己的内部缓冲区,而是按需从消费者到源头流动。

公平地说,Node.js 确实尚未在全面优化其 Web 流实现的性能上投入太多精力。通过有针对性地优化热路径,Node.js 的性能数据应该还有不小的提升空间。话虽如此,在 Deno 和 Bun 上运行这些基准测试,同样显示出这种基于迭代器的替代方案相比它们各自的 Web 流实现有显著的性能提升。

浏览器基准测试(Chrome/Blink,3 次运行取平均值)也展现出一致的优势:

| 场景 | 替代方案 | Web 流 | 差距 | | --- | --- | --- | --- | | 推送 3KB 数据块 | ~135k ops/s | ~24k ops/s | ~5–6 倍 | | 推送 100KB 数据块 | ~24k ops/s | ~3k ops/s | ~7–8 倍 | | 三级转换链 | ~4.6k ops/s | ~880 ops/s | ~5 倍 | | 五级转换链 | ~2.4k ops/s | ~550 ops/s | ~4 倍 | | bytes() 消费 | ~73k ops/s | ~11k ops/s | ~6–7 倍 | | 异步迭代 | ~1.1M ops/s | ~10k ops/s | ~40–100 倍 |

这些基准测试衡量的是受控场景下的吞吐量;实际性能取决于你的具体用例。Node.js 与浏览器之间增益幅度的差异,反映了两种环境对 Web 流所采取的不同优化路径。

值得一提的是,这些基准测试是用新 API 的纯 TypeScript/JavaScript 实现与各运行时中 Web 流的原生(JavaScript/C++/Rust)实现进行比较。新 API 的参考实现尚未做过任何性能优化工作;所有增益完全来自设计本身。若有原生实现,性能很可能还会进一步提升。

这些增益说明了根本性的设计选择是如何层层叠加的:批量处理分摊了异步开销,拉取语义消除了中间缓冲,而当数据立即可用时允许实现方使用同步快速路径 —— 三者共同作用,造就了这些成果。

> "我们在提升 Node 流的性能和一致性方面做了大量工作,但从零开始有一种独特的力量。新流的方案拥抱了现代运行时的现实,没有历史包袱,这为一个更简洁、更高效、更自洽的流模型打开了大门。" —— Robert Nagy,Node.js TSC 成员、Node.js 流模块贡献者

#### 下一步

我发表这篇文章是为了开启一场对话。我说对了什么?遗漏了什么?有没有不适合这个模型的用例?这种方案的迁移路径会是什么样子?目标是从那些切身感受过 Web 流之痛、对更好的 API 应该长什么样有自己见解的开发者那里收集反馈。

##### 亲自试试

这一替代方案的参考实现现已可用,地址是 https://github.com/jasnell/new-streams。 API 参考文档:完整文档请查阅 API.md 示例代码:samples 目录下有常见模式的可运行代码

欢迎提交 issue、参与讨论和发起 pull request。如果你遇到过我未涉及的 Web 流问题,或者你发现了这个方案的不足之处,请告诉我。但再次强调,这里的初衷不是说 "大家快来用这个闪亮的新玩意儿!",而是要开启一场超越 Web 流现状、回归第一性原理的讨论。

Web 流是一个雄心勃勃的项目,在别无选择的年代将流式处理带到了 Web 平台。设计它的人们在 2014 年的约束条件下做出了合理的选择 —— 那时还没有异步迭代,也没有多年生产实践揭示出的各种边界情况。

但从那以后,我们学到了很多。JavaScript 已经进化了。一套在今天设计的流式 API 可以更简洁,与语言更契合,对真正重要的事情 —— 比如背压和多消费者行为 —— 更加直言不讳。

我们值得拥有更好的流式 API。所以,让我们来聊聊它应该是什么样子。

关于本文

译者:@飘飘

作者:@James M Snell

原文:https://blog.cloudflare.com/a-better-web-streams-api/

这期前端早读课

对你有帮助,帮”赞“一下,

期待下一期,帮”在看” 一下。 阅读原文 跳转微信打开

查看原文 → 發佈: 2026-03-12 09:02:00 收錄: 2026-03-12 12:01:08

🤖 問 AI

針對這篇文章提問,AI 會根據文章內容回答。按 Ctrl+Enter 送出。