s09 - 状态管理
s08 的聊天界面能流式输出、能渲染 Markdown,但它只有一个对话。你一刷新页面,什么都没了。 这一章要做的事情:让应用记住你的对话,支持多个会话,关掉浏览器再打开还在。
这一章要解决什么问题
s08 的状态全靠 useState:
const [messages, setMessages] = useState<Message[]>([]);一个 messages 数组,装着当前对话的所有消息。能用,但有两个问题。
问题一:只有一个对话。 你想开第二个话题,得把当前的消息清掉。想回到之前的对话?没办法。ChatGPT 左边那个会话列表,你这里没有。
问题二:刷新就没了。 useState 是内存状态,页面一刷新全部归零。用户每次打开页面都是一个全新的对话。
你需要两样东西:多会话和持久化。
多会话意味着数据结构要变——不是一个 messages 数组,而是一组会话,每个会话有自己的 messages。持久化意味着要把数据存到 localStorage,页面刷新后能恢复。
这两个需求叠在一起,useState 就撑不住了。不是不能做,而是要做的话你得在组件之间传来传去、用 useEffect 同步 localStorage、用 useCallback 包一堆函数……代码会变得很乱。
是时候引入一个状态管理库了。
为什么选 Zustand
React 生态里状态管理方案很多:Redux、Jotai、Recoil、MobX、Zustand。它们解决同一个问题,但思路不同。
Redux 最老牌,但写一个状态变更要创建 action type、action creator、reducer,三个文件起步。对于我们要做的事情来说,太重了。
Context API 是 React 内置的,不需要装包。但它有一个性能问题:Context 值一变,所有消费这个 Context 的组件都会重新渲染。对于聊天应用这种频繁更新的场景,会导致卡顿。
Zustand 的特点是简单。一个函数创建 store,直接调用,不需要 Provider、不需要 action type、不需要 reducer。整个 store 可以写在一个文件里。
// 这就是一个完整的 Zustand store
import { create } from 'zustand';
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));在任何组件里用 useStore() 就能读取和修改状态。不需要 Context Provider 包裹,不需要 dispatch。这就是我们选它的原因。
安装
npm install zustand数据结构设计
先想清楚要存什么。一个聊天应用的状态大概是这样:
store
├── sessions: Session[] 所有会话
├── currentSessionId: string 当前选中的会话
├── createSession() 新建会话
├── deleteSession(id) 删除会话
├── switchSession(id) 切换会话
├── addMessage(msg) 给当前会话加消息
└── updateLastMessage(content) 更新最后一条消息(流式输出用)每个 Session 的结构:
Session
├── id: string
├── title: string 会话标题(取第一条消息的前 20 个字)
├── messages: Message[]
└── createdAt: number 创建时间戳这个结构的好处是:每个会话独立,切换会话就是换一个 currentSessionId,不需要复制或移动消息。
代码
store/chatStore.ts
这是整个应用的核心。所有状态变更都从这里出发。
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface Message {
role: 'user' | 'assistant';
content: string;
}
export interface Session {
id: string;
title: string;
messages: Message[];
createdAt: number;
}
interface ChatStore {
sessions: Session[];
currentSessionId: string | null;
createSession: () => void;
deleteSession: (id: string) => void;
switchSession: (id: string) => void;
addMessage: (msg: Message) => void;
updateLastMessage: (content: string) => void;
}
export const useChatStore = create<ChatStore>()(
persist(
(set, get) => ({
sessions: [],
currentSessionId: null,
createSession: () => {
const id = crypto.randomUUID();
const newSession: Session = {
id,
title: '新对话',
messages: [],
createdAt: Date.now(),
};
set((state) => ({
sessions: [newSession, ...state.sessions],
currentSessionId: id,
}));
},
deleteSession: (id: string) => {
set((state) => {
const remaining = state.sessions.filter((s) => s.id !== id);
const newCurrentId =
state.currentSessionId === id
? remaining[0]?.id ?? null
: state.currentSessionId;
return { sessions: remaining, currentSessionId: newCurrentId };
});
},
switchSession: (id: string) => {
set({ currentSessionId: id });
},
addMessage: (msg: Message) => {
set((state) => {
const sid = state.currentSessionId;
if (!sid) return state;
const sessions = state.sessions.map((s) => {
if (s.id !== sid) return s;
const title =
s.messages.length === 0 && msg.role === 'user'
? msg.content.slice(0, 20) + (msg.content.length > 20 ? '...' : '')
: s.title;
return { ...s, messages: [...s.messages, msg], title };
});
return { sessions };
});
},
updateLastMessage: (content: string) => {
set((state) => {
const sid = state.currentSessionId;
if (!sid) return state;
const sessions = state.sessions.map((s) => {
if (s.id !== sid) return s;
const msgs = [...s.messages];
if (msgs.length === 0) return s;
msgs[msgs.length - 1] = {
...msgs[msgs.length - 1],
content,
};
return { ...s, messages: msgs };
});
return { sessions };
});
},
}),
{
name: 'chat-sessions',
}
)
);几个关键设计决策
1. 标题自动取自第一条消息。 addMessage 里有一行逻辑:如果这是会话的第一条用户消息,就用它的前 20 个字作为标题。不需要用户手动命名。ChatGPT 也是这么做的。
2. updateLastMessage 用于流式输出。 模型回复是逐字到达的。每次收到一个 token,就把最后一条消息的内容整个替换。虽然看起来每次都覆盖了整个 content,但 React 的 diff 算法只更新变化的 DOM 节点,所以性能没问题。
3. persist 中间件。 这是 Zustand 内置的。一行配置 name: 'chat-sessions',就会自动把整个 store 序列化到 localStorage 的 chat-sessions key 下。页面刷新后自动恢复。不需要手写 useEffect + localStorage.setItem。
4. 删除当前会话时自动切换。 deleteSession 里检查:如果删的是当前会话,就自动切到列表里的第一个。如果列表空了,currentSessionId 就是 null。
components/Sidebar.tsx
侧边栏显示会话列表,可以新建、切换、删除。
'use client';
import { useChatStore } from '../store/chatStore';
function formatTime(ts: number) {
const d = new Date(ts);
const now = new Date();
const isToday =
d.getFullYear() === now.getFullYear() &&
d.getMonth() === now.getMonth() &&
d.getDate() === now.getDate();
if (isToday) {
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
}
return d.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' });
}
export default function Sidebar() {
const sessions = useChatStore((s) => s.sessions);
const currentSessionId = useChatStore((s) => s.currentSessionId);
const createSession = useChatStore((s) => s.createSession);
const switchSession = useChatStore((s) => s.switchSession);
const deleteSession = useChatStore((s) => s.deleteSession);
return (
<div className="w-64 bg-gray-900 text-gray-100 flex flex-col h-screen">
<div className="p-3">
<button
onClick={createSession}
className="w-full py-2 px-3 border border-gray-600 rounded-lg text-sm
hover:bg-gray-800 transition-colors text-left"
>
+ 新对话
</button>
</div>
<div className="flex-1 overflow-y-auto px-2 space-y-1">
{sessions.map((session) => (
<div
key={session.id}
onClick={() => switchSession(session.id)}
className={`group flex items-center justify-between px-3 py-2 rounded-lg
cursor-pointer text-sm transition-colors
${session.id === currentSessionId
? 'bg-gray-700'
: 'hover:bg-gray-800'}`}
>
<div className="flex-1 min-w-0">
<div className="truncate">{session.title}</div>
<div className="text-xs text-gray-500 mt-0.5">
{formatTime(session.createdAt)}
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
deleteSession(session.id);
}}
className="opacity-0 group-hover:opacity-100 ml-2 text-gray-500
hover:text-red-400 transition-opacity text-xs"
>
x
</button>
</div>
))}
</div>
</div>
);
}几个细节:
useChatStore((s) => s.sessions)是 Zustand 的选择器语法。只订阅sessions这个字段的变化。如果不传选择器,任何字段变化都会触发重渲染。- 删除按钮默认
opacity-0,鼠标悬停在会话行上时才显示(group-hover:opacity-100)。 e.stopPropagation()防止点击删除时触发会话切换。
components/ChatArea.tsx
聊天区域的逻辑跟 s08 基本一样,区别是消息来源从 useState 变成了 Zustand store。
'use client';
import { useState, useRef, useEffect } from 'react';
import { useChatStore, Message } from '../store/chatStore';
import MarkdownMessage from './MarkdownMessage';
export default function ChatArea() {
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const sessions = useChatStore((s) => s.sessions);
const currentSessionId = useChatStore((s) => s.currentSessionId);
const createSession = useChatStore((s) => s.createSession);
const addMessage = useChatStore((s) => s.addMessage);
const updateLastMessage = useChatStore((s) => s.updateLastMessage);
const currentSession = sessions.find((s) => s.id === currentSessionId);
const messages = currentSession?.messages ?? [];
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
async function handleSend() {
const text = input.trim();
if (!text || loading) return;
// 如果没有当前会话,先创建一个
if (!currentSessionId) {
createSession();
// createSession 更新了 store,但 Zustand 的更新是同步的
// 下一行就能拿到新的 currentSessionId
}
const userMsg: Message = { role: 'user', content: text };
addMessage(userMsg);
setInput('');
setLoading(true);
// 占位:先加一条空的 assistant 消息
addMessage({ role: 'assistant', content: '' });
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text }),
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let full = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
full += decoder.decode(value, { stream: true });
updateLastMessage(full);
}
} catch (err) {
updateLastMessage('出错了,请重试。');
} finally {
setLoading(false);
}
}
// 空状态:没有会话或会话列表为空
if (!currentSession) {
return (
<div className="flex-1 flex items-center justify-center bg-white">
<div className="text-center text-gray-400">
<div className="text-4xl mb-4">...</div>
<div>点击左侧「新对话」开始</div>
</div>
</div>
);
}
return (
<div className="flex-1 flex flex-col bg-white h-screen">
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-4">
{messages.map((msg, i) => (
<div
key={i}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[70%] rounded-2xl px-4 py-2 ${
msg.role === 'user'
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-900'
}`}
>
{msg.role === 'assistant' ? (
<MarkdownMessage content={msg.content} />
) : (
msg.content
)}
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
<div className="border-t px-4 py-3">
<div className="flex gap-2 max-w-3xl mx-auto">
<input
className="flex-1 border rounded-xl px-4 py-2 text-sm focus:outline-none
focus:border-blue-400"
placeholder="输入消息..."
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSend()}
disabled={loading}
/>
<button
onClick={handleSend}
disabled={loading}
className="bg-blue-500 text-white px-5 py-2 rounded-xl text-sm
hover:bg-blue-600 disabled:opacity-50 transition-colors"
>
发送
</button>
</div>
</div>
</div>
);
}注意 handleSend 里的顺序:先 addMessage 加用户消息,再 addMessage 加一条空的 assistant 消息(占位),然后开始流式读取,每收到一段就 updateLastMessage 覆盖那条占位消息。这个模式跟 s08 一样,只是操作目标从 useState 变成了 Zustand store。
app/page.tsx
把 Sidebar 和 ChatArea 拼起来。
'use client';
import { useEffect } from 'react';
import { useChatStore } from './store/chatStore';
import Sidebar from './components/Sidebar';
import ChatArea from './components/ChatArea';
export default function Home() {
const sessions = useChatStore((s) => s.sessions);
const createSession = useChatStore((s) => s.createSession);
// 首次加载:如果没有会话,自动创建一个
useEffect(() => {
if (sessions.length === 0) {
createSession();
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="flex h-screen">
<Sidebar />
<ChatArea />
</div>
);
}useEffect 的依赖数组是空的,只在组件挂载时执行一次。如果 localStorage 里有持久化的数据,sessions.length 不会是 0,就不会创建新会话。如果是第一次打开(localStorage 为空),就自动创建一个。
没有变化的文件
以下文件跟 s08 完全一样,直接复制:
app/api/chat/route.ts— 流式 API 路由app/components/MarkdownMessage.tsx— Markdown 渲染app/components/CodeBlock.tsx— 代码块高亮app/layout.tsx— 根布局app/globals.css— 全局样式tsconfig.json— TypeScript 配置tailwind.config.ts— Tailwind 配置
package.json
比 s08 多了一个依赖:
{
"name": "agent-chat-s09",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "^14.2.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-markdown": "^9.0.0",
"react-syntax-highlighter": "^15.5.0",
"zustand": "^4.5.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/react": "^18.3.0",
"@types/react-syntax-highlighter": "^15.5.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.4.0"
}
}localStorage 里长什么样
打开浏览器 DevTools -> Application -> Local Storage,你会看到一个 key 叫 chat-sessions,值是这样:
{
"state": {
"sessions": [
{
"id": "a1b2c3d4-...",
"title": "帮我写一个排序算法",
"messages": [
{ "role": "user", "content": "帮我写一个排序算法" },
{ "role": "assistant", "content": "好的,这是一个快速排序的实现:\n\n```python\n..." }
],
"createdAt": 1716902400000
},
{
"id": "e5f6g7h8-...",
"title": "什么是 Zustand",
"messages": [...],
"createdAt": 1716906000000
}
],
"currentSessionId": "e5f6g7h8-..."
},
"version": 0
}persist 中间件自动处理了序列化和反序列化。你不需要写任何 localStorage 的读写代码。
运行
cd code/s09
npm install
npm run dev打开 http://localhost:3000,你会看到左边是侧边栏,右边是聊天区域。试试:
- 发几条消息,观察侧边栏出现会话标题
- 点「新对话」,开始第二个话题
- 在两个会话之间切换,消息各自独立
- 刷新页面,会话还在
- 删一个会话,观察自动切换
Zustand 的 store 是怎么工作的
你可能好奇:为什么不需要 Provider?
Redux 和 Context API 都需要在组件树顶部放一个 Provider,所有子组件通过 Provider 订阅状态。Zustand 不需要。它的 store 是一个独立于 React 组件树的模块。useChatStore 只是一个 hook,它从模块级的变量里读取状态。
Redux / Context:
<Provider store={store}>
<App>
<Sidebar /> ← 通过 Context 读状态
<ChatArea /> ← 通过 Context 读状态
</App>
</Provider>
Zustand:
store (模块级变量)
↑
Sidebar ← 直接调用 useChatStore()
ChatArea ← 直接调用 useChatStore()好处是:不需要包 Provider、不需要 connect、不需要 useContext。任何组件任何时候都能直接读写 store。
代价是:如果你需要在测试里 mock store,需要用 Zustand 提供的 createStore API 替代 create,稍微麻烦一点。但对于我们的场景,这不是问题。
试着改改
1. 给会话加一个「重命名」功能
双击会话标题,变成输入框,回车确认修改。这需要在 store 里加一个 renameSession 方法:
renameSession: (id: string, title: string) => {
set((state) => ({
sessions: state.sessions.map((s) =>
s.id === id ? { ...s, title } : s
),
}));
},2. 限制 localStorage 大小
如果用户聊了很多,localStorage 可能撑爆(大多数浏览器限制 5MB)。在 persist 的配置里加一个 partialize,只持久化最近 20 个会话:
persist(
(set, get) => ({ ... }),
{
name: 'chat-sessions',
partialize: (state) => ({
...state,
sessions: state.sessions.slice(0, 20),
}),
}
)3. 加一个「清空所有会话」按钮
在 Sidebar 底部放一个按钮,点击后调用:
clearAll: () => set({ sessions: [], currentSessionId: null }),4. 观察 persist 的时机
在 store 里加一个订阅,看状态什么时候被写入 localStorage:
useChatStore.subscribe((state) => {
console.log('状态已更新,即将写入 localStorage');
});你会发现每次 set() 调用之后,persist 中间件都会自动同步到 localStorage。
教学边界
这一章只做一件事:用 Zustand 管理多会话状态,并持久化到 localStorage。
不涉及:
- 数据库存储(纯前端方案)
- 会话同步到服务器
- 用户认证
- 消息搜索
- 会话导入导出
- Zustand 的
subscribeWithSelector、devtools等高级中间件
这些都在后面的章节里。
一句话记住
useState 管一个组件的状态,Zustand 管整个应用的状态——当你的状态需要跨组件共享、需要持久化、需要结构化的时候,就该换工具了。