第四章:效能優化:複雜歷史對話紀錄的狀態管理 (Zustand) 與快取機制
當你的 AI 聊天室不只是像上一章那樣「一問一答」,而是需要記住上下文、並且支援切換不同的「聊天群組 (Chat Sessions)」時,原本簡單的 `useState` 就會瞬間崩潰。
如果把所有的對話資料、載入狀態、歷史紀錄全部塞在一個 Component 裡,只要一個狀態改變,整個頁面就會瘋狂重新渲染 (Re-render),甚至導致你的打字機特效卡頓。
本章將帶你使用 React 圈目前最受歡迎的輕量級全域狀態管理庫 Zustand,來打造高效能的對話紀錄管理系統。
為什麼不用 Redux 或 Context API?
在過去,面對這種跨元件的狀態,我們通常有兩個選擇:
- Context API:容易引發嚴重的「渲染地獄」。當 Provider 的值改變時,裡面所有的子元件都會無腦重新渲染,對 Streaming 這種每 10 毫秒就更新一次狀態的情境來說是效能災難。
- Redux:太重了。你需要寫一堆 Action, Reducer, Dispatch 樣板程式碼,大幅降低 Vibe Coding 的開發速度。
Zustand 的優勢在於:極簡、不依賴 Context Provider、而且支援非常精準的「選取器 (Selectors)」以避免不必要的渲染。
步驟一:定義狀態商店 (Store)
首先安裝 Zustand:`npm install zustand`
建立一個 `src/store/useChatStore.ts` 檔案。這個 Store 就是我們聊天室的大腦:
```typescript import { create } from 'zustand'
export type Message = { id: string role: 'user' | 'assistant' content: string }
export type ChatSession = { id: string title: string messages: Message[] }
interface ChatState { sessions: ChatSession[] activeSessionId: string | null
// 動作 (Actions) setActiveSession: (id: string) => void addMessage: (sessionId: string, msg: Message) => void updateLastMessage: (sessionId: string, chunk: string) => void createNewSession: () => string }
export const useChatStore = create
// 切換當前正在聊天的視窗 setActiveSession: (id) => set({ activeSessionId: id }),
// 開啟新對話 createNewSession: () => { const newId = crypto.randomUUID() set((state) => ({ // 將新對話推到陣列最前面 sessions: [{ id: newId, title: '新的探索', messages: [] }, ...state.sessions], activeSessionId: newId })) return newId },
// 增加完整的新訊息 addMessage: (sessionId, msg) => set((state) => ({ sessions: state.sessions.map(session => session.id === sessionId ? { ...session, messages: [...session.messages, msg] } : session ) })),
// 更新最後一則訊息 (這專門為了 Streaming 打字機設計的效能最佳化寫法!) updateLastMessage: (sessionId, chunk) => set((state) => ({ sessions: state.sessions.map(session => { if (session.id !== sessionId) return session;
const lastMsg = session.messages[session.messages.length - 1];
if (!lastMsg || lastMsg.role !== 'assistant') return session;
// 複製一份陣列,然後把新的字串片段「黏」到最後一則訊息的內容後面
const newMessages = [...session.messages];
newMessages[newMessages.length - 1] = {
...lastMsg,
content: lastMsg.content + chunk
};
return { ...session, messages: newMessages };
})
})), })) ```
步驟二:在元件中精準取用狀態
Store 建好了,我們來看看如何在 UI 裡面使用它。Zustand 最強大的地方在於,你可以「只拿你需要的東西」,這樣當其他東西改變時,這個元件就不會被牽連而重新渲染。
例如,我們有一個左側邊欄負責顯示「歷史紀錄列表」:
```tsx "use client" import { useChatStore } from "@/store/useChatStore" import { PlusCircle, MessageSquare } from "lucide-react"
export function Sidebar() { // 這裡我們精準提取需要的狀態和動作 // 這樣當 messages 更新打字機動畫時,Sidebar 不會跟著重新渲染! const sessions = useChatStore(state => state.sessions) const activeSessionId = useChatStore(state => state.activeSessionId) const setActiveSession = useChatStore(state => state.setActiveSession) const createNewSession = useChatStore(state => state.createNewSession)
return (
<div className="flex-1 overflow-y-auto mt-2 space-y-2 pr-2 custom-scrollbar">
{sessions.map(s => (
<div
key={s.id}
onClick={() => setActiveSession(s.id)}
className={\`flex items-center gap-3 p-3 rounded-xl cursor-pointer transition-all \${
activeSessionId === s.id
? 'bg-blue-600 font-bold shadow-lg shadow-blue-500/30'
: 'hover:bg-white/5 text-white/70'
}\`}
>
<MessageSquare className="w-4 h-4 opacity-50" />
<span className="truncate flex-1">{s.title}</span>
</div>
))}
</div>
</div>
) } ```
這段程式碼的巧妙之處在於:當你在右邊的對話框裡面享受 Streaming 打字機特效時(觸發 `updateLastMessage`),左側的 Sidebar 完全不會重新渲染!
結合 localStorage 做到資料持久化 (Persist)
更狂的是,Zustand 內建了強大的 Middleware。 想像一下,使用者正在進行一段極具價值的 AI 對話,不小心按到了 F5 重新整理,如果聊天紀錄全都不見了,他一定氣炸。 我們只要在 `useChatStore.ts` 中稍微包一層 `persist`,就能全自動把資料存進瀏覽器的 localStorage:
```typescript import { create } from 'zustand' import { persist } from 'zustand/middleware'
export const useChatStore = create
就加這五行字!現在,無論使用者怎麼重整網頁、甚至關閉分頁明天再打開,他之前的「所有對話紀錄」與「聊天群組」,都會跟施了魔法一樣完美還原在畫面上。
透過 Zustand 的全域狀態管理與 Persist 快取,我們不依賴龐大的資料庫讀寫,就打造出了極度流暢且具備本地記憶功能的 AI 聊天室核心!這不僅大幅降低了伺服器資料庫的連線成本,也帶來了閃電般快速的使用者體驗。
💾 歷史對話的資料庫設計
Supabase Schema
CREATE TABLE conversations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) NOT NULL,
title TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE messages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
conversation_id UUID REFERENCES conversations(id) ON DELETE CASCADE,
role TEXT CHECK (role IN ('user', 'assistant', 'system')),
content TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 索引:加速查詢
CREATE INDEX idx_messages_conversation ON messages(conversation_id, created_at);
CREATE INDEX idx_conversations_user ON conversations(user_id, updated_at DESC);
快取策略
| 資料 | 快取位置 | 到期時間 | 原因 | |------|---------|:--------:|------| | 最近 10 筆對話列表 | Zustand | 永不(頁面層級) | 使用者常切換 | | 單一對話的完整訊息 | Zustand | 永不(頁面層級) | 使用中需要反覆查看 | | 歷史對話列表(超過 10 筆) | React Query | 5 分鐘 | 不常查看 | | 已封存的對話 | React Query | 30 分鐘 | 很少存取 |
串接 OpenAI Streaming API
為什麼需要 Streaming?
LLM 的回應需要幾秒鐘。如果等完整回應才顯示,使用者要盯著空白畫面等 3-5 秒。Streaming 讓 AI 邊產生邊顯示——使用者在 1 秒內就能看到第一個字。
OpenAI Streaming
from openai import OpenAI
client = OpenAI()
stream = client.chat.completions.create(
model='gpt-4',
messages=[{'role': 'user', 'content': '寫一篇短文'}],
stream=True, # 啟用串流
)
for chunk in stream:
if chunk.choices[0].delta.content is not None:
print(chunk.choices[0].delta.content, end='')
# 透過 WebSocket 傳送給前端
await websocket.send_text(chunk.choices[0].delta.content)
下一章預告:完整 WebSocket 實戰
學會 Streaming 之後,下一章整合 WebSocket + AI Streaming——建立完整的即時 AI 對話應用。
解鎖完整教學內容
本章為付費內容。加入專案即可解鎖超過 5000 字的深度解析,包含 10 個以上神級 Prompt 與真實 Source Code 範例!