🌌 1. 开场:一个异步请求的奥德赛
想象浏览器是一座剧院,舞台上的演员分别是:
角色 | 职责 | 道具 |
---|---|---|
用户 | 提问 | 键盘 |
AI | 回答 | fetch |
React | 导演 | hooks |
我们 | 剧务 | useChat |
传统写法像即兴表演:
"台词、灯光、音效一起上!"------结果舞台塌了(内存泄漏、竞态、loading 闪烁)。
useChat 则像提前彩排好的百老汇,灯光、走位、提词器,一键到位。
🧰 2. 目录(舞台布置图)
- API 设计:演员的剧本
- 代码剖析:拆台、换幕、打光
- 内存与竞态:后台的幽灵
- 彩蛋:把 useChat 变成"时间旅行机"
- 一键启动:
npm i @ai-chat/use-chat
(假装我们有包)
🧩 3. API 设计:给哈姆雷特一把 TypeScript 的剑
ts
// 用 JSDoc 冒充 TypeScript,浏览器也能看懂
/**
* @param {Object} config
* @param {string} config.api - AI 服务端地址
* @param {number} config.maxHistory - 记忆长度(防止 token 爆炸)
* @returns {{
* messages: Array<Message>,
* send: (text: string) => Promise<void>,
* isLoading: boolean,
* abort: () => void
* }}
*/
🎨 4. 代码剖析:把哈姆雷特拆成乐高
以下代码可直接丢进 Vite / CRA / Next.js 的
src/hooks/useChat.js
js
import { useState, useRef, useEffect, useCallback } from 'react';
export default function useChat({ api, maxHistory = 20 }) {
// 1. 舞台灯光:react 状态
const [messages, setMessages] = useState([]);
const [isLoading, setIsLoading] = useState(false);
// 2. 打光师:控制 fetch 的 AbortController
const abortRef = useRef(null);
// 3. 提词器:真正发送消息的函数
const send = useCallback(async (text) => {
// 3-a 防止空字符串的小丑上场
if (!text.trim()) return;
// 3-b 临时演员:用户消息
const userMsg = { role: 'user', content: text };
setMessages(prev => [...prev, userMsg]);
// 3-c 点亮"加载中"霓虹灯
setIsLoading(true);
// 3-d 新建 AbortController,给幽灵一个名字
abortRef.current = new AbortController();
try {
const res = await fetch(api, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: text, history: messages.slice(-maxHistory) }),
signal: abortRef.current.signal,
});
if (!res.ok) throw new Error(await res.text());
const { reply } = await res.json();
// 3-e 把 AI 放上舞台
setMessages(prev => [...prev, { role: 'assistant', content: reply }]);
} catch (err) {
if (err.name !== 'AbortError') {
// 真正的错误,亮起红灯
console.error(err);
setMessages(prev => [...prev, { role: 'error', content: err.message }]);
}
} finally {
setIsLoading(false);
}
}, [api, messages, maxHistory]);
// 4. 拉闸:观众起哄,演员退场
const abort = useCallback(() => {
abortRef.current?.abort();
setIsLoading(false);
}, []);
// 5. 幕布落下时清理后台幽灵
useEffect(() => {
return () => abortRef.current?.abort();
}, []);
return { messages, send, isLoading, abort };
}
🧠 5. 内存 & 竞态:后台的幽灵
幽灵 | 出没地点 | 驱魔咒语 |
---|---|---|
竞态 | 快速连发两次 send | AbortController |
内存泄漏 | 组件卸载仍在 fetch | useEffect 清理 |
无限历史 | token 爆炸 | slice(-maxHistory) |
⏳ 6. 彩蛋:时间旅行机(undo/redo)
js
// 在 useChat 里再加两行
const [historyStack, setHistoryStack] = useState([]);
const undo = () => setMessages(historyStack.pop());
把 messages
每次深拷贝 push 进栈即可。
"Undo 是前端人的月光宝盒。"
🖼️ 7. 配图:舞台全景
graph TD
A[用户敲字] -->|text| B(useChat.send)
B --> C{fetch 请求}
C -->|成功| D[AI 回复]
C -->|失败/取消| E[错误/abort]
D --> F[setMessages]
E --> F
F --> G[React 重渲染]
style C fill:#f9f,stroke:#333
style G fill:#bbf,stroke:#333
🚀 8. 一键起飞
bash
npm create vite@latest my-ai-chat --template react
cd my-ai-chat
# 把上面的 useChat.js 丢进 src/hooks/
npm run dev
🎭 尾声
useChat 像一支魔法羽毛笔,把异步、竞态、内存三大怪兽关进 React 的生命周期笼子。
今晚,让你的组件也念一段独白:
"To fetch, or not to fetch, that is no longer a question."