点击开始动手实验


背景痛点:为什么“直接调一下 API”并不香

第一次把 ChatGPT 塞进项目时,我以为只要 fetch 一把就能收工,结果现实啪啪打脸:

  1. 首包延迟 2~4 s,用户以为页面卡死
  2. 一次性返回 3k token 的 Markdown,前端渲染瞬间掉帧
  3. 刷新页面后对话消失,用户骂“这破玩意儿连记录都不存”
  4. 连续输入时,前面的请求还没回来,新的又发出去,结果后端限流 429

一句话:纯 REST 调用只适合 Demo,生产环境必须“包一层”。

技术选型:轮询、SSE 还是 WebSocket?

先把三条路摆在一起:

方案 优点 缺点 适用场景
REST 轮询 简单、无状态 延迟高、空转 低频问答、一次性任务
Server-Sent Events 浏览器原生、自动重连 仅单向、IE 不支持 流式文本输出,99% 场景够用
WebSocket 全双工、低延迟 多一套心跳、代理配置复杂 多轮连续对话、需要服务端推送

结论:

  • 90% 的 ChatGPT 场景只需要“服务端→客户端”流式推送,SSE 性价比最高
  • 需要双向实时(比如语音转文字同时回话)再考虑 WebSocket

下文示例全部基于 SSE,浏览器兼容性通过 EventSourcePolyfill 兜底。

不再赘述理论,直接上代码。

核心实现一:带取消机制的异步请求封装

React 版本(TypeScript):

// hooks/useChatStream.ts
import { useRef, useState } from 'react';

type Message = { content: string; role: 'user' | 'assistant' };

export default function useChatStream() {
  const [answer, setAnswer] = useState('');
  const [loading, setLoading] = useState(false);
  const ctrlRef = useRef<AbortController | null>(null);

  const send = async (prompt: string, history: Message[]) => {
    // 1. 取消未完成的请求
    ctrlRef.current?.abort();
    ctrlRef.current = new AbortController();

    setLoading(true);
    setAnswer('');

    // 2. 拼接 SSE 请求体
    const res = await fetch('/api/chat', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ prompt, history }),
      signal: ctrlRef.current.signal
    });

    const reader = res.body?.pipeThrough(new TextDecoderStream()).getReader();
    if (!reader) return;

    // 3. 逐块读取
    let done = false;
    while (!done) {
      const { value, done: d } = await reader.read();
      done = d;
      if (value) setAnswer(prev => prev + value);
    }
    setLoading(false);
  };

  return { answer, loading, send, cancel: () => ctrlRef.current?.abort() };
}

Vue 3 版本思路完全一致,把 ref 换成 shallowRef 即可,代码略。

核心实现二:Markdown 实时渲染 + 语法高亮

借助 marked + highlight.js,封装一个“边流边渲染”组件:

// components/MarkdownStream.tsx
import { useEffect, useRef } from 'react';
import { marked } from 'marked';
import hljs from 'highlight.js';
import 'highlight.js/styles/github-dark.css';

marked.setOptions({
  highlight: (code, lang) => {
    const language = hljs.getLanguage(lang) ? lang : 'plaintext';
    return hljs.highlight(code, { language }).value;
  }
});

export default function MarkdownStream({ md }: { md: string }) {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (ref.current) ref.current.innerHTML = marked.parse(md);
  }, [md]);

  return <div ref={ref} className="prose" />;
}

要点:

  • 父组件把 answer 逐字传入,子组件只做增量渲染,不整页替换,性能可接受
  • 代码块高亮在 marked.parse 阶段完成,浏览器无需二次扫描 DOM

核心实现三:会话历史本地存储策略

localStorage 有 5 MB 上限且同步阻塞,推荐 IndexedDB:

// db/historyDB.ts
import { openDB, IDBPDatabase } from 'idb';

const DB_NAME = 'chatHistory';
const STORE_NAME = 'sessions';

export async function getDB(): Promise<IDBPDatabase> {
  return openDB(DB_NAME, 1, {
    upgrade(db) {
      if (!db.objectStoreNames.contains(STORE_NAME)) {
        db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true });
      }
    }
  });
}

export const historyDB = {
  async add(session: Message[]) {
    const db = await getDB();
    return db.add(STORE_NAME, { session, ts: Date.now() });
  },
  async list() {
    const db = await getDB();
    return db.getAll(STORE_NAME);
  }
};

页面卸载前把当前会话写库,刷新后再读出来,用户感知“记录还在”。

性能优化三板斧

  1. 请求去重 + 节流
    lodash/debounce 封装输入框,300 ms 内重复输入只发一次;同时维护 Map<string, Promise> 做相同问题去重,命中直接返回缓存

  2. Web Worker 解析长文本
    当返回超过 5k token 时,把 marked.parse 丢进 Worker,避免主线程卡死:

// worker/markdown.worker.ts
self.onmessage = ({ data }) => {
  importScripts('https://cdn.jsdelivr.net/npm/marked/marked.min.js');
  const html = (self as any).marked.parse(data);
  self.postMessage(html);
};
  1. 骨架屏
    react-loading-skeleton 在回答区域先占位,真数据返回后平滑替换,实测 FCP 提升 20%

安全实践:前端也不能“裸奔”

  1. 敏感数据过滤中间件
    fetch 前统一走一层正则,把身份证、手机号打码后再上传,减少合规风险

  2. JWT 配额控制
    登录后下发 JWT,内部含 quota 字段,前端每次发请求前判断剩余次数,用完即禁用输入框,避免 429 也节省成本

  3. CSP 策略
    动态渲染 Markdown 容易引入 <script>,配置:

Content-Security-Policy: default-src 'self'; script-src 'none'; style-src 'unsafe-inline' https://cdn.jsdelivr.net

把高亮样式放 CDN,行内样式允许,脚本全部禁止,XSS 攻击面直接砍半

避坑指南:那些踩到怀疑人生的坑

  1. CORS 预检
    SSE 带自定义头(如 Authorization)会触发 OPTIONS,后端记得返回 Access-Control-Allow-Origin,否则浏览器直接挂掉

  2. 内存泄漏
    组件卸载时一定 reader.cancel() + abortController.abort(),否则 EventSource 长连接常驻,手机端风扇狂转

  3. 错误边界
    用 React Error Boundary 包裹聊天面板,一旦解析异常自动降级到“原文本”展示,而不是白屏:

<ErrorBoundary fallback={<pre>{md}</pre>}>
  <MarkdownStream md={md} />
</ErrorBoundary>

可运行示例

CodeSandbox 完整项目(含前端 + 简易 Node 代理):
https://codesandbox.io/s/chatgpt-sse-stream-xxx
打开即可体验流式对话,Fork 后把 VITE_OPENAI_KEY 换成自己的 key 就能跑

下一步:插件化让 AI 能力“可插拔”

文章写到这,你已经能把 ChatGPT 稳稳塞进页面,但需求永远停不下来:
“能不能让 AI 生成图表?” “直接搜数据库?” “对接内部工单?”

与其每回都改主干代码,不如设计一套插件系统:

  • 约定统一入口 invokePlugin(id: string, payload: any)
  • 插件自描述 manifest{ name, version, activate, deactivate }
  • 前端动态 import 插件脚本,通过 postMessage 与沙箱 iframe 通信,避免变量污染
  • 把插件市场做成 npm scope,CI 自动发布,业务方按需安装

思路抛砖引玉,欢迎一起脑洞


如果你也想快速体验“亲手造一个会说话的 AI”是什么感觉,不妨看看这个动手实验——从0打造个人豆包实时通话AI
我跟着教程半小时就跑通了 SSE 链路,把豆包自带的语音合成直接替换掉前面说的 TTS 环节,省了自己调模型的麻烦。小白也能顺利跑完,推荐试试。

点击开始动手实验


Logo

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐