Skip to content

s09 - 状态管理

s08 的聊天界面能流式输出、能渲染 Markdown,但它只有一个对话。你一刷新页面,什么都没了。 这一章要做的事情:让应用记住你的对话,支持多个会话,关掉浏览器再打开还在。

这一章要解决什么问题

s08 的状态全靠 useState

tsx
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 可以写在一个文件里。

tsx
// 这就是一个完整的 Zustand store
import { create } from 'zustand';

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

在任何组件里用 useStore() 就能读取和修改状态。不需要 Context Provider 包裹,不需要 dispatch。这就是我们选它的原因。

安装

bash
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

这是整个应用的核心。所有状态变更都从这里出发。

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

侧边栏显示会话列表,可以新建、切换、删除。

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。

tsx
'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 拼起来。

tsx
'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 多了一个依赖:

json
{
  "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,值是这样:

json
{
  "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 的读写代码。

运行

bash
cd code/s09
npm install
npm run dev

打开 http://localhost:3000,你会看到左边是侧边栏,右边是聊天区域。试试:

  1. 发几条消息,观察侧边栏出现会话标题
  2. 点「新对话」,开始第二个话题
  3. 在两个会话之间切换,消息各自独立
  4. 刷新页面,会话还在
  5. 删一个会话,观察自动切换

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 方法:

ts
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 个会话:

ts
persist(
  (set, get) => ({ ... }),
  {
    name: 'chat-sessions',
    partialize: (state) => ({
      ...state,
      sessions: state.sessions.slice(0, 20),
    }),
  }
)

3. 加一个「清空所有会话」按钮

在 Sidebar 底部放一个按钮,点击后调用:

ts
clearAll: () => set({ sessions: [], currentSessionId: null }),

4. 观察 persist 的时机

在 store 里加一个订阅,看状态什么时候被写入 localStorage:

ts
useChatStore.subscribe((state) => {
  console.log('状态已更新,即将写入 localStorage');
});

你会发现每次 set() 调用之后,persist 中间件都会自动同步到 localStorage。

教学边界

这一章只做一件事:用 Zustand 管理多会话状态,并持久化到 localStorage。

不涉及:

  • 数据库存储(纯前端方案)
  • 会话同步到服务器
  • 用户认证
  • 消息搜索
  • 会话导入导出
  • Zustand 的 subscribeWithSelectordevtools 等高级中间件

这些都在后面的章节里。

一句话记住

useState 管一个组件的状态,Zustand 管整个应用的状态——当你的状态需要跨组件共享、需要持久化、需要结构化的时候,就该换工具了。