Vue 中接入 ChatGPT 流式响应的状态治理:从 SSE 断流到响应式更新的完整链路
引言
接入 ChatGPT 的流式响应,很多人把力气花在「怎么拿到 data: 前缀的那串 SSE」,却忽略了更难的部分:流怎么进 Vue 的响应式系统。逐字到来的 token 如果直接写进一个深层 reactive 对象,每一次推进都会触发一次依赖追溯和视图重算。本文用一条完整的流式会话链路,讲清读取、取消、渲染三处关键决策。
一、把 ReadableStream 逐块读成可暂停的循环
浏览器的 fetch 返回 response.body(一个 ReadableStream),SSE 文本需要手动按块解析。反例是用一次性 await response.text(),它等价于等所有 token 到齐才渲染,流式形同虚设。
// 反例:等待完整响应,流式退化成普通请求
const res = await fetch('/api/chat', { method: 'POST', body });
const full = await res.json(); // 所有 token 拼完才拿到,丧失逐字体验
// 正例:用 getReader 逐块读取,边读边消费
const res = await fetch('/api/chat', { method: 'POST', body });
const reader = res.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
handleChunk(chunk); // 消费这段增量文本
}
关键在于 decoder.decode(value, { stream: true }):多字节字符(如中文)可能被切断在一个块边界,stream: true 让解码器缓存不完整的字节,等下一块补全。漏掉这个参数,中文会被拆成乱码。
二、解析 SSE 用「缓冲+按行」而非按块
SSE 的 data: 事件不一定正好落在一个网络块里。直接把每块当完整事件解析,是流式解析最常犯的错。
// 反例:假设每个 chunk 恰好是一条完整 SSE 事件
function handleChunk(chunk: string) {
chunk.split('\n').forEach((line) => {
if (line.startsWith('data:')) append(line.slice(5));
});
}
当网络把一个 data: 事件拆成两半传输,上面的代码会把一个 JSON 片段当成独立事件,后续拼接必然错位。
// 正例:维护一个 buffer,按「完整行」才出队
let buffer = '';
function handleChunk(chunk: string) {
buffer += chunk;
let idx;
while ((idx = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, idx);
buffer = buffer.slice(idx + 1);
if (line.startsWith('data:')) append(line.slice(5).trim());
if (line.startsWith('data: [DONE]')) finish();
}
}
只有凑齐换行符才算一个完整事件,越界的内容留在 buffer 等待下一块。这是流式解析不可省略的一层。
三、用 shallowRef 避免逐字触发的重渲染风暴
拿到 token 后,最考验 Vue 功底的是「写到哪」。逐字 push 进深层代理数组,代价是每来一个字都触发一次依赖追踪与 diff。
// 反例:每个 token 都 push 进 reactive 数组,逐字触发更新
const state = reactive({ messages: [] as string[] });
function append(token: string) {
state.messages.push(token); // 一个字一次依赖通知
}
// 正例:用 shallowRef 持有累积文本,只在整体替换时触发
const reply = shallowRef('');
function append(token: string) {
// 内部拼字符串是普通操作,不触发响应式
reply.value += token;
}
shallowRef 只对 .value 的整体赋值做响应式追踪,字符串拼接不触发。但 .value += token 仍会触发一次更新,更好的做法是在渲染层用 computed 只暴露一次引用,或干脆用「节流批处理」把多次 token 合并到一帧。
四、AbortController 与竞态守卫:让取消和乱序都可控
流式请求的两个经典事故:用户切换会话后旧流仍在写入;组件卸载后回调还在操作已销毁的 ref。二者都需要显式的取消与竞态标记。
import { onBeforeUnmount } from 'vue';
let controller: AbortController | null = null;
async function send(userInput: string) {
controller?.abort(); // 取消上一个未完成的流
const current = controller = new AbortController();
const requestId = Symbol(); // 竞态守卫:标记本次请求
try {
const res = await fetch('/api/chat', {
signal: current.signal,
body: JSON.stringify({ text: userInput })
});
const reader = res.body!.getReader();
while (true) {
const { value, done } = await reader.read();
if (done || requestId !== current.requestId) break; // 已过期,丢弃
reply.value += new TextDecoder().decode(value, { stream: true });
}
} catch (e) {
if ((e as Error).name === 'AbortError') return; // 主动取消,不算错误
throw e;
}
}
onBeforeUnmount(() => controller?.abort());
symbol 竞态标注确保「晚发出的请求」不会覆盖「早发出但仍在流式」的结果;onBeforeUnmount 兜底取消,避免组件销毁后仍有写入。这条链路上,取消不是可选项,而是正确性的前提。
结语
Vue 接 ChatGPT 流式响应,四件事缺一不可:getReader 逐块读、TextDecoder 的 stream 参数保证多字节安全、缓冲按行解析 SSE、shallowRef 加请求标识把「逐字失控」收敛成「整帧可控」。它们分别对应「读得对、解得对、渲染得稳、取消得干净」。当流式不再是一条裸 fetch,而是一条可观测、可取消、可竞态守卫的链路时,聊天体验的「顺滑」才真正由代码保证,而不是靠网络运气。
评论区
登录 后参与评论