14 — Zustand 状态管理
本章讲解本项目最重要的部分:Zustand Store。
整个应用 80% 的业务逻辑都在这里。读懂它 = 读懂应用。
项目有两个 Store:chatStore.ts(聊天业务)和 themeStore.ts(主题)。
1. 为什么需要状态管理?
1.1 跨组件共享数据的问题
1 2 3 4 5 6 7 8 9
| function App() { return ( <> <EmptyState /> {/* 想显示输入框的文字 */} <InputArea /> {/* 输入框组件 */} </> ); }
|
没有状态管理的解法:层层传 Props(痛苦)。
有状态管理的解法:全局 Store,组件按需订阅。
1.2 状态管理的解法
把共享数据放到一个全局仓库(Store)里:
1 2 3 4 5
| function EmptyState() { const text = useStore(s => s.text); const setText = useStore(s => s.setText); return <p>{text}</p>; }
|
好处:
- ✅ 任何组件直接读写,不需要透传
- ✅ 业务逻辑(Store)和 UI(组件)分离
- ✅ 性能好:组件只订阅自己关心的字段
2. Zustand 是什么?
Zustand = 极简的 React 状态管理库(比 Redux 轻量得多)。
核心 API:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| import { create } from 'zustand';
const useStore = create((set, get) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 })), }));
function Counter() { const count = useStore((s) => s.count); const increment = useStore((s) => s.increment); return <button onClick={increment}>{count}</button>; }
|
3. 项目 Store 架构:两个 Store
项目里有两个独立的 Store:
| Store | 文件 | 职责 |
|---|
useChatStore | src/store/chatStore.ts | 聊天业务(消息、会话、Agent、文件、发送) |
useThemeStore | src/store/themeStore.ts | 主题(6 主题切换、持久化) |
为什么要拆开?
- 关注点分离:主题是 UI 偏好,聊天是业务数据
- 重渲染隔离:切换主题不应该重新跑聊天相关的订阅
- 代码可读性:每个 Store 职责清晰
4. chatStore 详解
文件:src/store/chatStore.ts(约 614 行)
4.1 数据结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| interface ChatState { chatList: Chat[]; currentChatId: string | null; inputMessage: string; selectedFile: File | null; uploadedFileId: string | null; isUploading: boolean; selectedAgent: AgentId; isSending: boolean; connectionError: string | null; confirmDialog: ConfirmDialogState | null;
testConnection: () => Promise<void>; loadChats: () => Promise<void>; selectChat: (id: string) => Promise<void>; createNewChat: () => void; deleteChat: (id: string) => void; }
|
4.2 核心数据类型
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| interface ChatMessage { id: string; role: 'user' | 'assistant'; content: string; timeline?: TimelineItem[]; reference?: ReferenceItem[]; recommend?: string[]; file?: boolean; fileName?: string | null; showTimeline?: boolean; copied?: boolean; pptFile?: string; timestamp: number; }
interface Chat { id: string; title: string; messages: ChatMessage[]; isNew: boolean; agentType?: string; fileid?: string | null; }
|
4.3 怎么读 Store
1 2 3
| const chatList = useChatStore((s) => s.chatList); const isSending = useChatStore((s) => s.isSending);
|
项目里典型用法:
1 2 3 4
| const chatList = useChatStore((s) => s.chatList); const currentChatId = useChatStore((s) => s.currentChatId); const isSending = useChatStore((s) => s.isSending);
|
⚠️ 性能陷阱:避免在选择器里返回新对象(每次都触发重新渲染):
1 2 3 4 5 6
| const data = useChatStore(s => ({ name: s.name, age: s.age }));
const name = useChatStore(s => s.name); const age = useChatStore(s => s.age);
|
4.4 怎么写 Store
1 2 3 4
| const createNewChat = useChatStore((s) => s.createNewChat);
<button onClick={createNewChat}>新对话</button> <button onClick={() => deleteChat(chat.id)}>删除</button>
|
5. Immer 中间件:让状态更新更简单
问题:React 状态要求「不可变更新」,层层 ...spread 啰嗦。
项目用 Immer 中间件,可以「直接修改」:
1 2 3 4 5 6 7 8 9 10 11
| import { immer } from 'zustand/middleware/immer';
const useStore = create( immer((set, get) => ({ chatList: [], updateTitle: (id, title) => { const chat = get().chatList.find(c => c.id === id); if (chat) chat.title = title; }, })) );
|
项目里真实例子:
1 2 3 4 5 6 7 8 9 10
| chat.messages.push(userMsg); chat.messages.push(aiMsg); chat.title = shortTitle(message);
set({ chatList: [...get().chatList], inputMessage: '', isSending: true, });
|
⚠️ 注意:Immer 让内部修改变简单,但整体状态对象还是要用 set 触发 React 更新。
6. chatStore 核心方法
6.1 sendMessage — 发送消息(最复杂)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| async sendMessage() { const state = get(); if (state.isSending || state.isUploading) return;
chat.messages.push(userMsg); chat.messages.push(aiMsg);
set({ isSending: true });
const url = getStreamUrl(state.selectedAgent, hasFile, message, chat.id, fileId); await parseSseStream({ url, signal: abortController.signal, onEvent: (event) => { const target = chat.messages.find(m => m.id === aiMsg.id); if (target) { applyEvent(target, event); set({ chatList: [...get().chatList] }); } }, onDone: () => set({ isSending: false }), }); }
|
关键:
- 闭包变量
currentStreamContent 累积文本(实现打字机效果) - 每次 SSE 事件都
set 一次 → 触发 React 重渲染 → 逐字显示 AbortController 用于停止
6.2 其他常用方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| deleteChat(id) { get().showConfirm({ title: '确认删除', onOk: async () => { await sessionApi.remove(id); }, }); }
async selectChat(id) { set({ currentChatId: id }); const detail = await sessionApi.detail(id); }
async stopMessage() { abortController?.abort(); set({ isSending: false }); }
|
7. themeStore 详解(新增)
文件:src/store/themeStore.ts
7.1 完整代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57
| import { create } from 'zustand';
export type ThemeId = 'ink' | 'minimal' | 'forest' | 'paper' | 'cream' | 'matcha';
export interface ThemeMeta { id: ThemeId; name: string; description: string; swatch: string; mode: 'dark' | 'light'; }
export const THEMES: ThemeMeta[] = [ { id: 'ink', name: '墨韵东方', description: '深墨浓韵,宋画留白', swatch: '#0e0d0b', mode: 'dark' }, { id: 'minimal', name: '极简留白', description: '纯黑灰阶,朱砂点缀', swatch: '#0a0a0a', mode: 'dark' }, { id: 'forest', name: '森绿雅静', description: '深林静谧,苔绿安然', swatch: '#0f1611', mode: 'dark' }, { id: 'paper', name: '素雅米黄', description: '宣纸温润,金色典雅', swatch: '#f5f0e6', mode: 'light' }, { id: 'cream', name: '乳白简约', description: '极致简约,靛蓝克制', swatch: '#fafaf7', mode: 'light' }, { id: 'matcha', name: '抹茶清新', description: '淡雅柔和,自然静谧', swatch: '#e8e6d8', mode: 'light' }, ];
const STORAGE_KEY = 'dodo.theme'; const DEFAULT_THEME: ThemeId = 'ink';
function readInitialTheme(): ThemeId { if (typeof window === 'undefined') return DEFAULT_THEME; try { const t = localStorage.getItem(STORAGE_KEY) as ThemeId | null; if (t && THEMES.some((th) => th.id === t)) return t; } catch { } return DEFAULT_THEME; }
function applyTheme(theme: ThemeId) { if (typeof document === 'undefined') return; document.documentElement.setAttribute('data-theme', theme); try { localStorage.setItem(STORAGE_KEY, theme); } catch { } }
interface ThemeState { current: ThemeId; setTheme: (theme: ThemeId) => void; }
export const useThemeStore = create<ThemeState>((set) => ({ current: readInitialTheme(), setTheme: (theme) => { applyTheme(theme); set({ current: theme }); }, }));
|
7.2 主题切换的双重写入
setTheme(theme) 做两件事:
1 2 3 4 5
| function setTheme(theme) { applyTheme(theme); set({ current: theme }); }
|
关键洞察:CSS 变量切换是纯 DOM 操作,不需要 React 重渲染就能改变所有组件的颜色(性能极佳)。Store 更新只是为了 UI 显示当前主题名。
7.3 防闪烁:HTML 加载前应用主题
问题:如果 React 启动后才应用主题,浏览器会先看到默认主题闪烁一下。
解决:在 index.html 里加同步脚本:
1 2 3 4 5 6 7 8 9
| <script> (function() { try { var t = localStorage.getItem('dodo.theme'); if (t) document.documentElement.setAttribute('data-theme', t); } catch (e) {} })(); </script>
|
用户刷新页面时,HTML 加载完成(CSS 还没解析),脚本立刻从 localStorage 读主题并设置 data-theme,避免闪烁。
7.4 跨组件通信:打开主题面板
Topbar 的主题标签点击要打开 ThemeSwitcher,但 Topbar 和 ThemeSwitcher 是兄弟组件。用 CustomEvent 解决:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| export function dispatchOpenTheme() { window.dispatchEvent(new CustomEvent('dodo:open-theme-switcher')); }
import { dispatchOpenTheme } from '@/store/themeStore'; <button onClick={dispatchOpenTheme}>主题</button>
useEffect(() => { const handler = () => setThemeOpen(true); window.addEventListener('dodo:open-theme-switcher', handler); return () => window.removeEventListener('dodo:open-theme-switcher', handler); }, []);
|
详见 17-移动端适配实战.md 末尾的「跨组件通信」章节。
8. 实战:怎么在 Store 里加新方法
场景 1:在 chatStore 加「重命名会话」
1 2 3 4 5 6 7 8
| renameChat: (id: string, newTitle: string) => { const chat = get().chatList.find(c => c.id === id); if (chat) { chat.title = newTitle; set({ chatList: [...get().chatList] }); } },
|
场景 2:在 themeStore 加「重置主题」
1 2 3 4 5
| resetTheme: () => { applyTheme(DEFAULT_THEME); set({ current: DEFAULT_THEME }); },
|
场景 3:新建独立 Store
1 2 3 4 5 6 7 8 9 10 11 12
| import { create } from 'zustand';
interface UserState { name: string; setName: (name: string) => void; }
export const useUserStore = create<UserState>((set) => ({ name: '', setName: (name) => set({ name }), }));
|
9. 常见误区
错误 1:以为修改了 State 就会自动更新
1 2 3 4 5 6 7
| const chatList = useChatStore(s => s.chatList); chatList.push(newChat);
set({ chatList: [...chatList, newChat] });
|
错误 2:在选择器里返回新对象
1 2 3 4 5
| const data = useChatStore(s => ({ name: s.name, age: s.age }));
const name = useChatStore(s => s.name);
|
错误 3:组件直接调用 set
1 2 3 4 5 6 7
| const set = useChatStore(s => s.set); set({ isSending: true });
const startSending = useChatStore(s => s.startSending); startSending();
|
10. 一段话总结
Zustand = 全局状态仓库。
项目里有两个 Store:
chatStore 聊天业务(消息、SSE、Agent、文件)themeStore 主题(6 主题切换 + 持久化)
读:useStore(s => s.xxx)(避免返回新对象)
写:调用 action(方法)
Immer:让内部修改直接赋值,但整体状态要用 set 触发 React 更新
主题切换:纯 DOM 操作(data-theme 属性)+ Store 更新(显示主题名)
接下来
你已经懂 Zustand 了。下一章看 SSE — AI 流式回复是怎么实现的:15-SSE流式通信.md。
或者直接看新主题系统详解:16-主题系统与ThemeSwitcher.md。