ChatGPT 前端集成实战:从 API 调用到性能优化的全链路指南
背景痛点:为什么“直接调一下 API”并不香
第一次把 ChatGPT 塞进项目时,我以为只要 fetch 一把就能收工,结果现实啪啪打脸:
- 首包延迟 2~4 s,用户以为页面卡死
- 一次性返回 3k token 的 Markdown,前端渲染瞬间掉帧
- 刷新页面后对话消失,用户骂“这破玩意儿连记录都不存”
- 连续输入时,前面的请求还没回来,新的又发出去,结果后端限流 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);
}
};
页面卸载前把当前会话写库,刷新后再读出来,用户感知“记录还在”。
性能优化三板斧
-
请求去重 + 节流
用lodash/debounce封装输入框,300 ms 内重复输入只发一次;同时维护Map<string, Promise>做相同问题去重,命中直接返回缓存 -
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);
};
- 骨架屏
用react-loading-skeleton在回答区域先占位,真数据返回后平滑替换,实测 FCP 提升 20%
安全实践:前端也不能“裸奔”
-
敏感数据过滤中间件
在fetch前统一走一层正则,把身份证、手机号打码后再上传,减少合规风险 -
JWT 配额控制
登录后下发 JWT,内部含quota字段,前端每次发请求前判断剩余次数,用完即禁用输入框,避免 429 也节省成本 -
CSP 策略
动态渲染 Markdown 容易引入<script>,配置:
Content-Security-Policy: default-src 'self'; script-src 'none'; style-src 'unsafe-inline' https://cdn.jsdelivr.net
把高亮样式放 CDN,行内样式允许,脚本全部禁止,XSS 攻击面直接砍半
避坑指南:那些踩到怀疑人生的坑
-
CORS 预检
SSE 带自定义头(如Authorization)会触发 OPTIONS,后端记得返回Access-Control-Allow-Origin,否则浏览器直接挂掉 -
内存泄漏
组件卸载时一定reader.cancel()+abortController.abort(),否则 EventSource 长连接常驻,手机端风扇狂转 -
错误边界
用 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 环节,省了自己调模型的麻烦。小白也能顺利跑完,推荐试试。
更多推荐

所有评论(0)