前端如何优雅地“边聊边等”——用 Fetch 实现流式请求大模型

274 阅读4分钟

前端如何优雅地“边聊边等”——用 Fetch 实现流式请求大模型

作者:你们的前端课代表
场景:ChatGPT 爆火,老板要求“打字机”效果,后端说“我流式返回”,你说“好嘞”!


一、为什么“流式”突然成了刚需?

传统接口:一次请求→全部数据→JSON.parse()→渲染,用户盯着白屏干等。
大模型接口:一次请求→源源不断的 token→像打字机一样逐字蹦出来,体验拉满。
核心原理:HTTP Transfer-Encoding: chunked,后端把响应拆成一块块往前端“流”,前端边收边渲染


二、Fetch 也能“流”?——先补 3 个冷知识

知识点一句话记忆代码提示
1. 响应体是 ReadableStreamresponse.body 不是字符串,而是一条“水管”const reader = response.body.getReader()
2. 读取器是异步迭代器while(true) 逐块拿 Uint8Arrayawait reader.read()
3. 解码器 TextDecoder把二进制流变成人能看的字符串new TextDecoder().decode(chunk)

三、最简“裸奔”版:30 行看懂流式 Fetch

async function streamChat(prompt) {
  const response = await fetch('/api/chat', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ prompt })
  });

  const reader = response.body.getReader();     // 拿水管
  const decoder = new TextDecoder();           // 拿解码器
  let buffer = '';                             // 行缓冲(SSE 格式)

  while (true) {
    const { done, value } = await reader.read(); // 等待下一块
    if (done) break;

    buffer += decoder.decode(value, { stream: true }); // 注意 stream:true 保留末尾残缺字符
    const lines = buffer.split('\n');                  // 按行切
    buffer = lines.pop()!;                             // 最后一行可能不完整,留到下一轮

    for (const line of lines) {
      if (line.startsWith('data: ')) {
        const data = line.slice(5);                    // 去掉 "data: "
        if (data === '[DONE]') return;                 // 约定结束标记
        console.log(JSON.parse(data).content);         // 逐 token 渲染
      }
    }
  }
}

跑起来后,控制台就像打字机一样“哒哒哒”——成就感 +1


四、生产级封装:既要好用,又要能“踩刹车”

裸奔代码只能做 demo,线上还要考虑:

  1. 随时中断(用户说“停!”)
  2. 自动重连(网络抖动)
  3. 兼容两种格式(纯 data: {...}\n\n 或 SSE)
  4. 友好错误(超时/4xx/5xx)

上代码——streamFetcher.ts,复制就能用:

type StreamOptions = {
  url: string;
  body: Record<string, any>;
  method?: 'GET' | 'POST';
  headers?: Record<string, string>;
  signal?: AbortSignal;              // 外部中断
  onMessage: (data: any) => void;    // 收到一包数据
  onDone?: () => void;               // 正常结束
  onError?: (err: any) => void;      // 异常
};

export function streamFetcher({
  url,
  body,
  method = 'POST',
  headers = {},
  signal,
  onMessage,
  onDone,
  onError
}: StreamOptions) {
  let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;

  const abort = () => reader?.cancel().catch(() => {}); // 温柔关闭
  signal?.addEventListener('abort', abort);

  fetch(url, { method, headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal })
    .then(async res => {
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      if (!res.body) throw new Error('No stream body');

      reader = res.body.getReader();
      const decoder = new TextDecoder();
      let buf = '';

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        buf += decoder.decode(value, { stream: true });
        const lines = buf.split('\n');
        buf = lines.pop()!;

        for (const line of lines) {
          if (line.startsWith('data: ')) {
            const payload = line.slice(6);
            if (payload === '[DONE]') { onDone?.(); return; }
            try { onMessage(JSON.parse(payload)); } catch {}
          }
        }
      }
      onDone?.();
    })
    .catch(err => {
      if (err.name !== 'AbortError') onError?.(err);
    })
    .finally(() => {
      signal?.removeEventListener('abort', abort);
    });

  // 返回一个“手柄”,外部可主动 cancel
  return () => {
    signal?.abort();
    abort();
  };
}

使用姿势:

const cancel = streamFetcher({
  url: '/api/chat',
  body: { prompt: '把 fetch 讲成段子' },
  onMessage: ({ content }) => appendToDom(content),
  onError: e => toast.error('网络开小差:' + e.message)
});

// 用户点击“停止”按钮
stopBtn.onclick = () => cancel();

五、踩坑备忘录

表现解药
中文被“腰斩”出现乱码TextDecoder({stream:true}) 必须加
后端突然断连reader.read() 直接 doneonDone 里给用户提示“回答已结束”
用户狂点重发旧流还在输出每次新请求先 cancel() 旧手柄
nginx 缓冲迟迟不吐数据X-Accel-Buffering: noTransfer-Encoding: chunked

六、和其他“实时”技术比比个子

技术传输方向优点缺点适合场景
Fetch 流(本文)服务端→客户端基于 HTTP,零依赖、跨域友好、浏览器默认支持只能服务端单向推大模型打字机、下载进度条
EventSource (SSE)服务端→客户端自动重连、浏览器自带 onmessage仅 GET、仅单向、IE 全灭股票行情、活动推送
WebSocket双向全双工、低延迟需额外端口、代理/网关配置复杂聊天室、多人协同
长轮询双向模拟兼容性最好延迟高、浪费连接老系统兼容、问卷投票

一句话总结:
“打字机”读大模型 → Fetch 流最轻;双向实时对战 → WebSocket 最稳;只推不拉 → SSE 最省事。


七、总结(省流版)

  1. response.body.getReader() 就是水龙头,边读边渲染就能实现打字机。
  2. 记得用 TextDecoder({stream:true}) 防止中文被砍半。
  3. 封装时把 AbortSignal 暴露出去,随时可 cancel,避免“鬼打印”。
  4. 纯推送场景选 SSE,双向实时选 WebSocket,大模型流式输出 Fetch 足够香。

把这段代码丢进项目,老板再提“像 ChatGPT 那样”的需求时,你就可以优雅地回一句:
安排,已经封装好了,两分钟上线。