源码精读:拆解 ChatGPT 打字机效果背后的数据流水线

源码精读:拆解 ChatGPT 打字机效果背后的数据流水线

你是否曾经好奇过 ChatGPT 的打字机效果是如何实现的?一年前,我带着同样的疑问写下了ChatGPT流式数据渲染:一份面向初学者的最佳实践演示,跟大家分享可以使用 fetch-event-source 库的几个钩子函数来实现打字机效果。

但仅仅会用是不够的!随着深入使用,我越来越好奇它背后的实现原理。于是,我深入研究了 fetch-event-source 这个核心库的源代码,并将精华总结在本文中。无论你是刚接触前端的新人,还是想深入理解现代 Web 技术的开发者,阅读本文都能让你:

  • 明白"那个打字效果是怎么打出来的?":彻底搞清楚像ChatGPT那样的打字机效果,底层是怎么一个字一个字从服务器流到你的浏览器并显示出来的,而不仅仅是调用一个API。

  • 学会"代码怎么知道现在该干嘛?":理解库如何在"刚连接成功"、"收到消息"、"出错"等不同时刻准确触发对应的回调函数,让你能精准控制每个环节。

  • 搞懂"数据流水线"是怎么组装的? :深入理解如何从零构建一个高效、低内存占用的流式数据管道,学习 getBytes → getLines → getMessages 这一经典的三层处理模型,以及如何把零碎的二进制数据拼装成完整消息。

  • 学会"让专业的模块干专业的事" :通过剖析 fetch.ts (连接管理) 与 parse.ts (数据解析) 的职责分离,学会如何设计职责单一、易于维护和测试的模块,并将其应用到你自己的项目架构中。

  • 搞定"如何一心三用管理连接和错误?":学会库如何用巧妙的技术,同时处理好连接、收数据、断线重试这些复杂操作,让流程稳如泰山。

准备好揭开流式数据处理的神秘面纱了吗?让我们开始吧!

缘起:原生 EventSource 哪里不够好?

服务器推送事件(Server-Sent Events, SSE)是 Web 中一种轻量级的实时通信技术,非常适合实现消息通知、实时数据更新等场景。浏览器为此提供了原生的 EventSource API。

然而,在实际的复杂项目中,原生 EventSource 很快就会显得力不从心:

  1. 无法携带认证信息 :它不支持自定义 HTTP 请求头(Headers),这意味着我们无法方便地加入 Authorization Token。
  2. 孱弱的错误处理:当连接出错时,我们无法获取详细的 HTTP 状态码,很难判断是网络问题还是服务器权限问题。
  3. 功能受限:只能发送 GET 请求,无法发送请求体(Body)。

fetch-event-source 的诞生,就是为了解决以上所有痛点。

核心思想:用 fetch 的超能力重塑 EventSource

这个库的作者做出了一个非常聪明的决定:摒弃原生 EventSource,转而使用无所不能的 fetch API 来从零开始构建一个功能完备的 SSE 客户端。

fetch API 允许我们完全控制请求的方方面面------Headers、Method、Body、信号中断等,这从根本上解决了原生 API 的所有局限性。

架构之美:高内聚、低耦合的"双子星"

在深入代码细节之前,我们先来看看它的整体架构。其核心代码被清晰地划分在两个文件中:

  • src/fetch.ts: 连接大总管。它负责所有与网络相关的"脏活累活"------发起请求、管理连接的生命周期(重连、关闭)、处理 HTTP 级别的错误。它对外提供简洁的 API,对内则指挥着整个流程。
  • src/parse.ts: 纯粹的解析器。它的职责只有一个,就是接收二进制数据流,并根据 SSE 协议规范,把它"翻译"成结构化的 JavaScript 对象。它不关心数据从哪来,只关心如何正确解析。

这种关注点分离的设计思想,使得代码的每个部分都职责单一、清晰明了,极大地提高了代码的可读性和可维护性。

魔法揭秘:数据在"流处理管道"中的奇妙旅程

fetch-event-source 最令人拍案叫绝的部分,在于它如何高效地处理来自服务器的流式数据。它构建了一个由三个函数组成的、如同流水线一样的处理管道。

我们可以把它想象成一个智能快递分拣中心:

typescript 复制代码
// fetch.ts
await getBytes(response.body!, getLines(getMessages(...)));

数据流就像一个源源不断的传送带,依次经过三个处理站:

1. getBytes - 卸货工

  • 职责 :这是流水线的第一站。它从 fetch 响应的 response.body (一个 ReadableStream) 中,一块一块地读取最原始的二进制数据(Uint8Array)。
  • 比喻:一个勤勤恳恳的卸货工,只管把卡车上的货箱搬下来,放到传送带上,而不关心箱子里是什么。

2. getLines - 开箱与分拣员

  • 职责:这是流水线的核心枢纽。它接收一块块大小不一的数据,面临的挑战是数据可能在任何地方被截断(比如半行数据)。它的任务是利用一个内部缓冲区,将这些碎片化的数据拼接起来,并准确地按换行符切割成完整的一行一行。
  • 比喻:一个经验丰富的分拣员。他把货箱打开,将里面杂乱的字条拼接成一行行通顺的"句子",然后交给下一个人。

3. getMessages - 包裹组装员

  • 职责 :流水线的最后一站。它接收一行行规整的数据,并根据 SSE 协议规则(如 data:、id:、event:)将它们组装成一个结构化的消息对象(EventSourceMessage)。当它遇到一个空行(协议中的消息结束符)时,就意味着一个完整的"包裹"组装完毕,可以派送了。
  • 比喻 :一个精通业务的打包员。他看懂了每一句"句子"的含义,并将它们填写到一张标准的快递单上。当所有信息填写完毕(遇到空行),他就把这个包裹发出去(触发 onmessage 回调)。

这个从外到内、层层递进的处理方式,整个过程高效、内存占用极低,无论数据流多大,都能从容应对。

源码深潜:一次数据流的完整旅程

fetch-event-source 的整体流程由 fetchEventSource 函数精心编排,其核心是一个名为 create 的内部函数,它像一个永不放弃的"任务执行官",负责单次连接的尝试、成功、失败与重试。

整个流程可以概括为以下几个步骤:

  1. 启动任务 : 调用 fetchEventSource 时,会立即返回一个 Promise,同时 create 函数被首次调用,开始第一次连接尝试。

  2. 尝试连接 (try 块) : 在 create 函数内部,try...catch 结构包裹了单次连接的"快乐路径"。

    • await fetch(...): 发起网络请求,建立与服务器的连接。
    • await onopen(...) : 连接成功后,调用 onopen 回调,允许用户校验响应头和状态码。如果校验失败(例如,用户在此抛出异常),流程会立即转入 catch 块。
    • await getBytes(...) : 开始进入我们下面将要深入探索的数据处理管道。await 会在这里暂停,直到数据流正常结束。
    • onclose() : 如果数据流正常结束,代表连接圆满完成,触发 onclose 回调。
  3. 处理异常 (catch 块) : 如果在 try 块的任何步骤发生错误(网络问题、onopen 抛错等),流程会进入 catch 块。

    • 调用 onerror(...) : 将错误交给用户提供的 onerror 回调处理。
    • 决定重试策略 : onerror 回调的返回值将决定是否以及何时重试。如果返回一个数字(毫秒),setTimeout 就会被安排,在指定延迟后再次调用 create 函数,开始下一次尝试。
    • 终止流程 : 如果 onerror 回调自己也抛出异常,则认为错误是致命的,整个流程将以 Promise reject 告终。

现在,让我们聚焦于 try 块中最核心的部分------数据处理管道。我们一起来当一次数据侦探,追踪 response.body 这个"包裹"的完整旅程。

起点:fetch.ts - 旅程的开端

我们一起来当一次数据侦探,追踪 response.body 这个"包裹"的完整旅程。

首先,我们必须回到"总指挥室" fetch.ts,因为getBytes是在这里被调用的。这是理解一切的关键。

typescript 复制代码
// in src/fetch.ts
const response = await fetch(sseEndpoint, { ... });

await getBytes(response.body!, getLines(getMessages(...)));

现在,我们把 getBytes 的定义放在旁边对比一下:

typescript 复制代码
// in src/parse.ts
export async function getBytes(stream, onChunk) {
  const reader = stream.getReader();
  let result: ReadableStreamDefaultReadResult<Uint8Array>;
  while (!(result = await reader.read()).done) {
    onChunk(result.value);
  }
}

看到了吗?getBytes 需要两个参数:

  1. stream: 我们传了 response.body!,这是数据来源。
  2. onChunk: 我们传了 getLines(getMessages(...))。

最重要的认知 :传递给 getBytes 的第二个参数 onChunk,就是 getLines 这个函数运行后的返回值!

所以,当 getBytes 内部调用 onChunk(result.value) 时,它到底在调用什么?为了找到答案,我们必须先看看 getLines 做了什么。


第一站:进入 getLines - "我是谁?我返回了什么?"

我们来看 getLines 的结构(我简化了一下):

typescript 复制代码
// in src/parse.ts
export function getLines(onLine) {
    // 1. 做一些准备工作 (定义 buffer, position 等变量)
    let buffer;
    let position;
    // ...

    // 2. 返回一个全新的函数!
    return function onChunk(arr) { // arr 就是 getBytes 传来的 result.value
        // ... 在这里处理 arr ...
        // ... 找到一行后,调用 onLine(line) ...
    };
}

现在谜底揭晓了!

getBytes 拿到的 onChunk 参数,就是 getLines 返回的这个内部函数!

所以,当 getBytes 兴高采烈地执行 onChunk(result.value) 时,它实际上是把 result.value 这个数据块(chunk),交给了我们刚刚在 getLines 内部定义的那个函数来处理。


第二站:追踪 result.value 在 getLines 内部的旅程

现在,我们就是 getLines 返回的那个函数了。我们刚刚从 getBytes 那里收到了一个 result.value,它现在被命名为 arr。

让我们看看 arr 会经历什么:

1. 被放入缓冲区

  • 目的 :处理数据被网络分割的问题。不管 getBytes 给我们的 arr 是多大一块,我们都把它安全地放到 buffer 这个大工作台上,确保数据是连续的。
typescript 复制代码
// in src/parse.ts, inside the function returned by getLines
if (buffer === undefined) {
    buffer = arr; // 如果是第一个 chunk,直接赋值
    position = 0;
    fieldLength = -1;
} else {
    // 如果之前有剩下的数据,就把新来的 arr 拼在后面
    buffer = concat(buffer, arr);
}

2. 在缓冲区中被扫描

  • 目的:从连续的二进制数据中,识别出"行"的边界。
typescript 复制代码
// in src/parse.ts, inside the function returned by getLines
const bufLength = buffer.length;
let lineStart = 0; // 记录当前行开始的位置
while (position < bufLength) {
    // 从当前位置开始,一个字节一个字节地寻找换行符
    let lineEnd = -1;
    for (; position < bufLength && lineEnd === -1; ++position) {
        switch (buffer[position]) {
            case 13: // \r
                discardTrailingNewline = true;
            case 10: // \n
                lineEnd = position;
                break;
        }
    }
    // ...
}

3. 被切割成"行"并送往下游

  • 目的 :getLines 的核心任务完成!它把原始的、大小不一的 chunk,成功转换成了一行一行(line)的数据。
typescript 复制代码
// in src/parse.ts, inside the while loop
if (lineEnd === -1) {
    // 还没找到换行符,说明这行数据不完整,等待下一个 chunk
    break;
}

// 找到了!把这一行数据从 buffer 中"剪"下来
// 注意:subarray 是高效的,没有复制数据,只是创建了一个新的"视图"
const line = buffer.subarray(lineStart, lineEnd);

// 把处理好的"行"数据,交给下一个环节
onLine(line, fieldLength);

// 更新下一行的起始位置
lineStart = position;
fieldLength = -1;

第三站:进入 getMessages - 包裹的最终组装

我们已经追踪到,原始的 result.value 经过 getLines 的处理,变成了一行一行的 line。这些 line 通过 onLine(line) 调用被发送出去。

那么,onLine 究竟是谁?它就是 getMessages 返回的那个函数!现在,我们进入旅程的最后一站。

我们先看 getMessages 的结构:

typescript 复制代码
// in src/parse.ts
export function getMessages(onId, onRetry, onMessage) {
    // 1. 准备工作:创建一个空的 message 对象和一个解码器
    let message = newMessage();
    const decoder = new TextDecoder();

    // 2. 返回一个全新的函数 onLine
    return function onLine(line, fieldLength) {
        // ... 核心处理逻辑就在这里 ...
    }
}

这个返回的 onLine 函数,就是 result.value 变形记的终点。它接收 getLines 切割好的每一行 line,并执行最终的"组装"工作。

在 onLine 内部,主要有两种情况:

情况一:收到一个空行 (line.length === 0)

这是 SSE 协议中的"暗号",代表一条完整的消息至此结束。

typescript 复制代码
// in src/parse.ts, inside the returned function
if (line.length === 0) {
    // 触发 onMessage 回调,把组装好的包裹发走!
    onMessage?.(message);
    // 再拿一张新的空快递单,准备下一个包裹
    message = newMessage();
}
  • onMessage?.(message) : 旅程结束!组装完毕的 message 对象,通过这个回调函数,被正式派送给最终的用户。
  • message = newMessage(): 旧的工作完成,立刻为新的任务做准备,体现了状态机的思想。
情况二:收到带有内容的行

这意味着我们收到了类似 data: Hello World 或 id: 123 这样的信息。

typescript 复制代码
// in src/parse.ts, inside the returned function
} else if (fieldLength > 0) {
    // 将二进制的 line 解码成字符串
    const field = decoder.decode(line.subarray(0, fieldLength));
    const value = decoder.decode(line.subarray(/*...*/));

    // 根据 field 的内容,填写快递单
    switch (field) {
        case 'data':
            // 多行 data 需要用 \n 拼接
            message.data = message.data
                ? message.data + '\n' + value
                : value;
            break;
        case 'id':
            // 填写 id,并同时通过 onId 回调"汇报"给总指挥室
            onId(message.id = value);
            break;
        case 'event':
            message.event = value;
            break;
        case 'retry':
            // ...处理 retry...
            break;
    }
}
  • 解码 : decoder.decode 将二进制数据转换成我们可以理解的 field 和 value 字符串。
  • 填充 : switch 语句像一个熟练的工人,根据 field 的指示,将 value 准确地填充到 message 对象的相应属性上。
  • 汇报 : 在处理 id 和 retry 的时候,它还会"顺便"调用 onId 和 onRetry 回调,将关键信息实时同步给 fetch.ts。

总结:result.value 的变形记

  1. 在 getBytes 中,它诞生了,名字叫 result.value。它是一个原始的、大小不一的二进制数据块。
  2. getBytes 把它交给了 getLines 返回的 onChunk。
  3. 在 onChunk 内部,result.value (现在叫 arr) 被放入缓冲区,并被切割成了一段或多段更小的数据,名字叫 line。它现在是一行一行的二进制数据。
  4. onChunk 把每一个 line 交给了 getMessages 返回 onLine。
  5. 在 onLine 内部,line 被最终解析,它的信息(id, data 等)被用来填充一个 message 对象。
  6. 最终,当一个完整的 message 对象组装完毕(以一个空行line为标志),它就会被 onmessage 回调发送出去,完成它的使命。

所以,getBytes 抛出的 result.value 并没有被"神秘地"处理掉,而是作为整个流处理管道的输入燃料,驱动了后续所有环节的运行。这是一个从原始数据到结构化信息,层层递进、不断精炼的过程。

演示示例:一次完整的请求与数据处理

让我们通过一个完整的端到端示例,来看看当用户发起请求后,数据是如何在 fetch-event-source 内部被处理的。

第一步:用户发起请求

假设我们有一个 SSE 接口 '/api/events',我们会像这样使用 fetchEventSource:

typescript 复制代码
import { fetchEventSource } from './fetch-event-source';

const ctrl = new AbortController();

fetchEventSource('/api/events', {
    signal: ctrl.signal,
    onopen(response) {
        if (response.ok && response.headers.get('content-type') === 'text/event-stream') {
            return; // 一切正常
        }
        throw new Error(`Unexpected content-type: ${response.headers.get('content-type')}`);
    },
    onmessage(ev) {
        console.log('New message received:', ev);
    },
    onerror(err) {
        console.error('An error occurred:', err);
        ctrl.abort(); // 发生错误,终止连接
    }
});

第二步:服务器响应与网络分块

服务器开始向我们推送事件。假设它要发送两条消息,原始数据流如下:

id: 1\ndata: First message\n\n_id: 2\nevent: update\ndata: Second message\n\n

然而,由于网络传输的特性,response.body 可能会将数据切割成多个 chunk(数据块)。我们来模拟一个常见的场景,数据被分成了两个 不规则的 chunk 到达:

  • Chunk 1 : id: 1\ndata: First message\n\n_id: 2\nev
  • Chunk 2 : ent: update\ndata: Second message\n\n

第三步:数据在处理管道中的旅程

现在,我们来看看这两个 chunk 是如何被 getBytes, onChunk, 和 onLine 逐步处理的。

处理 Chunk 1
  1. getBytes 读到 Chunk 1,立即将其交给 getLines 返回的 onChunk 函数。
  2. onChunk 开始处理 Chunk 1:
    • 它扫描并切割出第一行 "id: 1",交给 onLine 处理。
    • 接着切割出第二行 "data: First message",交给 onLine 处理。
    • 然后切割出一个空行 "",也交给 onLine。
    • 最后,它发现 _id: 2\nev 这一段数据不完整(末尾没有换行符),于是 onChunk 将其存入自己的内部缓冲区 buffer,静待下一个数据块。
  3. onLine 的工作:
    • 收到 "id: 1",将 { id: '1' } 存入内部的 message 对象。
    • 收到 "data: First message",将 { data: 'First message' } 存入 message 对象。
    • 收到空行 ,这是一个结束信号!onLine 触发 onmessage 回调。
    • 控制台输出 : New message received: { id: '1', data: 'First message', event: '', ... }
    • onLine 重置内部的 message 对象,准备处理下一条消息。
处理 Chunk 2
  1. getBytes 读到 Chunk 2 (ent: update\ndata: Second message\n\n),再次交给 onChunk。
  2. onChunk 的工作:
    • 它首先将 Chunk 2 与缓冲区中剩下的 _id: 2\nev 拼接 起来,得到一个完整的新数据 id: 2\nevent: update\ndata: Second message\n\n。
    • 它开始扫描这个新 buffer:
      • 切割出 "id: 2",交给 onLine。
      • 切割出 "event: update",交给 onLine。
      • 切割出 "data: Second message",交给 onLine。
      • 切割出最后的空行 "",交给 onLine。
  3. onLine 的工作:
    • 收到 "id: 2",存入新的 message 对象。
    • 收到 "event: update",存入 message 对象。
    • 收到 "data: Second message",存入 message 对象。
    • 收到空行 ,再次触发 onmessage 回调!
    • 控制台输出 : New message received: { id: '2', data: 'Second message', event: 'update', ... }

通过这个例子,我们可以看到,无论网络如何分割数据,fetch-event-source 强大的流处理管道都能确保每一条消息都被准确无误地解析和送达。

流程图示

下面是整个流程的序列图,可以帮助你更直观地理解各个部分之间的交互。

sequenceDiagram participant User as 用户 participant FetchTS as fetch.ts (create) participant FetchAPI as fetch API participant ParseTS as parse.ts participant Server as 服务器 User->>FetchTS: fetchEventSource(url, options) activate FetchTS FetchTS->>FetchAPI: fetch(url, options) activate FetchAPI FetchAPI->>Server: HTTP请求 (GET/POST) Server-->>FetchAPI: Response + Stream deactivate FetchAPI FetchAPI-->>FetchTS: Response对象 FetchTS->>User: onopen(response) activate User Note over User: 用户校验响应<br>状态码、Content-Type等 User-->>FetchTS: 正常返回或抛出错误 deactivate User alt 用户校验失败 User->>FetchTS: 抛出错误 FetchTS->>Catch: 进入异常处理 end par 流式数据处理 loop 持续读取流 FetchTS->>ParseTS: getBytes(stream, getLines(getMessages(...))) activate ParseTS ParseTS->>ParseTS: getBytes: 读取chunk ParseTS->>ParseTS: getLines: 拼接/分块 → 生成line ParseTS->>ParseTS: getMessages: 解析line → 组装message alt 收到完整消息(空行) ParseTS->>User: onmessage(message) activate User User-->>ParseTS: deactivate User end ParseTS-->>FetchTS: deactivate ParseTS end and 错误处理监听 Note over FetchTS: 监听abort信号等 end alt 流结束或连接关闭 FetchTS->>User: onclose() FetchTS->>FetchTS: 结束本次连接 end alt 发生错误 FetchTS->>User: onerror(err) activate User User-->>FetchTS: 返回重试延迟或抛出错误 deactivate User alt 需要重试 FetchTS->>FetchTS: setTimeout(重试延迟) Note over FetchTS: 等待后重新开始循环 else 终止流程 FetchTS->>User: Promise.reject(error) FetchTS->>FetchTS: 终止流程 end end deactivate FetchTS
相关推荐
福兮说10 小时前
设计稿是 #4A7C6F,页面量出来是 #4B7C6F:HEX、HSL、透明度、canvas 来回转的七个坑
前端·javascript·css·canvas
郑州光合科技余经理11 小时前
海外版外卖加盟:总站与分站配送规则怎么分开管
java·开发语言·前端·后端·uni-app·php·ai编程
小呆呆66612 小时前
副业搞起来,小说,漫画,漫剧的成本优化思路
前端·后端·面试
Dovis(誓平步青云)12 小时前
浇水提醒刚弹出又消失,植物状态别只存一个百分比
开发语言·前端·javascript·pdf·ecmascript·电脑
码艺-Alimjan12 小时前
Vben Admin 新增维吾尔语 Vben-Modal的关键坑之一
前端·javascript·vue.js
可乐鸡翅yeah_13 小时前
hls.js 手动自定义 http 请求 loader,修改请求头实战
开发语言·前端·javascript·网络协议·http·ecmascript·m3u8在线
IT_陈寒13 小时前
Vite静态资源导入这个坑我帮你们踩过了
前端·人工智能·后端
广州华水科技13 小时前
大坝安全监测解决方案:单北斗GNSS形变监测系统应用与维护
前端
yivifu14 小时前
中文古籍电子书注释集成
前端·javascript·python·beautifulsoup·epub
Hello_wshuo14 小时前
FreeCAD配置MCP使用chatgpt
chatgpt