前端开发··2 阅读·预计 9 分钟

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 逐块读、TextDecoderstream 参数保证多字节安全、缓冲按行解析 SSE、shallowRef 加请求标识把「逐字失控」收敛成「整帧可控」。它们分别对应「读得对、解得对、渲染得稳、取消得干净」。当流式不再是一条裸 fetch,而是一条可观测、可取消、可竞态守卫的链路时,聊天体验的「顺滑」才真正由代码保证,而不是靠网络运气。

0 评论

评论区

登录 后参与评论