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

634 阅读58分钟

前言

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

长文预警,涉及概念很多,但我们会把这些串联起来,善用目录,可以收藏下来慢慢吸收。

什么是流?——流动的数据

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

在计算机科学中,流(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. **做一件事并做好 ** :鼓励创建小而专一的程序,每个程序只负责一个明确的任务。
  2. **协同工作 ** :期望每个程序的输出都能成为另一个(可能是未知的)程序的输入。

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

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

注意:流式和链式的区别

流是连续的数据块(chunk),连续的块组成数据,数据可以通过pipe传递,即被连接起来,而链式仅仅体现了连接过程。流可以连接也可以不连接,流的重点是分块传输和处理。

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

虽然我们经常处理文本,但计算机世界的基础是二进制——由 0 和 1 组成的数据。图像、音频、视频、网络数据包以及许多底层数据结构,都需要以原始的二进制形式来表示和操作。

流在字节(byte)这个层面上工作,因此理解如何在 JavaScript 和 Node.js 中处理二进制数据是帮助我们理解流的关键。

我们必须先了解以下概念。

ArrayBuffer:存放二进制数据的原始内存块

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

new ArrayBuffer(0);   // 合法,创建一个 0 字节的 buffer(空)
new ArrayBuffer(4);   // 合法,创建一个 4 字节的 buffer
new ArrayBuffer(1024); // 创建 1KB 的 buffer

每个字节默认为0,即 00000000

const buffer = new ArrayBuffer(4);
const view = new Uint8Array(buffer);

console.log(view); // Uint8Array(4) [ 0, 0, 0, 0 ]
// 00000000(二进制) = 0(十进制)

不能直接读写 ArrayBuffer 的内容。它仅仅是数据的容器,一块分配好的内存,必须通过**视图(view)**来操作,视图工具包括 TypedArrayDataView

ArrayBuffer 的特点:

  1. 固定长度:一旦创建,其大小(byteLength)通常是固定的,除非使用了较新的可调整大小(resizable)特性,一般不使用。
  2. 通用性:它不指定内部数据的类型,只是原始字节,只知道自己存储的是 01010101 8位字节。就像水如何出售由装它的水瓶决定,它本身就只是水。
  3. 不可直接操作:需要通过“视图”(View)对象来访问和修改其内容。
  4. 可转移性 (Transferable) :可以通过 postMessage 在主线程和 Web Workers 之间转移所有权,实现高效的数据传递(转移后原ArrayBuffer会分离,不可再用)。
如何理解可转移性

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

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

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

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

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

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

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

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

  • 优化并行计算:让主线程和 Worker 能够高效协作,让worker更好地处理cpu密集型任务。

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

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

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

当一个API返回ArrayBuffer时,意味着你可以使用“工具”来读写其中的01010101了。

TypedArray :解读缓冲区的视图

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

TypedArray 的类型

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

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

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

有符号和无符号的区别

同样的一个8位字节,使用不同 TypedArray 读取时的区别

类型区别
Uint8Array所有数字都是正数,可以读取为,0~255
Int8Array数字有正有负,可以读取为 -128 ~ 127

重要特性:

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

    为什么说TypedArray 是视图?

    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还是 DataView

一般来说:

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 是用于处理原始二进制数据(字节)的、固定大小的内存块,是一个二进制缓冲区。

Node.js 的 Buffer 就是一个字节数组每个元素是一个 8 位(1 字节)无符号整数,范围 0 ~ 255

你可以把 Buffer 理解成 Node.js 版的 Uint8Array,但是功能更丰富,更偏向于二进制数据处理的实用工具箱

为什么叫“缓冲区”?

因为它本质上就是一个临时存储区域,用来在数据生产者(比如读取文件、接收网络数据)和数据消费者(比如你的 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 流的核心数据类型

ArrayBuffer 是最底层的内存表示,TypedArray 是操作它的工具,而 Buffer 是 Node.js 针对其特定环境优化的二进制处理方案。

理解这些基础构件是接下来继续深入学习流处理的前提。

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

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

不同类型的流:

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

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

2. 字符流 (文本流):

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

3. 对象流 :

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

传统的流(如 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!

但是如果你将这个对象流写入一个不支持对象模式的目标(比如写入文件、HTTP 响应) ,就必须进行转换,如果你用的API不能自动转换,就需要通过 JSON.stringify()手动转换为字符串,转换之后的字符串最终就可以被处理为字节流了。

在 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 响应!

再次强调:

对象流仅用于Node 或 浏览器内部逻辑中使用,不能直接把对象流用于 fetch 等不支持对象流的底层API,必须先手动转成字符串、Buffer、Blob 等“网络友好格式”。

上层封装过的API有可能支持,但是底层API是只能认识字节流的。

4. 其他特定应用逻辑流:

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

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

    data: 第一行数据
    data: 第二行数据(可选)
    event: 自定义事件名(可选)
    id: 事件 ID(可选)
    retry: 3000(客户端自动重连间隔,单位 ms)(可选)
    
    \n  ← 两个换行符表示一个事件块结束
    
  • 类比: 运送的完整的邮件包裹,每个包裹里又包含信件、账单等不同类型的纸张。

SSE等特定应用逻辑流与通用意义上的“对象流”的区别在于,SSE 的流格式是固定为它自己的文本协议,符合符合 EventSource 规范,已经定好了必须按照这个格式来才行。

而通用对象流(比如 Node.js 的 objectMode 或自定义的 TransformStream)可以处理任何可以序列化/反序列化的对象,其底层的序列化格式是可变的或由封装它的开发者决定的。

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都默认使用或推荐使用 UTF-8。其成功主要归功于以下几个关键设计特点:

UTF-8 特点

1. 可变宽度编码

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

2. 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 位二进制编码

为什么是 7 位(而不是 8 位)?

在 1960 年代,内存和通信资源非常宝贵:

  • 当时的计算机内存单位小,通常按 6 位或 7 位组织;
  • 通信线路(如电传打字机)传输每个字符越短,越节省带宽;
  • 7 位最多可表示 128 个字符(2⁷ = 128) ,已经足够覆盖常用英文字符(A-Z、a-z)、数字、标点和控制字符(如回车、换行等);
  • 所以 7 位在效率和功能之间找到了一个平衡。

ASCII 字符的分类

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

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

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

ASCII 示例

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

ASCII 码是 Unicode 的子集,所有 ASCII 码点在 Unicode 中依然保持原始定义。

3. 无字节顺序标记问题

之前提到过不同平台使用大小端的字节序会影响兼容性的问题,与 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" 。不支持其他编码。

      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 聊天的流式输出就要用到这个选项。

TextDecoder.decode() 的参数是:

TextDecoder.decode(input?: BufferSource)

BufferSource 类型定义为:

type BufferSource = ArrayBufferView | ArrayBuffer

所以:

  • Uint8ArrayDataViewInt16Array 等都是 ArrayBufferView
  • ArrayBuffer 也可以直接用,它会被内部转换为 Uint8Array
  // 接上例
  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 流中处理文本数据的关键工具,它们是人类能理解的字符串和流传输的底层字节之间的翻译官。

Web Streams API

随着 Web 应用对处理大数据、实时数据和网络响应的需求日益增长,JavaScript 需要一个标准化的、高效的方式来处理流式数据。

传统的 XMLHttpRequest 或早期 fetch 的非流式处理方式(如 .json(), .text())往往需要将整个响应缓冲到内存中,对于大文件或持续数据流来说效率低下且可能导致内存耗尽。

Web Streams API 应运而生,为浏览器环境提供了一套统一的、强大的、用于处理流数据的接口。

这套 API 的核心是三个接口:ReadableStream(可读流)、WritableStream(可写流)和 TransformStream(转换流)。

ReadableStream

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

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

如何获取数据?

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

  1. ReadableStreamDefaultReader:默认读取器,用于读取流中的通用数据块(chunks)。这些块可以是任何 JavaScript 类型(在浏览器内部使用时,不限制类型,但是如果与外部API交互,需要考虑外部API是否支持该类型,这点前面说过),但一般是 Uint8Array
  2. ReadableStreamBYOBReader :允许手动管理缓冲区,减少额外的内存分配,提高性能。
const reader = ReadableStream.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) 用于向流中添加数据块,用到时大家再去详细了解就可以了,这里不赘述。

    const readable = new ReadableStream({
      start(controller) {
        controller.enqueue("H");
        controller.enqueue("i");
        controller.close();
      }
    });
    

WritableStream

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

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

底层汇点 (Underlying Sink)

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

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

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

const writableStream = new WritableStream({
  start(controller) {
    console.log("WritableStream started");
  },
  write(chunk, controller) {
    console.log("写入数据:", chunk);
    // 可以将 chunk 写入某个目标,比如数组、console、文件等
  },
  close(controller) {
    console.log("WritableStream 已关闭");
  },
  abort(reason) {
    console.error("WritableStream 出错:", reason);
  }
});

如何写入数据?

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

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

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

const writableStream = new WritableStream({
  write(chunk) {
    memory.push(chunk);
  },
  close() {
    console.log("写入完毕:", memory);
  }
});

const writer = writableStream.getWriter();
await writer.write("a");
await writer.write("b");
await writer.close();

// 输出:写入完毕: ['a', 'b']

TransformStream

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

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

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

工作原理

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

const readable = new ReadableStream({
  start(controller) {
    controller.enqueue("hello");
    controller.enqueue("world");
    controller.close();
  }
});

const transform = new TransformStream({
  start(controller) {
    // 初始化时调用(可选)
  },
  transform(chunk, controller) {
    controller.enqueue(chunk.toUpperCase());
  },
  flush(controller) {
    // 所有数据处理完后调用(可选)
  }
});

const writable = new WritableStream({
  write(chunk) {
    console.log("最终输出:", chunk);
  }
});

await readable
  .pipeThrough(transform)
  .pipeTo(writable);

// 输出:HELLO, WORLD

常见的 TransformStream 应用:

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

大家了解作用在遇到类似场景时能想起来就好,用到时自然就理解了,光看是记不住的,也不需要记。

连接流:pipeTo()pipeThrough()

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

readableStream.pipeTo(writableStream)

  • 作用:将一个 ReadableStream 的所有数据直接“管道输送”到一个 WritableStream
  • 过程:它会自动从 readableStream 读取数据块,并将它们写入 writableStream,直到 readableStream 结束。
  • 优点:自动处理读取、写入以及两者之间的背压(backpressure,流量控制)。

类似用一根软管直接将水龙头连接到水槽 。

// 假设 readableSource 是一个 ReadableStream
// 假设 writableDest 是一个 WritableStream
await readableSource.pipeTo(writableDest);
// 数据自动从 source 流向 dest

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

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

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

以上就是这套 API 的核心是三个接口:ReadableStream(可读流)、WritableStream(可写流)和 TransformStream(转换流)。

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

网络通信中的流 —— TCP流

流的概念不仅限于浏览器内部或服务器内部的数据处理,它在网络通信中也起着很重要的作用,帮助数据能够在客户端和服务器之间高效的传输。

方便后续理解,我们先来回顾一下 TCP 协议基础知识,后面会用到哦。

TCP 是什么?

TCP 传输控制协议(Transmission Control Protocol) 是一种面向连接可靠传输的传输层协议。

Web、聊天、文件传输等很多场景的基础通信协议(HTTP、FTP、SMTP 等)都运行在 TCP 之上。

TCP/IP 四层模型

  1. 网络接口层

    • 负责数据在 物理网络 上传输(类似 OSI 的 物理层 + 数据链路层
    • 相关协议:Ethernet(以太网)、Wi-Fi等
  2. 互联网层

    • 负责 IP 地址 的分配与路由,保证数据能够在不同网络之间正确传输(类似 OSI 的 网络层
    • 相关协议:IPv4、IPv6等
  3. 传输层

    • 负责端到端数据传输,确保数据完整性(类似 OSI 的 传输层
    • 相关协议:TCP(可靠传输)、UDP(快速但不可靠)
  4. 应用层

    • 直接与用户交互,提供各种 网络服务(包含 OSI 的 会话层 + 表示层 + 应用层
    • 相关协议:HTTP、FTP、DNS、SMTP、Telnet

TCP/IP vs. OSI:

  • TCP/IP 更接近实际应用,而 OSI 是理论模型
  • TCP/IP 只有四层,比 OSI 的 七层 结构更简化;
  • TCP/IP 使用 IP 地址进行数据传输,而 OSI 仅描述网络通信逻辑。

搞个顺口溜,方便记忆: OSI模型有七层,物数网传会表应。

OSI模型传输过程

层级单位名称举例
物理层比特流01010101
数据链路层帧 Frame以太网(有线)帧、Wi-Fi(无线)帧等
网络层包 PacketIP包(IPv4、IPv6)
传输层段 SegmentTCP 段、UDP 数据报
应用层应用数据HTTP、SSE、WebSocket等数据

当发送数据时,数据会自上而下通过协议栈(也就是OSI分层网络协议):

  1. 应用层数据(比如网页内容)被传输层(TCP 或 UDP)分割并添加头部,形成 TCP 段 或 UDP 数据报。
  2. TCP 段/UDP 数据报被网络层(IP)添加 IP 头部,封装成 IP 数据包
  3. IP 数据包被数据链路层添加帧头部和尾部,封装成 
  4. 帧在物理层被转换成 比特流,在物理介质上传输。

在接收端,这个过程反过来:

  1. 物理层接收比特流,重建成帧。
  2. 数据链路层检查帧的错误,移除帧头部和尾部,提取出 IP 数据包。
  3. 网络层检查 IP 数据包的头部,根据 IP 地址判断是发给自己的还是需要转发,如果是发给自己的,则移除 IP 头部,提取出 TCP 段或 UDP 数据报。
  4. 传输层根据 TCP/UDP 头部处理段/数据报(比如 TCP 会检查顺序、重组),然后将应用层数据提交给应用程序。

每层通信都是点对点:

  • TCP 段:进程 ↔ 进程(端口号)
  • IP 包:主机 ↔ 主机(IP 地址)
  • :网卡 ↔ 网卡(MAC 地址)

以上基础知识请多读几遍,接下来的你才会更容易理解。

TCP 的关键特性

特性说明
面向连接通信前必须先建立连接(握手)。
可靠传输保证数据 不丢、不重、不乱序(靠序号 + 重传机制)。
全双工双向通信,双方都能发送/接收数据。
流量控制控制发送速率,避免接收方处理不过来(滑动窗口机制)。
拥塞控制避免因网络拥塞造成严重数据丢失。
无边界数据是一个“连续的字节流”,没有消息的分包概念(这一点非常关键!)。

概括: 可靠、顺序、连续、无边界。

三次握手和四次挥手

建立连接(三次握手)


  客户端                         服务器
  ────── SYN ──────→          // SYN(Synchronize)请求建立连接
  ←──── SYN + ACK ─────       // ACK(Acknowledgment) 确认请求
  ────── ACK ──────→          // 确认接收,连接建立

断开连接(四次挥手)

plaintext
复制编辑
客户端                         服务器
  ────── FIN ──────→          // FIN(Finish)请求连接断开
  ←────── ACK ──────          // 收到请求(理论上,关闭连接也可以用三次,但 TCP 允许服务器在收到 `FIN` 后仍能发送数据,因此必须等它也发送 `FIN`,才算真正结束,所以需要多一步确认发送完才能断开。)
  ←────── FIN ──────          // 数据发送完成,服务器也准备断开
  ────── ACK ──────→          // 双方都确认,连接关闭

为什么需要滑动窗口?

  1. 流量控制

防止发送方过快地发送,导致接收方来不及处理,内存溢出或数据丢失

接收方告诉发送方:“我最多还能接受 N 个字节”,发送方就按这个窗口发。

  1. 实现可靠传输

如果某个包没有收到 ACK,就不会滑动,也可以重发丢失的数据(支持“选择性确认”)。

什么是TCP流?

既然叫做流,就意味着:不一定一次性拥有完整数据,而是数据会逐步送达。

TCP 流指的是 TCP 建立连接后,在两端之间形成的一条连续的、有序的、可靠、无边界的字节流通道

可以把 TCP 连接 想象成一个管道:

  • 一端是客户端,一端是服务器。
  • 数据像水一样通过这个管道流动。
  • 没有消息边界,可能会将字节数据拆成多个段,只有“一个或多个字节”地读写,应用层必须自己划分消息。
  • 所以,TCP 是一种 面向字节流(byte stream) 的协议。

与其他流的关系

场景是否基于 TCP 流是否有边界示例
HTTP有(头 + body)下载网页
SSE无边界(用 \n\n 协议约定)推送数据流
WebSocket无边界(你要自己封包)双向交互聊天
UDP有边界视频直播、线上游戏

流数据被拆分或合并

情况一:单次发送被拆分

'{"name": "Alice"}\n'
  • TCP 可能因为 MSS 限制(比如 1460 字节),把这一段数据 拆成多个 TCP 段
  • 接收方通过 .recv() 或前端的 ReadableStream 的reader.read()可能读到“半截”的数据

结果可能是这样读取到两次:

1st read: '{"name": "A'
2nd read: 'lice"}\n'

情况二:多次发送被合并

'{"name": "Alice"}\n'
'{"name": "Bob"}\n'

你以为是两次发送,接收端也会两次收到,对吧?错了。

实际上,操作系统内核的 TCP 输出缓冲区可能把它们合并成一个段再发送,比如 Nagle 算法会这么做(虽然现代服务端很多会禁用它)。

接收方 .recv()ReadableStream 的 reader.read() 可能一次就读到:

'{"name": "Alice"}\n{"name": "Bob"}\n'

或者也可能读取得更碎:

1st read: '{"name": "Alice"}\n{"na'
2nd read: 'me": "Bob"}\n'

基于TCP流的应用层需要自行去处理流数据的边界,以获取服务端发送的原始数据格式。

重点:如何处理流的边界?

以后端流式发送JSON格式字符串数据为例:

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

app = FastAPI()

async def json_stream():
    data = [
        {"name": "Alice"},
        {"name": "Bob"},
        {"name": "Tom"}
    ]

    for item in data:
        yield json.dumps(item) + "\n"  # 一行一个 JSON对象,以 \n 来划分边界
        await asyncio.sleep(1)

@app.get("/stream-json")
async def stream_json():
    return StreamingResponse(json_stream(), media_type="application/json")

当后端发送一个完整的JSON字符串时,我们来捋一下整个过程:

在服务端

  1. 应用层 (HTTP/SSE):  后端程序准备好要发送的完整数据字符串(比如 {"name": "Alice"}\n)。它将这个字符串以及其他 HTTP 信息(如头部)交给操作系统的网络栈。

  2. 传输层 (TCP):  TCP 协议从应用层接收字节流数据。它不会一股脑地将所有数据打包发送,而是根据多种因素(最重要的是最大分段大小 MSS - Maximum Segment Size)将这些字节流分割成多个较小的块,称为“段”(Segments)

    • MSS 的大小通常是根据网络的 MTU (Maximum Transmission Unit) 来确定的,简单来说就是底层网络(比如以太网)一次能传输的最大数据包大小减去各种头部开销。
    • TCP 将原始数据分割成不大于 MSS 的段,并为每个段添加一个 TCP 头部(包含顺序号、确认号等重要信息)。
  3. 网络层 (IP):  TCP 段被传递给网络层,网络层将每个 TCP 段封装在一个 IP 数据包 (Packet) 中,并添加 IP 头部(包含源和目标的 IP 地址)。这些 IP 数据包就是真正在互联网上传输的基本单元。

  4. 数据链路层和物理层:  IP 数据包进一步被封装成帧,通过物理介质(如电缆中的电信号、光纤中的光信号、空气中的无线电波等)传输字节流。

在接收端:

  1. 物理层和数据链路层:  接收端硬件接收到比特流,重建成帧,并检查错误。

  2. 网络层 (IP):  IP 层接收到 IP 数据包,可能会发现它们乱序到达。

  3. 传输层 (TCP):  TCP 层接收到 IP 数据包中的 TCP 段。它利用段头部中的顺序号来:

    • 将乱序到达的段重新排序。
    • 检测丢失的段,并请求发送端重传。
    • 移除 TCP 头部,将各个段的原始数据重新组合成发送端原始的字节流。
    • 在这里,TCP 已经努力把原始的字节流恢复出来了,它不会保留原始发送时 TCP 分段的信息
  4. 操作系统和浏览器缓冲:  从 TCP 层重组出来的连续字节流会被放到操作系统内核的网络缓冲区或浏览器进程内部的缓冲区里(多个连续消息, TCP 输出缓冲区可能把它们合并成一个段再发送)。

  5. Web Stream API ReadableStream (reader.read()):  reader.read() 方法就是从浏览器内部的这个缓冲区里读取一块数据。

    • 注意:这个读取块的大小不一定等于原始 TCP 分段的大小,也不一定等于后端发送的原始应用层消息(如 {"name": "Alice"}\n)的大小。它只是根据缓冲区里当前有多少数据以及浏览器内部的读取机制来决定本次读取多少数据。

所以,你发一次 ≠ 别人收一次,数据到TCP时会按照A策略被分割或合并一次。而在接收端,虽然 TCP 负责重组回原始的字节流,但 Web Stream API 的 read() 方法是从缓冲区读取,这个缓冲区的读取块大小与原始 TCP 分段A策略没有直接对应关系,也与后端应用层发送的逻辑消息边界(如 JSON用\n分割)无关。

所以,你需要自己去缓冲数据并查找 \n 边界解析服务端原始数据。

fetch('/stream-json')
  .then(response => {
    const reader = response.body.getReader();
    const decoder = new TextDecoder('utf-8');
    let buffer = '';

    function readChunk({ value, done }) {
      if (done) return;

      // 累积数据到 buffer
      buffer += decoder.decode(value, { stream: true }); //  stream: true  表示增量解码(保留未完整字符),防止字符被截断,流式响应时必须开启,假设一个中文字符3个字节,如果不开启这三个字节就会乱套,最终无法恢复正确的中文字符了

      // 拆分完整 JSON 字符串(以换行符为边界)
      const parts = buffer.split('\n');
      buffer = parts.pop(); // 可能是未完整的一条,留给下次处理

      for (const jsonStr of parts) {
        try {
          const obj = JSON.parse(jsonStr);
          console.log('收到数据对象:', obj);
        } catch (e) {
          console.error('JSON 解析失败:', e);
        }
      }

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

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

接下来我们来深入 HTTP流。

网络通信中的流 —— HTTP流

Fetch API 的 ReadableStream 响应体

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

XMLHttpRequest (XHR) 是一个更老的 API。它不原生支持将响应体暴露为一个标准的 Web ReadableStream。使用 XHR 获取数据,需要等待整个响应体全部下载并缓冲到内存中后,才能通过 xhr.responseText 或 xhr.response 等属性访问数据。(虽然有一些非标准的技巧或在特定环境(如 Node.js)中 XHR 可能表现出一些流的特性)。

当服务器开始发送响应数据时,咱不需要等待整个响应下载完成,就可以通过 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. 使用管道 :将 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 流则避免了这个问题。

HTTP/1.1的Transfer-Encoding: chunked

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

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

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

工作机制

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

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

  3. chunk 如何定义边界?每个块包含两部分:

    • 块大小:表示该块数据部分长度的十六进制数字,后跟 \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       <-- 结束块
\r\n        <-- 结束块后的空行

关键点

  • 流式传输:允许服务器边生成边发送数据,无需预先知道总大小,提高了首字节时间(TTFB),实际上就是一种自定义的边界划分格式,只不过Transfer-Encoding: chunked是官方支持的,其他自定义格式也行,但是需要你自行处理解析。
  • HTTP/1.1 特定:这是 HTTP/1.1 的机制。HTTP/2 和 HTTP/3 拥有更先进的内置流式传输机制(基于帧),不再使用(甚至禁止)Transfer-Encoding: chunked
  • 与压缩的关系:如果同时使用压缩(如 Content-Encoding: gzip)和分块传输,通常是先压缩整个内容,然后将压缩后的数据进行分块传输。

流式 API 不一定要使用 chunked

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

chunked 是 HTTP/1.1 在应用层“模拟”出来的流式传输方式;

而 HTTP/2、HTTP/3 则是在协议层“原生”定义了流、帧、多路复用等机制,是真正内建对流的支持。

SSE 与 text/event-stream

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

工作机制

  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 output = document.getElementById('output');
      output.textContent = ''; // 清空

      const eventSource = new EventSource('http://localhost:80/events');

      eventSource.onmessage = function (event) {
        output.textContent += `默认消息: ${event.data}\n`;
      };

      eventSource.addEventListener('user_update', function (event) {
        const data = JSON.parse(event.data);
        output.textContent += `用户更新: ${data.username} 状态: ${data.status}\n`;
      });

      eventSource.onerror = function (err) {
        output.textContent += `连接错误\n`;
        console.error("EventSource failed:", err);
        eventSource.close();
      };

fetch + ReadableStream

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

fetch('/sse-endpoint')
  .then(response => {
    const reader = response.body.getReader();
    const decoder = new TextDecoder(); // 默认 UTF-8编码的流
    let buffer = ''; // 创建一个字符串缓冲区 `buffer` 用于收集不完整的数据块

    // 从 TCP 层重组出来的连续字节流会被放到操作系统内核的网络缓冲区和浏览器进程内部的缓冲区里。
    // Web Stream API (reader.read()): reader.read() 方法就是从浏览器内部的这个缓冲区里读取一块数据。这个读取块的大小不一定等于原始 TCP 分段的大小,也不一定等于后端发送的原始应用层消息(如 data:...\n\n)的大小。它只是根据缓冲区里当前有多少数据以及浏览器内部的读取机制来决定返回多少数据。
    return reader.read().then(function process({ value, done }) {
      if (done) return;

      // `{ stream: true }` 表示增量解码(保留未完整字符),防止字符被截断,流式响应必须开启,假设一个中文字符3个字节,如果不开启这三个字节就会乱套,最终无法恢复正确的中文字符了
      buffer += decoder.decode(value, { stream: true });

      // 将缓冲区按 `\n\n`(SSE 消息分隔符)拆成多段。
      // `lines.pop()` 剩下的是**可能还没读完的那一段**,下次继续读进来再拼接。
      let lines = buffer.split('\n\n');
      buffer = lines.pop(); // 剩余未完整的数据

       /* 遍历每个完整的消息块:
        -   找到 ` data:  `开头的行。
        -   提取 `data` 内容(去掉前缀)。
        -   打印输出。如果是json字符串就用JSON.parase()
       */
      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);
          if (jsonStr) {
           try {
            const json = JSON.parse(jsonStr);
           } catch (e) {
             // 安静地跳过不完整或无效JSON
           }
        }
        }
      }

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

两者对比:

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

当你使用前端使用fetch + ReadableStream时,如果后端返回的格式不遵循EventSource约定的话,后端不用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'); // 可以不使用 text/event-stream
    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 是更合适的选择。

AI 语言模型流式响应

几乎世面上所有AI聊天产品都支持了流式响应。

为何需要流式响应?

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

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

如何实现?Token 流的传递

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

  1. LLM API → 后端服务器

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

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

    • 使用 SSE 或 WebSockets来实现流式响应:

      • Server-Sent Events (SSE) :非常适合这种单向的文本数据推送。后端设置 Content-Type: text/event-stream,并将收到的 tokens 包装成 SSE 事件发送给前端。
      • WebSockets:虽然是双向的,但如果应用本身就需要 WebSocket 进行其他交互(如聊天),也可以用它来传输 LLM 的 token 流。
  3. 前端客户端处理

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

有一个细节是拼接文本的工作最好在前端,即服务端每次推送只推送当前文本而不是,拼接工作由前端来做,降低服务端资源消耗,前端处理也更灵活。

代码示例同上。

结语

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

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

AI 模型(如 LLM)流式输出响应,通过实时展示生成过程显著改善用户体验。

视频平台利用分块、自适应码率和缓冲技术,在不稳定的网络条件下提供流畅的观看体验。

另外,无论是处理无法一次性载入内存的大文件,还是实现与服务器的实时交互,或是优化网络资源的传输效率,流都提供了一种优雅且高效的解决方案。

流,是一种处理数据的方式——一种拥抱数据流动性、注重资源效率、追求即时响应的方式。对于构建高性能、高响应性、用户体验更佳的 Web 应用至关重要,尤其是 AI 发展突飞猛进的今天,理解并应用流是每个对AI应用跃跃欲试的小伙伴的基本功。