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

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

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

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

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

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

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

  • 学会"让专业的模块干专业的事" :通过剖析 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.valuegetLines 内部的旅程

现在,我们就是 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 Worldid: 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 将二进制数据转换成我们可以理解的 fieldvalue 字符串。
  • 填充 : switch 语句像一个熟练的工人,根据 field 的指示,将 value 准确地填充到 message 对象的相应属性上。
  • 汇报 : 在处理 idretry 的时候,它还会"顺便"调用 onIdonRetry 回调,将关键信息实时同步给 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: 用户校验响应
状态码、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
相关推荐
文心快码BaiduComate2 小时前
“一人即团队”——一句话驱动智能体团队
前端·后端·程序员
我是ed2 小时前
# vue3 实现前端生成水印效果
前端
IAtlantiscsdn2 小时前
Redis7底层数据结构解析
前端·数据结构·bootstrap
小枫编程2 小时前
Spring Boot 与前端文件上传跨域问题:Multipart、CORS 与网关配置
前端·spring boot·后端
uhakadotcom3 小时前
入门教程:如何编写一个chrome浏览器插件(以jobleap.cn收藏夹为例)
前端·javascript·面试
捡芝麻丢西瓜3 小时前
SPM 之 混编(OC、Swift)项目保姆级教程(Swift Package Manager)
前端
我是天龙_绍3 小时前
cdn是个啥?
前端
南雨北斗3 小时前
VSCode三个TS扩展工具介绍
前端
若无_3 小时前
了解 .husky:前端项目中的 Git Hooks 工具
前端·git