源码精读:拆解 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
很快就会显得力不从心:
- 无法携带认证信息 :它不支持自定义 HTTP 请求头(Headers),这意味着我们无法方便地加入
Authorization
Token。 - 孱弱的错误处理:当连接出错时,我们无法获取详细的 HTTP 状态码,很难判断是网络问题还是服务器权限问题。
- 功能受限:只能发送 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
的内部函数,它像一个永不放弃的"任务执行官",负责单次连接的尝试、成功、失败与重试。
整个流程可以概括为以下几个步骤:
-
启动任务 : 调用
fetchEventSource
时,会立即返回一个 Promise,同时create
函数被首次调用,开始第一次连接尝试。 -
尝试连接 (try 块) : 在
create
函数内部,try...catch
结构包裹了单次连接的"快乐路径"。await fetch(...)
: 发起网络请求,建立与服务器的连接。await onopen(...)
: 连接成功后,调用onopen
回调,允许用户校验响应头和状态码。如果校验失败(例如,用户在此抛出异常),流程会立即转入catch
块。await getBytes(...)
: 开始进入我们下面将要深入探索的数据处理管道。await
会在这里暂停,直到数据流正常结束。onclose()
: 如果数据流正常结束,代表连接圆满完成,触发onclose
回调。
-
处理异常 (catch 块) : 如果在
try
块的任何步骤发生错误(网络问题、onopen
抛错等),流程会进入catch
块。- 调用
onerror(...)
: 将错误交给用户提供的onerror
回调处理。 - 决定重试策略 :
onerror
回调的返回值将决定是否以及何时重试。如果返回一个数字(毫秒),setTimeout
就会被安排,在指定延迟后再次调用create
函数,开始下一次尝试。 - 终止流程 : 如果
onerror
回调自己也抛出异常,则认为错误是致命的,整个流程将以 Promisereject
告终。
- 调用
现在,让我们聚焦于 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
需要两个参数:
stream
: 我们传了response.body!
,这是数据来源。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
的变形记
- 在
getBytes
中,它诞生了,名字叫result.value
。它是一个原始的、大小不一的二进制数据块。 getBytes
把它交给了getLines
返回的onChunk
。- 在
onChunk
内部,result.value
(现在叫arr
) 被放入缓冲区,并被切割成了一段或多段更小的数据,名字叫line
。它现在是一行一行的二进制数据。 onChunk
把每一个line
交给了getMessages
返回onLine
。- 在
onLine
内部,line
被最终解析,它的信息(id
,data
等)被用来填充一个message
对象。 - 最终,当一个完整的
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
getBytes
读到Chunk 1
,立即将其交给getLines
返回的onChunk
函数。onChunk
开始处理Chunk 1
:- 它扫描并切割出第一行
"id: 1"
,交给onLine
处理。 - 接着切割出第二行
"data: First message"
,交给onLine
处理。 - 然后切割出一个空行
""
,也交给onLine
。 - 最后,它发现
_id: 2\nev
这一段数据不完整(末尾没有换行符),于是onChunk
将其存入自己的内部缓冲区buffer
,静待下一个数据块。
- 它扫描并切割出第一行
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
getBytes
读到Chunk 2
(ent: update\ndata: Second message\n\n
),再次交给onChunk
。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
。
- 切割出
- 它首先将
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
强大的流处理管道都能确保每一条消息都被准确无误地解析和送达。
流程图示
下面是整个流程的序列图,可以帮助你更直观地理解各个部分之间的交互。
状态码、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