14 — Zustand 状态管理

本章讲解本项目最重要的部分:Zustand Store。
整个应用 80% 的业务逻辑都在这里。读懂它 = 读懂应用。
项目有两个 Store:chatStore.ts(聊天业务)和 themeStore.ts(主题)。


1. 为什么需要状态管理?

1.1 跨组件共享数据的问题

1
2
3
4
5
6
7
8
9
// 问题场景:InputArea 显示输入文字,EmptyState 也想显示输入的文字
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 轻量得多)。

1
npm install zustand    # 已经在项目的 dependencies 里

核心 API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { create } from 'zustand';

// 1. 创建 Store
const useStore = create((set, get) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));

// 2. 在组件中使用
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文件职责
useChatStoresrc/store/chatStore.ts聊天业务(消息、会话、Agent、文件、发送)
useThemeStoresrc/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;

// 方法(actions)
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
// MessageList.tsx
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; // ← Immer 自动转成不可变更新
},
}))
);

项目里真实例子

1
2
3
4
5
6
7
8
9
10
// chatStore.ts - sendMessage 中
chat.messages.push(userMsg); // ← 直接 push!
chat.messages.push(aiMsg);
chat.title = shortTitle(message); // ← 直接赋值!

set({
chatList: [...get().chatList], // ← 但整个 chatList 还是要重新设(触发 React 更新)
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;
// ...

// 创建用户消息 + AI 占位
chat.messages.push(userMsg);
chat.messages.push(aiMsg);

set({ isSending: true });

// 发起 SSE 请求
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 {
/* noop */
}
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 {
/* noop */
}
}

interface ThemeState {
current: ThemeId;
setTheme: (theme: ThemeId) => void;
}

export const useThemeStore = create<ThemeState>((set) => ({
current: readInitialTheme(),
setTheme: (theme) => {
applyTheme(theme); // 1. 立即应用到 DOM
set({ current: theme }); // 2. 更新 Store
},
}));

7.2 主题切换的双重写入

setTheme(theme) 做两件事:

1
2
3
4
5
function setTheme(theme) {
applyTheme(theme); // ① 改 DOM: <html data-theme="paper">
// → CSS [data-theme='paper'] 匹配 → 变量立即生效
set({ current: theme }); // ② 改 Store: Topbar 显示主题名
}

关键洞察:CSS 变量切换是纯 DOM 操作,不需要 React 重渲染就能改变所有组件的颜色(性能极佳)。Store 更新只是为了 UI 显示当前主题名。

7.3 防闪烁:HTML 加载前应用主题

问题:如果 React 启动后才应用主题,浏览器会先看到默认主题闪烁一下。

解决:在 index.html 里加同步脚本:

1
2
3
4
5
6
7
8
9
<!-- index.html -->
<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
// themeStore.ts
export function dispatchOpenTheme() {
window.dispatchEvent(new CustomEvent('dodo:open-theme-switcher'));
}

// Topbar.tsx
import { dispatchOpenTheme } from '@/store/themeStore';
<button onClick={dispatchOpenTheme}>主题</button>

// App.tsx
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
// chatStore.ts
renameChat: (id: string, newTitle: string) => {
const chat = get().chatList.find(c => c.id === id);
if (chat) {
chat.title = newTitle; // Immer 直接修改
set({ chatList: [...get().chatList] });
}
},

场景 2:在 themeStore 加「重置主题」

1
2
3
4
5
// themeStore.ts
resetTheme: () => {
applyTheme(DEFAULT_THEME);
set({ current: DEFAULT_THEME });
},

场景 3:新建独立 Store

1
2
3
4
5
6
7
8
9
10
11
12
// src/store/userStore.ts
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); // 直接改数组,React 不会重新渲染

// ✅ 对的
set({ chatList: [...chatList, newChat] });
// 或用 Immer:get().chatList.push(newChat); set({ chatList: [...get().chatList] });

错误 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
// ❌ 错的:绕过 action
const set = useChatStore(s => s.set);
set({ isSending: true });

// ✅ 对的:通过 action
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