在现代 Web 开发中,Web Streams API 是由 WHATWG 标准定义的,旨在为浏览器环境(以及现在的 Node.js 和 Deno)提供一套统一、高性能、可组合的流处理方式。
相比传统的 Node.js Streams,Web Streams 更具通用性,深度集成了 fetch 和异步迭代器。
1. Web Streams 的核心组件
Web Streams 主要由三个角色组成,构成了一个完整的"生产者-消费者"管道:
① ReadableStream (可读流)
数据的源头。它将底层资源(如网络请求、文件、甚至是一个计时器)封装起来。
- 控制器 (Controller) :用于向流中推送数据(
enqueue)或关闭流。 - 读取器 (Reader) :通过
getReader()获取,确保同一时间只有一个对象在读取数据(独占锁机制)。
② WritableStream (可写流)
数据的终点。例如将数据写入磁盘、发送到打印机或更新 DOM。
- 写入器 (Writer) :通过
getWriter()获取。
③ TransformStream (转换流)
中间的过滤器。它包含一个可写端和一个可读端,数据从一端进入,经过处理后从另一端流出。常用于压缩、解密或格式转换。
2. 核心原理:背压与分块
背压 (Backpressure)
Web Streams 完美内置了背压管理。
- 每个流都有一个内部队列(Buffer)。
- 当消费者处理速度慢于生产者时,队列会积压。
- 一旦积压达到阈值(High Water Mark),控制器会向生产者发出信号(通过
desiredSize属性),告知其停止产生数据。
分块 (Chunking)
流中的数据被分解为一个个小单位,称为 Chunk 。Chunk 既可以是字符串,也可以是 Uint8Array,甚至是自定义对象。
3. 详细代码示例
示例 A:从零创建一个 ReadableStream
这个例子模拟了一个每秒产生一个数字的流,当数字达到 5 时停止。
JavaScript
javascript
const customStream = new ReadableStream({
start(controller) {
let count = 0;
const interval = setInterval(() => {
count++;
if (count <= 5) {
// 向流中推送数据
controller.enqueue(`数据块 #${count}\n`);
} else {
// 关闭流
controller.close();
clearInterval(interval);
}
}, 1000);
},
cancel() {
// 如果消费者取消了流,在这里进行清理
console.log("流被消费者取消了");
}
});
// 消费流
const reader = customStream.getReader();
(async () => {
while (true) {
const { done, value } = await reader.read();
if (done) break;
console.log("收到:", value);
}
})();
示例 B:使用 TransformStream 实时转换数据
我们将一个 fetch 获取的文本流实时转换为大写,并展示在控制台。
JavaScript
javascript
// 1. 定义一个简单的转换流逻辑
const upperCaseTransformer = new TransformStream({
transform(chunk, controller) {
// 假设 chunk 是字符串(或者先解析为字符串)
controller.enqueue(chunk.toUpperCase());
}
});
async function fetchAndTransform() {
const response = await fetch('https://example.com/data.txt');
// 建立管道:原始流 -> 文本解码流 -> 大写转换流
const transformedStream = response.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(upperCaseTransformer);
// 消费最终的流
const reader = transformedStream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
console.log("转换结果:", value);
}
}
4. Web Streams 与 Node.js Streams 的区别
| 特性 | Web Streams (WHATWG) | Node.js Streams |
|---|---|---|
| 标准 | Web 浏览器标准 | Node.js 自有标准 |
| 核心方法 | pipeThrough, pipeTo |
pipe |
| 访问控制 | 使用 Reader/Writer 独占锁 | 监听 data 事件或 read() |
| Promise 支持 | 原生深度集成 | 早期基于事件,现代版支持 Promise |
| 主要环境 | 全平台 (浏览器/Edge/Node) | 主要在服务器端 |
5. 为什么要学习 Web Streams?
- 性能:数据不需要一次性全部加载到内存。处理 1GB 的文件和处理 1KB 的文件,内存占用可以保持一致。
- 流式 Fetch:你可以实现在图片还没下载完时就开始处理像素,或者在大型 JSON 下载时就开始解析。
- 标准化:编写的代码可以同时在 Chrome、Cloudflare Workers 和 Node.js 中运行。
进阶技巧:Tee (分叉)
你可以使用 readableStream.tee() 将一个流分叉成两个完全相同的流。这在需要同时将数据展示在页面上并上传到服务器时非常有用。
6. 如何定时器中处理被压?
这是一个非常深入的问题。在第一个 ReadableStream 实例中,如果你设置了一个频率极高的定时器(比如 setInterval(..., 0)),背压的实现依赖于 controller.desiredSize 属性和 底层逻辑的协作。
1. 背压的核心信号灯:desiredSize
每个 Web 可读流内部都有一个队列(Internal Queue) 。
- HWM (High Water Mark) :高水位线,默认通常是 1(如果以分块数量计)。
desiredSize:这是一个计算值,公式为:HWM - 当前队列中数据的总量。
当 desiredSize > 0 时,表示队列还没满,可以继续产生数据。
当 desiredSize <= 0 时,表示队列已满,流会通过这个值向你发出"停工"信号。
2. 定时器场景下的背压实现
在 ReadableStream 的构造函数中,start 方法是立即执行的。如果里面有个死循环或极快的定时器,它并不会自动 强行掐断你的定时器,而是需要你手动检查信号。
错误写法(无视背压):
即使队列满了,定时器依然在疯狂 enqueue,这会导致内部内存暴涨。
JavaScript
javascript
const stream = new ReadableStream({
start(controller) {
setInterval(() => {
// ❌ 错误:不管消费者吃不吃得消,一直往里塞
controller.enqueue("数据块");
}, 0);
}
});
正确写法(响应背压):
你需要配合 pull 方法,或者在生产数据前检查 desiredSize。
方法 A:使用 pull 方法(最标准的背压实践)
pull 方法的特性是:只要内部队列没满,浏览器就会反复调用它。如果队列满了,调用就会停止,直到消费者取走数据。
JavaScript
ini
let count = 0;
const stream = new ReadableStream({
// pull 会在队列未满时自动被重复调用
pull(controller) {
count++;
controller.enqueue(`数据 #${count}`);
// 如果达到了某种条件,可以关闭
if (count >= 1000) controller.close();
}
});
方法 B:在定时器中检查 desiredSize
如果你必须使用定时器,你应该通过判断 desiredSize 来决定是否跳过当前节拍。
JavaScript
javascript
const stream = new ReadableStream({
start(controller) {
const interval = setInterval(() => {
// ✅ 检查背压信号
// 如果 desiredSize <= 0,说明消费者还没处理完,我们先不 enqueue
if (controller.desiredSize > 0) {
controller.enqueue("数据");
} else {
console.log("背压触发:队列已满,跳过本次生产");
}
}, 1);
}
});
3. 底层原理:当 enqueue 发生时
- 当你调用
controller.enqueue()时,数据进入流的内部队列。 - 如果消费者(Reader)调用了
read(),数据会立即被派发给消费者,队列清空。 - 如果消费者处理极慢,数据会堆积在队列里,直到达到
HighWaterMark。 - 此时
desiredSize变成 0 或负数。 - 核心点 :Web Streams 机制本身不具备"物理暂停"一个正在运行的 JavaScript 定时器的能力。它只是提供状态接口。背压的成功取决于生产者是否"自律"地去观察
desiredSize。
4. 总结
在定时器频率极高的情况下:
- 流不会主动杀掉你的定时器。
- 你必须利用
pull钩子 (由引擎根据背压自动调度)或者在start的逻辑中轮询controller.desiredSize。 - 如果你的代码无视
desiredSize强制enqueue,背压机制就会失效,程序会退化为普通的内存堆积模式。
这正是 Web Streams 设计的精妙之处:它将"何时生产数据"的决定权,通过 desiredSize 信号交还给了生产者。
7. 实现一个迷你版 Web Streams(ReadableStream)
下面我们从 0 到 1 手写一个"迷你版 Web Streams(ReadableStream)" ,专注把 pull 模型 + 背压吃透。代码会刻意简化,但关键机制都会保留。
🧠 目标(我们要实现什么)
我们要得到这样一套 API:
ini
const stream = new MiniReadableStream({
pull(controller) {
controller.enqueue(Math.random());
}
});
const reader = stream.getReader();
(async () => {
const { value, done } = await reader.read();
})();
并满足:
- ✅ 消费者驱动 (只有
read()才触发pull) - ✅ 队列 + 高水位(highWaterMark)
- ✅ 背压(不需要就不生产)
- ✅ Promise 风格(而不是事件)
⚙️ 一、核心结构设计
我们拆成 3 个角色:
| 角色 | 职责 |
|---|---|
MiniReadableStream |
流本体 |
Reader |
消费数据 |
Controller |
生产数据 |
🧱 二、第一版:最小可用实现(无背压)
js
class MiniReadableStream {
constructor(underlyingSource) {
this.queue = [];
this.closed = false;
this.controller = new Controller(this);
this.underlyingSource = underlyingSource;
}
getReader() {
return new Reader(this);
}
}
class Reader {
constructor(stream) {
this.stream = stream;
}
async read() {
const stream = this.stream;
// 有数据直接返回
if (stream.queue.length > 0) {
return { value: stream.queue.shift(), done: false };
}
// 已结束
if (stream.closed) {
return { value: undefined, done: true };
}
// 👉 关键:触发 pull
await stream.underlyingSource.pull(stream.controller);
// 再试一次
if (stream.queue.length > 0) {
return { value: stream.queue.shift(), done: false };
}
return { value: undefined, done: true };
}
}
class Controller {
constructor(stream) {
this.stream = stream;
}
enqueue(chunk) {
this.stream.queue.push(chunk);
}
close() {
this.stream.closed = true;
}
}
🧠 这一版已经体现了核心思想
scss
read() → 触发 pull() → enqueue() → 返回数据
👉 消费者驱动生产(pull)
🚰 三、加入背压(highWaterMark)
现在加入真正关键的部分:
🧩 设计目标
arduino
queue.length >= highWaterMark
→ 不再调用 pull
✨ 改进版实现
js
class MiniReadableStream {
constructor(underlyingSource, strategy = {}) {
this.queue = [];
this.closed = false;
this.highWaterMark = strategy.highWaterMark ?? 1;
this.controller = new Controller(this);
this.underlyingSource = underlyingSource;
}
getReader() {
return new Reader(this);
}
}
class Reader {
constructor(stream) {
this.stream = stream;
}
async read() {
const stream = this.stream;
// 有数据直接返回
if (stream.queue.length > 0) {
return { value: stream.queue.shift(), done: false };
}
if (stream.closed) {
return { value: undefined, done: true };
}
// 👉 背压核心:只有在"需要数据"时才 pull
if (stream.queue.length < stream.highWaterMark) {
await stream.underlyingSource.pull(stream.controller);
}
if (stream.queue.length > 0) {
return { value: stream.queue.shift(), done: false };
}
return { value: undefined, done: true };
}
}
class Controller {
constructor(stream) {
this.stream = stream;
}
enqueue(chunk) {
if (this.stream.queue.length >= this.stream.highWaterMark) {
return; // 🚫 背压:拒绝过量生产
}
this.stream.queue.push(chunk);
}
close() {
this.stream.closed = true;
}
}
🔥 四、验证:真正的 pull 行为
js
const stream = new MiniReadableStream({
async pull(controller) {
console.log('👉 被调用(生产数据)');
controller.enqueue(Math.random());
}
}, { highWaterMark: 1 });
const reader = stream.getReader();
(async () => {
console.log(await reader.read());
console.log(await reader.read());
})();
输出特点
👉 被调用
👉 被调用
👉 每次 read() 才触发一次生产
⚖️ 五、对比 Node Stream(关键顿悟)
❌ Node(本质 push)
arduino
数据先产生 → 放 buffer → 你再读
✅ 我们的 mini Web Stream
你要数据 → 才触发生产
👉 这就是 pull 模型的本质
🧠 六、再进化:支持"等待数据"(Promise 队列)
当前实现有个问题:
👉 如果 pull 是异步慢操作,会丢节奏
✨ 加入 pending read
js
class MiniReadableStream {
constructor(source, strategy = {}) {
this.queue = [];
this.pendingReads = [];
this.closed = false;
this.highWaterMark = strategy.highWaterMark ?? 1;
this.controller = new Controller(this);
this.source = source;
}
getReader() {
return new Reader(this);
}
}
class Reader {
constructor(stream) {
this.stream = stream;
}
read() {
const stream = this.stream;
if (stream.queue.length > 0) {
return Promise.resolve({
value: stream.queue.shift(),
done: false
});
}
if (stream.closed) {
return Promise.resolve({ value: undefined, done: true });
}
return new Promise((resolve) => {
stream.pendingReads.push(resolve);
// 👉 触发 pull
if (stream.queue.length < stream.highWaterMark) {
stream.source.pull(stream.controller);
}
});
}
}
class Controller {
constructor(stream) {
this.stream = stream;
}
enqueue(chunk) {
const stream = this.stream;
if (stream.pendingReads.length > 0) {
const resolve = stream.pendingReads.shift();
resolve({ value: chunk, done: false });
} else {
if (stream.queue.length < stream.highWaterMark) {
stream.queue.push(chunk);
}
}
}
close() {
const stream = this.stream;
stream.closed = true;
while (stream.pendingReads.length) {
stream.pendingReads.shift()({ value: undefined, done: true });
}
}
}
🧩 七、这个版本已经非常接近 Web Streams
你已经实现了:
- ✅ pull 驱动
- ✅ 背压(highWaterMark)
- ✅ Promise 队列
- ✅ reader / controller 分离
- ✅ "要数据才生产"
🎯 八、终极理解(最重要)
🔥 Web Streams 核心不是 API
而是这个模型:
scss
consumer → read() → pull() → enqueue()
🔥 和 Node 最大差异
perl
Node:生产者决定节奏(push)
Web:消费者决定节奏(pull)
🧠 九、一句话总结
Web Streams 的本质是通过
read()驱动pull(),结合受控队列和 highWaterMark,实现由消费者主导的数据生产模型,从根本上解决背压问题。