Web Streams 简介

在现代 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?

  1. 性能:数据不需要一次性全部加载到内存。处理 1GB 的文件和处理 1KB 的文件,内存占用可以保持一致。
  2. 流式 Fetch:你可以实现在图片还没下载完时就开始处理像素,或者在大型 JSON 下载时就开始解析。
  3. 标准化:编写的代码可以同时在 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 发生时

  1. 当你调用 controller.enqueue() 时,数据进入流的内部队列
  2. 如果消费者(Reader)调用了 read(),数据会立即被派发给消费者,队列清空。
  3. 如果消费者处理极慢,数据会堆积在队列里,直到达到 HighWaterMark
  4. 此时 desiredSize 变成 0 或负数。
  5. 核心点 :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,实现由消费者主导的数据生产模型,从根本上解决背压问题。

相关推荐
悟空瞎说2 小时前
Flutter热更新 Shorebird CodePush 原理、实现细节及费用说明
前端·flutter
didadida2622 小时前
从“不存在”的重复请求,聊到 Web 存储的深坑
前端
xiaominlaopodaren2 小时前
Three.js 渲染原理-透明渲染为什么这么难
前端
米丘2 小时前
vue3.x 内置指令有哪些?
前端·vue.js
米丘2 小时前
Vue 3.x 模板编译优化:静态提升、预字符串化与 Block Tree
前端·vue.js·编译原理
一川_2 小时前
前端驱动工业报警:基于 WebSocket 与网关的三色蜂鸣灯实时报警系统实战
javascript·websocket
狗都不学爬虫_2 小时前
小程序逆向 - Hai尔(AliV3拖动物品)
javascript·爬虫·python·网络爬虫
We་ct2 小时前
HTML5 原生拖拽 API 基础原理与核心机制
前端·javascript·html·api·html5·浏览器·拖拽
是上好佳佳佳呀2 小时前
【前端(八)】CSS3 属性值笔记:渐变、自定义字体与字体图标
前端·笔记·css3