从AI聊天流式输出出发,深度剖析Web开发中的“流”以及如何实现流式响应?

52 阅读50分钟

理解 Web 开发中的“流”:从起源到现代应用

在现代 Web 开发中,“流”(Stream)是一个无处不在但又令许多小伙伴困惑的概念。无论是在处理用户上传的大文件、观看在线视频,还是与 AI 模型实时交互,流都在幕后默默工作。接下来我们来深入探索前后端开发中与流相关的各种概念,从它的起源、基本原理到具体的 API 和实际应用,并通过生动的类比和示例,帮助你轻松理解并记住这些知识点。

1. 什么是流?流动的数据

核心概念:随时间流动的数据,而非一次性全部加载

在计算机科学中,流(Stream)最核心的概念是指一系列随时间推移而可用的数据元素,其总量可能是无限的 。想象一条工厂里的传送带,物品(数据)一个接一个地被送过来处理,而不是等所有物品都堆积如山再一次性处理 。这与传统的“批处理”(Batch Processing)形成鲜明对比,后者通常需要所有数据准备就绪后才能开始处理 。

流处理的是持续不断的数据流,数据被分成小块(chunks)按顺序处理。这种方式的关键优势在于,我们不必将所有数据一次性加载到内存中。

类比:河流与水桶

  • 批处理 想象用一个巨大的水桶一次性装下一整条河的水。如果河流很长(数据量很大),你需要一个天文数字般大小的水桶(内存),而且要等河水全部流完被装进大桶里才能开始使用。
  • 流处理 则更像是站在河边,用一个小水桶一桶一桶地取水。无论河流多长,你只需要一个小水桶就能持续不断地获取和使用水(数据),大大节省了资源(内存),并且可以立即开始处理第一桶水,无需等待整条河流流完。

为何使用流?

流之所以在计算机科学和 Web 开发中如此重要,主要源于其带来的显著优势:

  1. 内存效率 (Memory Efficiency) :流处理允许程序以小块、可管理的数据片段进行工作,极大地减少了内存占用。在处理大文件(如视频、日志文件)或无法预知大小的数据源(如网络请求)时非常有用。
  2. 时间效率 (Time Efficiency) :处理可以立即开始,无需等待所有数据都可用。第一个数据块到达后,就可以开始处理,减少了用户等待时间,提高了应用的响应速度,例如 AI 聊天的流式响应,你是一个 Token 一个 Token 看到的,而不是一下子将回复全部显示出来。
  3. 可组合性 (Composability) :流可以被连接(pipe)在一起。一个流的输出可以直接作为另一个流的输入,形成处理管道(pipeline)。这使得复杂的数据处理任务可以分解为一系列简单、可重用的步骤。

这些优势使得流成为处理以下场景的理想选择:

  • 文件处理:读写大文件,如日志分析、数据导入导出。
  • 网络通信:在客户端和服务器之间传输数据,如下载文件、上传数据、API 响应/API 流式响应。
  • 实时数据处理:处理来自传感器、直播比赛、金融市场等的连续数据流。
  • 数据转换:在数据流动过程中进行过滤、编码、解码、压缩、解压等操作,例如在视频直播过程中对帧进行实时编码、解码、压缩等操作。

历史一瞥:Unix 的深远影响

“流”的概念并非 Web 时代的产物,其思想根源可以追溯到 Unix 操作系统的早期发展阶段(大约在 1960 年代末至 1970 年代初)。Unix 的设计深受其“哲学”的影响,其中两条核心原则与流息息相关:

  1. 做一件事并做好 (Do One Thing and Do It Well) :鼓励创建小而专一的程序,每个程序只负责一个明确的任务。
  2. 协同工作 (Work Together) :期望每个程序的输出都能成为另一个(可能是未知的)程序的输入。

为了实现这种协作,Unix 引入了“管道”(pipe)的概念。管道就像一根连接线,可以将一个程序的标准输出流(stdout)直接连接到另一个程序的标准输入流(stdin)。例如,ls | grep.txt 命令中,ls 程序的输出(文件列表)通过管道流向 grep 程序,后者则过滤出包含 ".txt" 的行。

这种将简单工具通过数据流连接起来,构建复杂工作流的模式,正是现代流处理思想的直接鼻祖。它体现了模块化和可组合性的强大威力。

可以说,Unix 哲学中对简洁、模块化和工具间协作的强调,天然地孕育了流式处理的思想。当今 Web 开发中的流 API,尤其是将处理步骤链接起来的能力(如 Web Streams API 的 pipeThrough),正是这种早期 Unix 设计理念的传承和发展。

2. 流的构建基石:在 JavaScript 和 Node.js 中处理二进制数据

虽然我们经常处理文本,但计算机世界的基础是二进制——由 0 和 1 组成的数据。图像、音频、视频、网络数据包以及许多底层数据结构,都需要以原始的二进制形式来表示和操作。流通常在字节(byte)这个层面上工作,因此理解如何在 JavaScript 和 Node.js 中处理二进制数据是理解流的关键前提。我们必须先了解以下概念。

ArrayBuffer:原始内存块 (类比:空盒子)

ArrayBuffer 是 JavaScript 中用于表示通用、固定长度的原始二进制数据缓冲区的对象。它代表了内存中的一块区域,可以看作是一个字节数组。

关键点在于,你不能直接读写 ArrayBuffer 的内容。它仅仅是数据的容器,一块分配好的内存。

类比:固定大小的空盒子

想象一个固定大小的纸箱 (ArrayBuffer)。你知道这个箱子有多大(它的 byteLength 属性),但你不能直接伸手进去随意摆弄里面的空间。你需要特定的工具(TypedArrayDataView)来按照特定的规则(数据类型)放入或取出物品(字节)。

ArrayBuffer 的特点:

  1. 固定长度:一旦创建,其大小(byteLength)通常是固定的,除非使用了较新的可调整大小(resizable)特性,一般不使用。

  2. 通用性:它不指定内部数据的类型,只是原始字节,不关心存储的具体是啥,只知道自己存储的是 010101,类似虽然这个纸箱是用来包装电饭锅的,但是你也可以用它来装别的东西。

  3. 不可直接操作:需要通过“视图”(View)对象来访问和修改其内容。

  4. 可转移性 (Transferable) :可以通过 postMessage 在主线程和 Web Workers 之间转移所有权,实现高效的数据传递(转移后原ArrayBuffer会分离,不可再用)。

    如何理解可转移性

    通常,如果要在主线程和 Web Worker 之间传递数据,涉及到 拷贝(复制数据并发送到另一个线程)。但对于大块数据来说,复制开销很大,影响性能。

    ArrayBuffer 通过 所有权转移(类似 Rust 的 move 即所有权转移,但 Rust 是编译时执行,ArrayBuffer 的可转移性是运行时机制) 来避免复制:

    I. 转移所有权:主线程调用 postMessage(arrayBuffer, [arrayBuffer]) 发送数据给 Worker,数据本身不会被复制,而是直接“交出所有权”。

    II. 原始 ArrayBuffer 变为空(变成“分离的状态”):主线程无法再访问该 ArrayBuffer,因为它的所有权已被转移。

    III. Worker 端接管数据:Worker 端接收到 ArrayBuffer 后,可以正常读取和处理数据。

    这种机制就像“交接物品”:你把一个箱子交给别人,自己就没了,不能再用它,而不是再造一个新的箱子。

    可转移性的用途

    • 提升性能:避免大数据的复制开销,提高数据传输的效率,尤其适用于 图片处理、视频编码、大量数据计算 等需要和 Web Worker 交互的场景。

    • 减少内存占用:数据不被复制,而是转移所有权,降低浏览器内存的负担。

    • 优化并行计算:让主线程和 Worker 能够高效协作,避免阻塞 UI 线程,提升用户体验。

      比如,当处理一个 大型图片 时,你可以把它放入 ArrayBuffer,然后转移给 Worker 进行处理,而主线程不会因数据复制而卡顿。让你的应用可以流畅地处理大量数据,而不会影响用户交互。

许多 Web API 会返回或接受 ArrayBuffer,例如:

  • fetch 响应:response.arrayBuffer() 将响应体读取为 ArrayBuffer
  • Blob 对象:blob.arrayBuffer() 将 Blob 内容读取为 ArrayBuffer
  • FileReader API:reader.readAsArrayBuffer(blob)

TypedArray (尤其是 Uint8Array):解读缓冲区的视图 (类比:量杯)

TypedArray 对象描述了一个底层的 ArrayBuffer类数组视图。它提供了一种机制,让我们能够将 ArrayBuffer 中的原始字节解释为特定的数值类型数组,并进行读写操作。

有多种类型的 TypedArray,对应不同的数据类型,例如:

  • Int8Array: 8 位有符号整数
  • Uint8Array: 8 位无符号整数 (最常用,代表原始字节)
  • Uint8ClampedArray: 8 位无符号整数,值被限制在 0-255 之间
  • Int16Array, Uint16Array: 16 位有/无符号整数
  • Int32Array, Uint32Array: 32 位有/无符号整数
  • Float32Array, Float64Array: 32/64 位浮点数

其中,Uint8Array 是处理流数据时最常用的,因为它直接将 ArrayBuffer 视为一个字节序列,数组中的每个元素对应一个字节。

类比:为“空盒子”(ArrayBuffer)设计的量杯

TypedArray 就像一套专门为那个“空盒子”设计的量杯。

  • Uint8Array 相当于有很多个容量为 1 字节的小量杯,可以以字节为单位精确地取用或填充盒子的内容。
  • Int32Array 则是容量为 4 字节的大量杯。
  • Float32Array 也是 4 字节的量杯,但它按照浮点数的规则来解读内容。

重要特性:

  1. 视图TypedArray 本身不存储数据,它只是提供了一个访问 ArrayBuffer 的接口。

    为什么说TypedArray 是视图?

    ArrayBuffer 只是原始的二进制数据

    ArrayBuffer 本质上是 一块连续的内存,没有任何具体的数据类型。 如果你直接访问 ArrayBuffer,你会看到一堆 裸字节,无法直接理解其中的数据结构。

    TypedArray 让我们可以“解读”这些数据

    Uint8ArrayInt16ArrayTypedArray 不会创建新的数据,而是基于 ArrayBuffer 建立一个 视图。或者说是窗口,可以理解为通过这个窗口去看或者拿黑箱子里面的东西。

    这个视图决定了如何解释这些数据,例如:

    • Uint8Array 认为每个字节是 无符号 8 位整数 (0-255)。
    • Int16Array 认为每两个字节组成一个 带符号 16 位整数 (-32,768 到 32,767)。
  2. 共享内存:多个 TypedArray 实例可以引用同一个 ArrayBuffer 的不同部分或相同部分,修改一个视图会影响到底层 ArrayBuffer 以及引用同一区域的其他视图。除非使用 slice() 等方法创建副本。

  3. 平台字节序 (Endianness) :对于多字节类型(如 Int16Array, Int32Array),TypedArray 使用平台的原生字节序。如果需要控制字节序(大端/小端),应使用 DataView

    平台字节序(Endianness)指的是计算机在存储多字节数据(如 Int16, Int32, Float64)时,如何排列这些字节的顺序,如小端字节序(Little-Endian)或大端字节序(Big-Endian)。不同处理器架构可能使用不同的字节顺序,因此 字节序依赖于当前运行的系统(平台)

    一般来说:

    TypedArray 更常用,它提供了 简单直接 的方式来处理二进制数据,适用于大多数高效计算、图像处理、WebAssembly 交互等场景。

    DataView 更灵活,适用于需要 精确控制字节序 或解析 复杂的二进制文件格式(如音视频文件、网络数据包)。

    何时使用 TypedArray

    ✅ 如果你的数据格式是 固定的(如 8 位、16 位、32 位整数或浮点数),用 TypedArray 更高效:

      const buffer = new ArrayBuffer(16);
      const uint8View = new Uint8Array(buffer);
      const floatView = new Float32Array(buffer);
    
      // 直接操作数据
      uint8View[0] = 255;
      floatView[1] = 3.14;
    
      console.log(uint8View); // 高效的字节数组
      console.log(floatView); // 以浮点视角访问数据
    

    何时使用 DataView

    ✅ 如果你需要 跨平台传输数据解析复杂格式(如文件头、网络协议)DataView 更适合:

      const buffer = new ArrayBuffer(8);
      const view = new DataView(buffer);
    
      // 存储数据
      view.setUint32(0, 1024, false); // 以大端字节序存储
      view.setUint32(4, 2048, true);  // 以小端字节序存储
    
      // 读取数据
      console.log(view.getUint32(0, false)); // 大端解释
      console.log(view.getUint32(4, true));  // 小端解释
    

    TypedArray 更常用,适合 高效计算、简单二进制数据操作

    DataView 更灵活,适合 需要控制字节序或解析复杂格式,跨平台兼容性更好

Node.js Buffer:服务器端的二进制利器 (类比:工业大桶)

在 Node.js 环境中,处理二进制数据的主要工具是 Buffer 类。它的出现早于 ArrayBufferTypedArray 在 JavaScript 中被标准化。

Node.js Buffer 是用于处理原始二进制数据(字节)的、固定大小的内存块,是一个二进制缓冲区。

为什么叫“缓冲区”?

因为它本质上就是一个临时存储区域,用来在数据生产者(比如读取文件、接收网络数据)和数据消费者(比如你的 JavaScript 代码处理数据、写入文件、发送网络数据)之间存放数据,以协调它们的速度和处理方式差异。

想象你在一个快递分拣中心工作:

  • 数据生产者: 从远处不断运来的包裹(数据)。它们可能一批一批地来,速度时快时慢。
  • 数据消费者: 你和你的同事们,负责拆开包裹、检查内容、重新打包发往下一个地方(处理数据)。你们的处理速度是有限的。
  • 缓冲区 (Buffer): 就像分拣中心里的一个临时的、固定的“待处理区域”或“暂存区”

包裹运到后,不会直接送到你手上,而是先被放到这个“暂存区”。你不是一个一个包裹去马路上拦车拿,而是到暂存区去拿。处理完的包裹也不是马上扔到马路上,而是先放到暂存区,等运货车来了再批量运走。

这个“暂存区”的作用就是:

  • 吸收速度差异: 如果包裹来得快,可以在这里堆起来,你不用手忙脚乱。如果你处理得快,可以不停地从这里拿,不用等着下一辆货车来。
  • 批量处理: 你可以从暂存区拿一批包裹一起处理,或者处理完一批后一起放到暂存区,等运货车来一次拉走,这样更高效。
  • 解耦: 运货车(生产者)和处理包裹的人(消费者)可以相对独立地工作,它们都只跟暂存区打交道。

Node.js 的 Buffer 就是扮演了这样的一个“暂存区”角色,处理那些原始的“货物”,即二进制数据(字节)

关键特性:

  • Uint8Array:现代 Node.js 中的 Buffer 类是 Uint8Array 的子类。这意味着它继承了 Uint8Array 的所有方法,并且可以直接与需要 Uint8Array 的 Web API(如 TextDecoder)交互。
  • Node.js 特有方法Buffer 提供了许多额外的便利方法,特别是用于字符串和二进制数据之间的转换,支持多种编码(如 'utf8', 'base64', 'hex', 'latin1' 等)。
  • 性能优化:为 Node.js 的 I/O 密集型操作(如 fs 模块, net 模块)进行了优化。
  • 创建方式:常用 Buffer.alloc(size)(创建指定大小并填充零的 Buffer)和 Buffer.from(data, [encoding])(根据字符串、数组或其他 Buffer 创建)。

历史演进的视角:理解为什么会有 BufferArrayBuffer 这两种看似相似的东西,需要一点历史视角。Node.js 在早期需要一种处理二进制数据的方式,于是发明了 Buffer 。后来,Web 平台标准化了 ArrayBufferTypedArray 作为通用的二进制数据处理机制 。为了与 Web 标准兼容并利用 V8 引擎的优化,Node.js 将其 Buffer 实现修改为继承自 Uint8Array 。这种演进导致了当前多种处理二进制数据的 API 并存的局面,了解其背景有助于区分它们的适用场景。

关键差异总结表

为了更清晰地区分这些概念,下表总结了它们的主要特点:

特性ArrayBufferTypedArray (以 Uint8Array 为例)Node.js Buffer
核心作用原始内存容器ArrayBuffer 的类型化视图Node.js I/O 的二进制数据处理器
可变性容器本身大小固定 (默认)可修改视图指向的 ArrayBuffer 内容内容可变, 大小固定 (通常)
直接操作是 (按指定类型读写)是 (字节级访问, 编码/解码辅助)
内存位置JavaScript 堆 (V8 Heap)视图指向 ArrayBuffer (JS Heap)V8 堆之外
主要用途作为 TypedArray/DataView 的底层解读/修改 ArrayBufferNode.js 文件/网络 I/O, 二进制操作
环境浏览器 & Node.js浏览器 & Node.jsNode.js 主要
关键特性原始缓冲区数据类型视图Node.js I/O 性能, 丰富的编解码工具
与流的关系常作为流中数据块的基础表示常用于操作流中的字节块Node.js 流的核心数据类型

ArrayBuffer 是最底层的内存表示,TypedArray 是操作它的工具,而 Buffer 是 Node.js 针对其特定环境优化的二进制处理方案。理解这些基础构件是深入学习流处理的前提。

3. 解码流动:文本、字节与编码

流中的数据并非总是原始字节,有时我们需要将其理解为文本。这就引出了二进制流与文本流等的区别,以及字符编码(尤其是 UTF-8)和相关转换工具的重要性。

不同类型的流:

  1. 字节流 (二进制流):

    • 单位: 字节 (byte, 8 bits)。
    • 特点: 这是最基础的流,直接处理原始的 0 和 1 组合。它不关心字节代表的是文本字符、图片像素、还是程序的指令。Web Streams API 的 ReadableStream<Uint8Array>WritableStream<Uint8Array> 默认处理的就是字节流(Uint8Array 就是字节数组)。
    • 类比: 就像一管道的原始水泥浆。
  2. 字符流 (文本流):

    • 单位: 字符 (character)。一个字符可能由一个、两个、三个或更多字节组成,取决于字符编码(如 UTF-8, GBK 等)。
    • 特点: 字符流处理的是文本数据。它在内部处理字节到字符的编码和解码过程,对使用者隐藏了底层的字节细节。你在处理文本文件或网络传输的文本数据时,常常会用到字符流。
    • 类比: 就像一管道的已经加工好的、随时可以用于建造的砖块。
  3. 对象流 (Object Stream):

    • 单位: 编程语言中的对象。
    • 特点: 对象流处理的是一系列结构化的数据对象。流的实现会负责将对象序列化(转换成字节序列以便存储或传输)和反序列化(将字节序列还原成对象)。这比字节流和字符流的抽象层次更高。
    • 类比: 就像一管道里运送的已经组装好的家具。

    传统的流(如 ReadableStreamWritableStream)通常处理 二进制数据(如 BufferArrayBuffer)。但 对象流允许流式传输 JavaScript 对象,而无需手动序列化。

    注意,网络底层(TCP/IP 层及以下)只认识字节流,对象流是一种在应用层面上的抽象。它不是指网络底层直接传输“对象”这种神奇的单位,而是指流的数据单位是结构化的对象,并且流的实现(库或者你写的代码)负责在后台进行对象和字节序列之间的转换。简单来说:

    发送端: 当你往一个“对象可写流”里写入一个对象时,这个流的内部实现会自动将这个对象“序列化”(Serialization) ,也就是将对象转换为一串字节序列,然后将这串字节发送到底层的字节流(最终通过 TCP/IP 传输)。

    接收端: 当一个“对象可读流”从底层的字节流接收到字节序列时,它会在内部进行缓冲和处理(就像我们之前讨论的),然后自动将这串字节“反序列化”(Deserialization) ,还原成原来的对象结构,再把这个还原好的对象提供给你的代码去读取。

    💡 示例:标准字节流(与对象流对比)

      const { Readable } = require("stream");
    
      // 标准字节流(传输 Buffer)
      const byteStream = Readable.from(Buffer.from("Hello, Stream!"));
      byteStream.on("data", chunk => console.log("Buffer:", chunk)); // <Buffer ...>
    
      // 对象流(传输 JavaScript 对象)
      const objectStream = Readable.from([{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }], { objectMode: true });
      objectStream.on("data", obj => console.log("对象流:", obj));
    

    对象流可以直接处理 JSON、数据库记录,而无需转换为 Buffer!

    对象流适用于:

    · JSON 数据流处理(如 API 响应流)

    · 数据库记录流(流式查询 MongoDB、PostgreSQL)

    · 消息队列处理(Kafka、RabbitMQ)

    · 事件流系统(实时数据处理,如日志、交易数据)

    在 Node.js 中使用对象流

    在 Node.js 的 stream 模块中,可以创建 自定义对象流

      const { Readable } = require("stream");
    
      // 创建一个对象流(objectMode: true)
      const stream = new Readable({
          objectMode: true,
          read() {
              this.push({ user: "Alice", age: 25 });
              this.push({ user: "Bob", age: 30 });
              this.push(null); // 结束流
          }
      });
    
      stream.on("data", obj => console.log("收到对象:", obj));
    

    对象流让数据可以直接流式处理,而不需要手动序列化/解析!

    Web Streams API 中的对象流

    在 Web Streams API(浏览器端),我们也可以创建对象流:

      const objectStream = new ReadableStream({
          start(controller) {
              controller.enqueue({ message: "Hello" });
              controller.enqueue({ message: "World" });
              controller.close();
          }
      });
    
      const reader = objectStream.getReader();
    
      reader.read().then(({ value }) => console.log("对象流数据:", value));
    

    适用于浏览器处理 JSON 数据流,如 fetch 解析 JSON 响应!

  4. 其他特定应用逻辑流 (Application-Specific Logical Stream):

    • 单位: 由应用协议定义的数据单元。

    • 特点: 比如我们讨论的 SSE 流,虽然底层是字节流,但在应用层面,咱们还是把它看作是一个由 \n\n 分隔的消息流。每个消息又包含由 \n 分隔的字段流(如 data:, event:)。你的代码就是在这个逻辑流的层次上进行处理。其他例子包括音频流(由音频采样或帧组成)、视频流(由视频帧组成)等。

    • 类比: 就像一管道里运送的完整的邮件包裹,每个包裹里又包含信件、账单等不同类型的纸张。

    SSE 与通用意义上的“对象流”的区别在于,SSE 的流格式是固定为它自己的文本协议,已经定好了必须按照这个格式来才行,而通用对象流(比如 Node.js 的 objectMode 或自定义的 TransformStream)可以处理任何可以序列化/反序列化的对象,其底层的序列化格式(JSON, Protobuf, 二进制格式等)是可变的或由实现者决定的。

Web 的通用语:UTF-8 的重要性

当我们在文本流中处理字符时,必须知道这些字符是如何用字节表示的,因为计算机不认识字符,只认识 01010101 组成的字节序列。这就是字符编码的作用。

从字符到数字(Code Point)

首先,需要一个标准来为世界上几乎所有的字符(包括不同语言的文字、符号、表情符号等)分配一个唯一的数字标识符。这就是 Unicode(Universal Character Encoding(通用字符编码) 标准所做的。每个字符对应一个 Unicode码点(Code Point),通常表示为 U+ 后面跟着十六进制数字(例如,'A' 是 U+0041,欧元符号 '€' 是 U+20AC,笑脸表情 '😁' 是 U+1F601)。Unicode 本身是一个庞大的字符集,它定义了字符与码点之间的映射关系。

从数字到字节(Encoding)

然后,需要一种方法将这些 Unicode 码点转换成计算机可以存储和传输的字节序列。这就是字符编码(Character Encoding)的任务。有多种编码方案可以将 Unicode 码点转换为字节,例如 UTF-8、UTF-16、GBK 等。

在 Web 世界中,UTF-8 是绝对的主导编码方式 。几乎所有的网页、API(如 JSON、XML)都默认使用或推荐使用 UTF-8。其成功主要归功于以下几个关键设计特点:

1. 可变宽度编码 (Variable-Width Encoding)

UTF-8 使用 1 到 4 个字节来表示一个 Unicode 字符。常用的字符(如英文字母、数字)通常只需要 1 个字节,而较不常用的字符(如中文、日文、韩文、表情符号)则使用 2、3 或 4 个字节。这使得它在处理以 ASCII 字符为主的内容(如 HTML 代码、JavaScript 代码)时非常节省空间。

2. ASCII 兼容性 (Backward Compatibility with ASCII)

UTF-8 最重要的特性之一是,前 128 个 Unicode 码点(与 ASCII 字符完全对应)使用单个字节编码,并且其字节值与 ASCII 编码完全相同。这意味着:

  • 只包含 ASCII 字符的 UTF-8 文件与纯 ASCII 文件在字节层面上是完全一样的。
  • 许多原本为处理 ASCII 设计的系统和工具可以无需修改或只需少量修改就能处理 UTF-8 编码的文本,这极大地促进了 UTF-8 的普及。

ASCII(American Standard Code for Information Interchange,美式信息交换标准代码)是一种早期的 字符编码标准,用于在计算机和通信设备之间表示文本数据。它为 128 个字符(包括字母、数字、符号和控制字符)分配了唯一的 7 位二进制编码

ASCII 字符的分类

  1. 控制字符(0-31) :如 \n(换行),\t(制表符)等,主要用于设备控制。

  2. 可打印字符(32-126)

    • 数字0-9
    • 英文字母A-Za-z
    • 符号:如 +-@#

ASCII 示例

字符ASCII 码(十进制)二进制
A6501000001
a9701100001
04800110000
#3500100011

尽管 ASCII 仍然用于许多系统,但Unicode(例如 UTF-8)如今更为广泛,因其支持全球所有语言。而 ASCII 码是 Unicode 的子集,所有 ASCII 码点在 Unicode 中依然保持原始定义。

3. 无字节顺序标记问题 (No BOM Issues Generally)

之前提到过不同平台使用大小端的字节序会影响兼容性的问题,与 UTF-16 等编码不同,UTF-8 没有字节序的问题,简化了跨平台数据交换,具备更好的兼容性。

4. 自同步 (Self-Synchronizing)

UTF-8 的编码结构使得即使从数据流的中间开始读取,也相对容易确定字符的边界,有助于错误恢复和数据处理,你可以理解为 UTF-8 有清晰的字符边界。

正是这些技术优势——尤其是对 ASCII 的无缝兼容——使得 UTF-8 战胜了其他编码方案,成为互联网上处理文本的事实标准和推荐标准。

TextEncoder & TextDecoder:文本与字节的翻译官

要在 JavaScript 中方便地在文本字符串(Unicode)和字节序列(一般是 UTF-8 编码的 Uint8Array)之间进行转换,Web 平台提供了 Encoding API,其核心就是 TextEncoderTextDecoder

  • TextEncoder:文本 → 字节

    • TextEncoder 实例的作用是将 JavaScript 字符串(内部表示为 Unicode 码点序列)编码成 UTF-8 字节流。

    • 它只有一个主要方法 encode(string),接收一个字符串作为输入,返回一个包含对应 UTF-8 字节的 Uint8Array

    • 还有一个 encodeInto(string, uint8Array) 方法,可以将编码结果直接写入一个已存在的 Uint8Array 中,使用不多。

    • 重要TextEncoder 始终使用 UTF-8 编码,其 encoding 属性永远返回 "utf-8" 。它不支持其他编码。

    • 类比:一个只会将你的口语(JS 字符串)翻译成标准摩尔斯电码(UTF-8 Uint8Array)的翻译员。

        const encoder = new TextEncoder();
        const greeting = "你好, world! €";
        const utf8Bytes = encoder.encode(greeting); // 得到一个 Uint8Array
        console.log(utf8Bytes); // 输出: Uint8Array(18) [ 228, 189, 160, 229, 165, 189, 44, 32, 119, 111, 114, 108, 100, 33, 32, 226, 130, 172 ]
      
  • TextDecoder:字节 → 文本

    • TextDecoder 实例的作用是将包含特定编码(默认为 UTF-8)字节的缓冲区(如 ArrayBufferTypedArray解码成 JavaScript 字符串。
    • 创建 TextDecoder 时可以指定要使用的编码(通过一个标签字符串,如 "utf-8", "gbk", "windows-1251" 等),如果不指定,默认为 "utf-8"
    • 主要方法是 decode(buffer, options),接收一个包含字节数据的缓冲区(如 Uint8Array)作为输入,返回解码后的字符串 。
    • decode 方法有一个重要的 options 参数,其中 stream: true 选项用于处理分块数据。当设置为 true 时,TextDecoder 会记住上一个块末尾可能不完整的字符字节序列,并在处理下一个块时将其拼接起来,确保多字节字符不会在块边界被错误地分割。这对于处理流数据至关重要,AI 聊天的流式输出就要用到这个选项。
    • 类比:一个可以解读多种密码本(不同编码)的解码员,将摩尔斯电码(字节 Uint8Array)翻译回口语(JS 字符串)。
        // 接上例
        const decoder = new TextDecoder("utf-8"); // 或者 new TextDecoder()
        const decodedGreeting = decoder.decode(utf8Bytes);
        console.log(decodedGreeting); // 输出: "你好, world! €",
    
        // 示例:处理分块数据
        const decoderStream = new TextDecoder("utf-8");
        const chunk1 = new Uint8Array(); // "你" 的前两个字节
        const chunk2 = new Uint8Array(); // "你" 的最后一个字节 + "好"
        console.log(decoderStream.decode(chunk1, { stream: true })); // 可能输出空字符串或部分内容,取决于内部状态
        console.log(decoderStream.decode(chunk2, { stream: false })); // 输出 "你好" (stream: false 表示这是最后一块)
    

TextEncoderTextDecoder 是在 Web 流中处理文本数据的关键工具,它们是易于人类理解的字符串和流传输的底层字节之间的翻译官。

4. 现代 Web 管道:Web Streams API

随着 Web 应用对处理大数据、实时数据和网络响应的需求日益增长,JavaScript 需要一个标准化的、高效的方式来处理流式数据。传统的 XMLHttpRequest 或早期 fetch 的非流式处理方式(如 .json(), .text())往往需要将整个响应缓冲到内存中,这对于大文件或持续数据流来说效率低下且可能导致内存耗尽。Web Streams API 应运而生,为浏览器环境提供了一套统一的、强大的、用于处理流数据的接口。

这套 API 的核心是三个接口:ReadableStream(可读流)、WritableStream(可写流)和 TransformStream(转换流),共同构成了在 JavaScript 中创建、处理和消费数据流的基础。

ReadableStream: (类比:水龙头)

ReadableStream 代表一个你可以从中读取数据的来源 。数据就像水一样从这个“源头”流出。

类比:厨房的水龙头

ReadableStream 就像你厨房里的水龙头。它的设计目的是输出水(数据)。你不能往水龙头里灌水,只能打开它让水流出来。

如何获取数据?

ReadableStream 中获取数据,你需要一个“读取器”(Reader)。可以通过调用流的 getReader() 方法来获取。有两种主要的读取器:

  1. ReadableStreamDefaultReader:默认读取器,用于读取流中的通用数据块(chunks)。这些块可以是任何 JavaScript 类型,但通常是 Uint8Array
  2. ReadableStreamBYOBReader :它允许手动管理缓冲区,减少额外的内存分配,提高性能。
const reader = stream.getReader({ mode: 'byob' });
const buffer = new Uint8Array(10); // 自定义缓冲区

reader.read(buffer).then(({ value, done }) => {
   console.log(value); // Uint8Array,数据被填充到 buffer
});

获取读取器后,你可以调用其 read() 方法。这个方法返回一个 Promise,该 Promise 会解析为一个包含两个属性的对象:{ value, done }

  • value:流中的下一个数据块。如果 donetrue,则 valueundefined
  • done:一个布尔值。如果为 true,表示流已经结束,没有更多数据可读。如果为 false,表示还有更多数据。

需要在一个循环中反复调用 read(),直到 done 变为 true

常见的 ReadableStream 来源:

  • fetch API 的响应体response.body 是一个 ReadableStream,允许你流式处理网络下载的数据。
  • BlobFile 对象:可以通过 blob.stream() 方法获取一个读取其内容的 ReadableStream
  • 自定义流:可以使用 new ReadableStream() 构造函数创建自己的可读流,并提供底层源逻辑(start, pull, cancel 方法)来控制数据的生成和推送。例如,controller.enqueue(data) 用于向流中添加数据块。

WritableStream: (类比:水槽)

WritableStream 代表一个你可以向其写入数据的目的地,也称为“汇点”(Sink)。数据就像水一样流入这个“目的地”。

类比:厨房的水槽

WritableStream 就像厨房里的水槽。它的设计目的是接收水(数据)。你不能从水槽里取水,只能把水倒进去。

如何写入数据?

WritableStream 写入数据,需要一个“写入器”(Writer)。通过调用流的 getWriter() 方法获取,得到一个 WritableStreamDefaultWriter 实例。

获取写入器后,可以使用以下方法:

  • writer.write(chunk) :将一个数据块(chunk)写入流中。这个方法返回一个 Promise,表示写入操作的完成状态。
  • writer.close() :关闭流。这表示你已经完成了所有数据的写入。它也返回一个 Promise,该 Promise 在所有已排队的写入操作完成并且流成功关闭后解析。
  • writer.abort(reason) :异常中止流。这表示发生了错误,生产者无法继续写入。任何已排队但未完成的写入都会被丢弃,流会进入错误状态。

底层汇点 (Underlying Sink)

创建 WritableStream 时,需要提供一个对象来定义其底层“汇点”即水槽的行为。这个对象可以包含以下方法:

  • start(controller):在流创建后立即调用一次,用于初始化设置。
  • write(chunk, controller):当有数据块通过 writer.write() 写入时调用,负责处理这个数据块(例如,将其发送到网络、写入文件等)。
  • close(controller):当调用 writer.close() 并且所有排队的写入都完成后调用,用于执行清理工作。
  • abort(reason):当调用 writer.abort() 时调用,用于处理异常终止。

controller 参数(一个 WritableStreamDefaultController 实例)可以用来向流发出错误信号 (controller.error())。

TransformStream:数据处理器 (类比:滤水器)

TransformStream 是连接可读流和可写流的桥梁,它代表了一个转换过程。它接收输入数据,对其进行处理,然后产生输出数据。

类比:滤水器

TransformStream 就像一个安装在水龙头和水槽之间的滤水器。

  • 它有一个入口(可写端,writable 属性),脏水(原始数据)从这里进入。
  • 它有一个出口(可读端,readable 属性),干净的水(处理后的数据)从这里流出。

工作原理

当你向 TransformStreamwritable 端写入数据块时,这些数据块会被传递给内部的 transform 函数进行处理。处理后的结果可以通过 controller.enqueue() 方法推送到 readable 端,供下游消费者读取。

创建 TransformStream 时,可以提供一个 transformer 对象来定义转换逻辑:

  • start(controller):构造时调用,用于初始化。
  • transform(chunk, controller):当数据块写入 writable 端时调用。chunk 是输入的数据块,你需要在这里编写处理逻辑,并使用 controller.enqueue(outputChunk) 将处理结果放入 readable 端。这个方法可以返回一个 Promise 来处理异步转换。
  • flush(controller):当 writable 端被关闭(writer.close())并且所有输入块都已成功转换后调用。这通常用于处理任何剩余的内部状态或发送最后的输出块。

controller 参数是一个 TransformStreamDefaultController 实例,除了 enqueue(),它还有 error()(使流出错)和 terminate()(关闭可读端并使可写端出错)等方法。

常见的 TransformStream 应用包括:

  • 编码/解码:如 TextEncoderStream(将字符串流转为 UTF-8 字节流)和 TextDecoderStream(将字节流解码为字符串流)。
  • 压缩/解压:如使用 CompressionStreamDecompressionStream
  • 数据格式转换:如将 JSON 流转换为 CSV 流。
  • 过滤/映射:只让满足特定条件的数据块通过,或修改每个数据块的内容。

大家理解作用就好,具体应用在需要时再去实践。

连接流:pipeTo()pipeThrough()

Web Streams API 提供了两个核心方法来方便地连接这些流,形成处理管道:

  1. readableStream.pipeTo(writableStream)

    • 作用:将一个 ReadableStream 的所有数据直接“管道输送”到一个 WritableStream
    • 过程:它会自动从 readableStream 读取数据块,并将它们写入 writableStream,直到 readableStream 结束。
    • 优点:自动处理读取、写入以及两者之间的背压(backpressure,流量控制)。
    • 类比:用一根软管直接将水龙头连接到水槽 。
    // 假设 readableSource 是一个 ReadableStream
    // 假设 writableDest 是一个 WritableStream
    await readableSource.pipeTo(writableDest);
    // 数据自动从 source 流向 dest
    
  2. readableStream.pipeThrough(transformStream)

    • 作用:将一个 ReadableStream 的数据“管道输送”通过一个 TransformStream(或任何具有 {writable, readable} 属性的对象)。
    • 过程:它将 readableStream 连接到 transformStreamwritable 端,并返回 transformStreamreadable 端 。
    • 优点:允许你在数据流中插入处理步骤,并且可以链式调用多个 pipeThrough 来构建复杂的处理管道。同样自动处理背压。
    • 类比:将水龙头用软管连接到滤水器的入口,然后滤水器的出口就是新的水源,你可以再将其连接到水槽(使用 pipeTo)或其他滤水器(再次使用 pipeThrough)。
    // 假设 readableSource 是一个 ReadableStream
    // 假设 textDecoderTransform 是一个 TextDecoderStream (一种 TransformStream)
    // 假设 writableDest 是一个 WritableStream
    
    await readableSource
     .pipeThrough(textDecoderTransform) // 数据先通过解码器转换
     .pipeTo(writableDest);            // 转换后的数据流向目的地
    

管理流量:背压 (Backpressure) 的解释

在流的管道中,数据源(ReadableStream)产生数据的速度可能比数据目的地(WritableStream)消耗数据的速度快。如果没有流量控制机制,快速的源可能会产生大量数据,迅速填满中间环节(如 TransformStream 或 WritableStream)的内部队列(缓冲区),最终可能耗尽内存。

背压 (Backpressure) 就是解决这个问题的机制。它是一种从下游(消费者)向上游(生产者)传递信号的方式,告知上游“慢一点”或“暂停”。

工作原理

  1. 每个流(特别是可写流和转换流的可写端)都有一个内部队列和高水位线 (High Water Mark) 。高水位线定义了队列中可以容纳的数据量的阈值(可以基于块的数量或字节大小,由排队策略 (Queuing Strategy) 决定)。
  2. 当写入器向流中写入数据块时,如果内部队列的大小达到了高水位线,流就会发出信号,表示它暂时不希望接收更多数据。
  3. 在使用 pipeTopipeThrough 连接的管道中,这个“暂停”信号会沿着管道反向传播。下游的流告诉直接连接它的上游流暂停,上游流再告诉它的上游,最终信号传到最初的 ReadableStream
  4. ReadableStream 接收到信号后,会暂时停止从其底层源拉取或生成数据。
  5. 当消费者处理了足够的数据,使得下游流的队列大小低于高水位线时,它会再次发出信号,表示可以继续接收数据。这个信号同样反向传播到源头,ReadableStream 于是恢复数据的产生。

关键优势pipeTopipeThrough 自动处理背压 。开发者在使用这些方法连接流时,不需要手动管理流量控制,API 会在后台协调生产者和消费者的速度,确保数据流畅、高效且不会导致内存溢出。

类比回顾:就像往水槽里倒水。如果水槽的排水速度(消费速度)跟不上你倒水的速度(生产速度),水槽就会开始蓄水(队列增长)。当水位快要溢出时(达到高水位线),你就得自觉地放慢倒水速度或者停下来(背压信号),等水位下降后再继续倒水。

Web Streams API 通过提供标准化的接口、自动流量控制(背压)和方便的管道连接方法,极大地简化了在浏览器中处理异步数据流的复杂性。

5. 跨越网络:HTTP 中的流

流的概念不仅限于浏览器内部或服务器内部的数据处理,它在网络通信,特别是 HTTP 协议中扮演着至关重要的角色,使得数据能够在客户端和服务器之间高效传输。

获取数据:fetch API 的 ReadableStream 响应体

现代 Web 开发中,fetch API 是进行网络请求的标准方式。fetch 的一个强大之处在于,它返回的 Response 对象包含一个 body 属性,这个 body 正是一个 ReadableStream

当服务器开始发送响应数据时,咱不需要等待整个响应下载完成,就可以通过 response.body 这个 ReadableStream 立即开始处理到达的数据块。这对于下载大文件、接收实时数据或任何需要尽快处理响应的场景都非常有用。

如何使用 response.body

可以像处理其他 ReadableStream 一样处理 response.body,因为它就是个ReadableStream

  1. 使用读取器 (Reader) :通过 response.body.getReader() 获取一个 ReadableStreamDefaultReader,然后在一个循环中使用 await reader.read() 来逐块读取原始字节数据( Uint8Array)。

    async function processFetchStream(url) {
      const response = await fetch(url);
      if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
    
      const reader = response.body.getReader();
      const decoder = new TextDecoder(); // 假设是文本数据
    
      while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        // 'value' 是一个 Uint8Array
        console.log("Received chunk:", decoder.decode(value, { stream: true }));
      }
      console.log("Stream finished.");
    }
    
  2. 使用管道 (Piping) :将 response.body 通过管道连接到其他流。

    • 连接到 WritableStream:如果你想将下载的数据直接写入某个地方(例如,写入 IndexedDB 或通过 FileSystemWritableFileStream 写入本地文件),可以使用 response.body.pipeTo(yourWritableStream)

    • 连接到 TransformStream:如果你想在处理前对数据进行转换(例如,解码文本、解压数据),可以使用 response.body.pipeThrough(yourTransformStream)

      // 示例:将响应流解码为文本并打印到控制台
      async function logFetchStreamAsText(url) {
        const response = await fetch(url);
        if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
      
        await response.body
         .pipeThrough(new TextDecoderStream()) // 解码为文本流
         .pipeTo(new WritableStream({ // 写入到自定义的“控制台打印”汇点
            write(chunk) {
              console.log(chunk);
            }
          }));
        console.log("Stream finished and logged.");
      }
      

注意:像 response.json()response.text()response.arrayBuffer() 这些便捷方法,虽然易于使用,但它们内部会读取整个 response.body 流并将其缓冲到内存中,然后才解析或返回结果 。对于非常大的响应,这可能会导致性能问题或内存不足。直接操作 response.body 流则避免了这个问题。

发送大小未知的数据:Transfer-Encoding: chunked

在 HTTP/1.1 协议中,服务器通常需要通过 Content-Length 头部告知客户端响应体的确切大小。但有时,服务器在开始发送响应时并不知道最终内容的总长度,例如动态生成的内容(数据库查询结果、压缩文件流等)。

为了解决这个问题,HTTP/1.1 引入了 Transfer-Encoding: chunked(分块传输编码)。

工作机制

  1. 服务器在响应头中包含 Transfer-Encoding: chunked,并且省略 Content-Length 头部。

  2. 响应体被分成若干个“块”(chunks)。

  3. 每个块包含两部分:

    • 块大小:表示该块数据部分长度的十六进制数字,后跟 \r\n
    • 块数据:实际的数据内容,长度由前面的块大小指定。
    • 块数据之后也跟一个 \r\n
  4. 传输以一个零长度的块(即,大小为 0,后跟 \r\n\r\n)结束。

示例

HTTP/1.1 200 OK
Content-Type: text/plain
Transfer-Encoding: chunked

7\r\n       <-- 第1块大小 (7字节)
Mozilla\r\n  <-- 第1块数据
11\r\n      <-- 第2块大小 (17字节, 十六进制11)
Developer Network\r\n <-- 第2块数据
0\r\n       <-- 结束块 (大小为0)
\r\n        <-- 结束块后的空行

关键点

  • 流式传输:允许服务器边生成边发送数据,无需预先知道总大小,提高了首字节时间(TTFB)。
  • HTTP/1.1 特定:这是 HTTP/1.1 的机制。HTTP/2 和 HTTP/3 拥有更先进的内置流式传输机制(基于帧),不再使用(甚至禁止)Transfer-Encoding: chunked
  • 与压缩的关系:如果同时使用压缩(如 Content-Encoding: gzip)和分块传输,通常是先压缩整个内容,然后将压缩后的数据进行分块传输。

类比:想象你要邮寄一部非常长的小说手稿,但你是一边写一边寄的。你不能提前知道总页数。于是你每写完几页(一个 chunk),就在信封上写明这几页有多少字(chunk size),然后寄出去。最后,你寄一个空的信封(zero-length chunk)告诉收件人:“写完了!”

流式 API 不一定要使用 chunked

传输方式是否依赖 Transfer-Encoding: chunked
HTTP 1.1✅ 适用于 chunked 传输
HTTP/2 & HTTP/3❌ 采用流式帧,不使用 chunked
WebSockets❌ 采用独立帧,不依赖 HTTP chunked
Web Streams API❌ API 本身支持流式读取,无需 chunked

实时更新:服务器发送事件 (SSE) 与 text/event-stream

有时,我们需要服务器能够主动将更新推送给客户端,而不需要客户端反复轮询。例如,股票报价、新闻动态、通知等。服务器发送事件 (Server-Sent Events, SSE) 就是为此设计的 Web 标准。

工作机制

  1. 客户端连接:客户端(浏览器)使用 JavaScript 的 EventSource API 向服务器上的特定 URL 发起一个普通的 HTTP GET 请求。

    const eventSource = new EventSource('/api/updates');
    
  2. 服务器响应:服务器接收到请求后,必须返回一个特殊的响应:

    • HTTP 状态码为 200 OK
    • Content-Type 头部设置为 text/event-stream
    • 通常还会设置 Cache-Control: no-cacheConnection: keep-alive
    • 最关键的是,服务器不关闭这个连接,而是保持其打开状态。
  3. 服务器推送事件:当服务器有新的数据要发送给客户端时,它就沿着这个打开的连接,按照特定的事件流格式写入数据。

  4. 客户端接收:客户端的 EventSource 对象会监听这个连接。每当服务器发送一个完整的事件块时,EventSource 就会触发相应的 JavaScript 事件(默认为 message 事件,或自定义事件),开发者可以在事件处理函数中获取并处理数据。

事件流格式 (text/event-stream)

  • 纯文本格式,必须使用 UTF-8 编码。

  • 事件由一个或多个 字段名: 值 的行组成。

  • 常见的字段名包括:

    • event: 事件类型(可选)。如果省略,客户端会触发 message 事件。如果指定了名称(如 event: user_update),客户端会触发同名事件。
    • data: 事件的实际数据。可以有多行 data:,客户端会将它们的值用换行符连接起来。
    • id: 事件的唯一标识符(可选)。客户端会自动记住最后收到的事件 ID。如果连接断开,客户端重新连接时会在请求头中发送 Last-Event-ID,服务器可以据此发送错过的事件。
    • retry: 建议客户端在连接断开后等待多少毫秒再尝试重新连接(可选)。
  • 每个事件以两个连续的换行符 (\n\n) 结束。

  • 以冒号 : 开头的行是注释,会被忽略(可用于保持连接活动)。

示例服务器端 (Node)

  const express = require('express');
  const app = express();

  // 监听 SSE 端点
  app.get('/events', (req, res) => {
      res.setHeader('Content-Type', 'text/event-stream');
      res.setHeader('Cache-Control', 'no-cache');
      res.setHeader('Connection', 'keep-alive');

      // 立即发送一个默认 message 事件
      const time = new Date().toUTCString();
      res.write(`data: The server time is: ${time}\n\n`);

      // 定期发送自定义事件
      setTimeout(() => {
          res.write(`event: user_update\n`);
          res.write(`data: {"username": "Alice", "status": "online"}\n\n`);
      }, 5000);

      // 保持连接
      req.on('close', () => {
          console.log('Client disconnected');
      });
  });

  app.listen(80, () => console.log('SSE server running on port 80'));

示例服务器端 (python)

    from fastapi import FastAPI
    from fastapi.responses import StreamingResponse
    import asyncio
    import time

    app = FastAPI()

    async def event_stream():
        yield f"data: The server time is: {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n"

        await asyncio.sleep(5)
        yield f"event: user_update\n"
        yield f"data: {{\"username\": \"Alice\", \"status\": \"online\"}}\n\n"

    @app.get("/events")
    async def sse():
        return StreamingResponse(event_stream(), media_type="text/event-stream")

    if __name__ == '__main__':
        import uvicorn
        uvicorn.run(app, host="0.0.0.0", port=80)

示例客户端 (JavaScript)

const evtSource = new EventSource("/api/updates");

// 监听默认的 "message" 事件
evtSource.onmessage = function(event) {
  console.log("Message received:", event.data);
};

// 监听自定义的 "user_update" 事件
evtSource.addEventListener("user_update", function(event) {
  const userData = JSON.parse(event.data);
  console.log("User update:", userData.username, "is", userData.status);
});

// 监听错误
evtSource.onerror = function(err) {
  console.error("EventSource failed:", err);
  // EventSource 会自动尝试重连,除非服务器返回 204 或其他特定状态码
};

// 如果需要手动关闭连接
// evtSource.close();

虽然 text/event-stream 是为 EventSource 设计的格式,但前端也可以使用 fetch + ReadableStream 来访问它,甚至可以更灵活地处理内容!

fetch('/sse-endpoint')
  .then(response => {
    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let buffer = '';

    return reader.read().then(function process({ value, done }) {
      if (done) return;

      buffer += decoder.decode(value, { stream: true });

      let lines = buffer.split('\n\n');
      buffer = lines.pop(); // 剩余未完整的数据

      for (const chunk of lines) {
        const line = chunk.split('\n').find(l => l.startsWith('data: '));
        if (line) {
          const data = line.replace(/^data: /, '');
          console.log('📩 收到事件数据:', data);
        }
      }

      return reader.read().then(process);
    });
  });

两者对比:

方法是否支持 text/event-stream是否自动处理格式是否可控更强
new EventSource(url)✅ 自动解析事件、自动重连❌ 不支持自定义 header、POST 等
fetch(url) + res.body.getReader()❌ 需手动解析✅ 支持所有 HTTP 请求定制,处理更灵活

当你使用前端使用fetch + ReadableStream时,后端不用text/event-stream也是可以的,但是如果前端使用EventSource则必须使用text/event-streamtext/event-stream代表格式遵循EventSource约定。

const express = require('express');

const app = express();

app.get('/stream', (req, res) => {
    res.setHeader('Content-Type', 'text/plain');
    res.setHeader('Transfer-Encoding', 'chunked'); // 仅http1.1需显式指定

    let count = 1;
    const interval = setInterval(() => {
        if (count <= 5) {
            res.write(`第 ${count}段数据\n`);
            count++;
        } else {
            clearInterval(interval);
            res.end();
        }
    }, 1000);

    req.on('close', () => {
        clearInterval(interval);
        console.log('Client disconnected');
    });
});

app.listen(80, () => console.log('Streaming server running on port 80'));

关于 SSE vs. WebSockets

  • 方向性:SSE 是单向的(服务器 → 客户端);WebSockets 是双向的(客户端 ↔ 服务器)。
  • 协议:SSE 建立在标准 HTTP/HTTPS 之上;WebSockets 是一个独立的基于 TCP的协议(虽然握手始于 HTTP)。
  • 复杂性:SSE 通常更简单,尤其是在服务器端,因为它利用了现有的 HTTP 基础设施。WebSockets 需要专门的服务器支持。
  • 功能:SSE 内置了自动重连事件 ID机制。WebSockets 需要手动实现这些。
  • 数据类型:SSE 主要传输 UTF-8 文本;WebSockets 支持文本和二进制数据。
  • 连接限制:浏览器对每个域名的并发 SSE 连接数有限制(HTTP/1.1 下通常是 6 个,HTTP/2 下更高)。WebSockets 连接数限制通常更高。

选择:如果只需要服务器向客户端推送更新,SSE 是一个轻量级、简单且基于标准 HTTP 的好选择。如果需要双向实时通信,WebSockets 是更合适的选择。

类比:SSE 像是你订阅了一份实时更新的报纸(text/event-stream),邮递员(服务器)会不断地把最新的版面(事件)投递到你的信箱(客户端 EventSource),你只需要查看信箱即可。而 WebSockets 更像是一条始终连接的电话线,双方都可以随时拿起听筒说话和听对方说话。

6. 流的应用实例:现代 Web 的脉搏

理论知识固然重要,但理解流的真正威力在于看到它们如何在现实世界的应用中发挥作用。以下两个例子——AI 大语言模型(LLM)的响应流和视频流媒体——将展示流技术如何驱动现代 Web 体验。

示例 1:AI 语言模型流式响应 (如 ChatGPT)

为何需要流式响应?提升用户体验

大型语言模型(LLM)如 ChatGPT 在生成较长回复时,需要逐个“思考”并生成词语或标记(tokens)。这个过程可能需要几秒甚至几十秒 。如果采用传统的“等待完整响应”模式,用户将长时间面对一个加载指示器,体验极差。

流式响应 (Streaming) 通过在 LLM 生成每个(或每小批)token 时就将其发送给用户界面,用户几乎可以实时看到文字逐字或逐句地出现,感觉就像在与一个正在思考和打字的“人”对话,大大降低了感知到的延迟,即使总生成时间不变。这种方式还能让用户在模型生成过程中就判断回复是否符合预期,并在必要时提前中断。

如何实现?Token 流的传递

典型的 LLM 流式响应涉及两个阶段的流传输:

  1. LLM API → 后端服务器

    • 当后端服务器调用 LLM 提供商(如 OpenAI, Google Gemini, Anthropic Claude)的 API 时,通常会设置一个 stream: true 之类的参数。
    • LLM API 不会一次性返回完整结果,而是通过一个流式连接(通常是 HTTP 长连接,使用类似 SSE 的格式或特定的流协议)将生成的 tokens(通常是文本片段)逐个或小批量地发送回后端服务器。
  2. 后端服务器 → 前端客户端 (浏览器)

    • 后端服务器收到来自 LLM API 的 token 流后,需要将其再次流式传输给前端用户界面。由于 API 密钥等敏感信息不能暴露给前端,后端充当了必要的中间层。

    • 常用的技术包括 :

      • Server-Sent Events (SSE) :非常适合这种单向(服务器到客户端)的文本数据推送。后端设置 Content-Type: text/event-stream,并将收到的 tokens 包装成 SSE 事件发送给前端。
      • WebSockets:虽然是双向的,但如果应用本身就需要 WebSocket 进行其他交互(如聊天),也可以用它来传输 LLM 的 token 流。
      • 轮询 (Polling) :前端定时向后端请求是否有新的 tokens。效率较低,不是真正的流式传输,但实现简单。
  3. 前端客户端处理

    • 前端使用相应的 API(如 EventSourcefetch + ReadableStreamWebSocket API)接收来自后端的 token 流 。
    • 每当收到一个新的 token(或一小块文本),就将其追加到用户界面上显示的回复区域。

挑战

  • 处理部分数据:有时 LLM 的输出包含格式标记(如 Markdown)或结构化数据(如 JSON),这些标记或结构可能跨越多个 token 块。前端需要正确地拼接和解析这些部分数据,才能在流结束时呈现完整的格式。

LLM 的流式响应完美地诠释了流的核心价值:通过将数据的可用性与数据的展示同步,弥合了底层计算延迟与用户感知响应速度之间的鸿沟。它不是让计算变快,而是让等待的过程变得可见和可交互,从而显著提升了用户体验。

代码示例同上。

示例 2:视频流媒体平台

为何需要流?即时播放与效率

想象一下下载一部高清电影需要多长时间和多少存储空间。如果必须等整个文件下载完才能观看会是什么样。视频流媒体通过流技术解决了这个问题,实现了几乎即时的播放,并且按需传输数据,节省了带宽和存储。

如何实现?分块、清单、自适应码率与缓冲

现代视频流(尤其是基于 HTTP 的流)是一个精心设计的系统,结合了多种流相关的技术:

  1. 分块 (Chunking / Segmentation)

    • 原始视频文件在服务器端被分割成许多小的、连续的视频片段(通常是几秒钟长,如 2-10 秒)。这些小文件可以通过标准的 HTTP 服务器轻松分发。
  2. 流协议与清单文件 (Protocols & Manifest Files)

    • 目前最主流的两个基于 HTTP 的自适应流协议是:

      • HLS (HTTP Live Streaming) :由 Apple 开发,使用 .m3u8 格式的播放列表文件(manifest)。.m3u8 文件是一个文本文件,列出了所有视频块的 URL、顺序以及可用的不同质量版本的信息。
      • MPEG-DASH (Dynamic Adaptive Streaming over HTTP) :一个开放的国际标准,使用 .mpd 格式的媒体表示描述文件(manifest),这是一个 XML 文件,包含类似 HLS 清单的信息,描述了视频结构、块的位置和不同质量选项。
    • 播放器首先下载这个清单文件,来了解如何获取和播放视频块。

  3. 自适应比特率 (Adaptive Bitrate Streaming, ABR)

    • 这是现代视频流的核心特性。服务器会对同一个视频源编码生成多个版本,每个版本具有不同的分辨率和比特率(数据传输速率)。例如,可能有 480p, 720p, 1080p, 4K 等多个版本,每个版本都有对应的视频块。
    • 视频播放器在播放过程中会持续监测用户的网络连接速度和设备性能。
    • 播放器根据当前的网络状况,动态地选择下载最合适质量版本的视频块 。如果网络变慢,播放器会自动切换到低比特率的版本(牺牲一些画质)以避免播放中断;如果网络变好,则切换回高比特率版本以提供最佳画质。这种切换通常在块与块之间进行,力求无缝。
  4. 缓冲 (Buffering)

    • 视频播放器并不会下载一个块就立刻播放一个块,而是会预先下载接下来几个视频块,并将它们存储在设备内存的一个临时区域,这个区域就叫缓冲区 (Buffer)
    • 缓冲的必要性:网络传输不是绝对稳定的,速度会有波动,数据包可能会延迟或丢失。缓冲区就像一个“蓄水池”,里面存储了接下来几秒钟要播放的内容。即使网络暂时出现波动,只要缓冲区里还有数据,播放就能继续进行,从而平滑掉网络的抖动,提供连续、无中断的观看体验。
    • 用户看到的“缓冲” :用户通常只在播放暂停并显示加载图标时才意识到“缓冲”的存在。这实际上是缓冲不足的表现,意味着播放器消耗数据的速度超过了从网络下载数据的速度,缓冲区空了,必须暂停播放以等待缓冲区重新填充。一般是因为网络速度慢、网络拥堵、设备性能不足或服务器问题引起。

类比:视频流就像通过邮政系统接收一部很长的连载小说。

  • 分块:小说被分成一章一章(chunks)邮寄。
  • 清单:你收到一份目录(manifest),告诉你总共有多少章,每章的编号,以及是否有大字版(不同质量)。
  • 自适应比特率:你根据自己的阅读速度(网络带宽)选择是读普通版还是大字版(低质量),并且可以在章节之间切换版本。
  • 缓冲:你总是确保手头有接下来几页的内容(buffer),这样即使邮递员(网络)偶尔送晚了,你也能继续阅读,不会中断。

视频流媒体的实现展示了流概念的高度复杂和协同的应用。它不仅仅是简单地按顺序处理数据,而是涉及服务器端的智能编码和分块、客户端的动态决策(ABR)以及精细的缓冲管理,所有这些都通过标准的 HTTP 协议进行协调,并常借助内容分发网络(CDN)来优化全球范围内的传输效率。这是一个将基础流概念(分块、顺序处理)与网络协议、编码技术和客户端智能相结合,以解决大规模、高质量媒体传输挑战的经典案例。

7. 结论:拥抱流动的数据

从 Unix 管道的简单哲学,到处理二进制数据的底层 JavaScript 对象(ArrayBuffer, TypedArray, Node.js Buffer),再到现代 Web Streams API 提供的标准化接口(ReadableStream, WritableStream, TransformStream),以及 HTTP 协议中为适应流式传输而演进的机制(fetch响应体流, Transfer-Encoding: chunked, Server-Sent Events),我们一路追溯了“流”在 Web 开发中的演变和应用。

流的核心思想——将数据视为随时间到达的序列,并以小块进行处理,以及其相关延伸如缓冲区,这些思想和方法不仅在编程世界里,在我们的日常生活中也有很多体现。

AI 模型(如 LLM)流式输出响应,通过实时展示生成过程显著改善用户体验;视频平台利用分块、自适应码率和缓冲技术,在不稳定的网络条件下提供流畅的观看体验。另外,无论是处理无法一次性载入内存的大文件,还是实现与服务器的实时交互,或是优化网络资源的传输效率,流都提供了一种优雅且高效的解决方案。

理解流,不仅仅是掌握一组 API 或协议。它更关乎理解一种处理数据的方式——一种拥抱数据流动性、注重资源效率、追求即时响应的方式。在数据量日益庞大、实时性要求越来越高的今天,深入理解和熟练运用流相关的概念和技术,对于构建高性能、高响应性、用户体验更佳的 Web 应用至关重要,尤其 AI 发展突飞猛进的今天,流技术将离大家越来越近。