一个"New Chat"按钮,为什么要重构整个架构?

这是一个真实的线上故障:用户点击"New Chat"按钮后,第一条消息经常发到旧会话里。看起来是个小 bug,实际上暴露了整个多会话管理架构的根本缺陷。

故障现场

用户操作很简单:

  1. 在会话 A 里聊了几轮
  2. 点击"New Chat"按钮
  3. 输入第一句话,回车

预期:消息出现在新会话里

实际:消息跑到会话 A 里了

或者更诡异的情况:

  1. 点"New Chat"
  2. 界面还显示着旧消息
  3. 输入框是灰色的,提示"系统繁忙"

原因是上个会话还在流式输出,把新会话的输入框也锁住了。

根因:三本账本的战争

账本一:服务器生成的 ID

app/(chat)/page.tsx 是服务器组件,每次渲染都会生成一个新的 UUID:

tsx 复制代码
export default function ChatPage() {
  const sessionId = generateUUID(); // 服务器说:这是新会话的 ID
  return <ChatClient initialSessionId={sessionId} />;
}

账本二:客户端组件的状态

components/chat.tsx 维护自己的 currentSessionId

tsx 复制代码
const [currentSessionId, setCurrentSessionId] = useState(initialSessionId);

同时还维护了一个消息列表的副本:

tsx 复制代码
const [messagesState, setMessagesState] = useState<Message[]>([]);
const storeMessages = useChatStore(state => state.messages[currentSessionId]);

useEffect(() => {
  setMessagesState(storeMessages); // 手动同步
}, [storeMessages]);

账本三:Zustand 全局 store

Store 里存着所有会话数据,还有一个全局的 isStreaming 标志:

tsx 复制代码
interface ChatStore {
  sessions: Record<string, Session>;
  messages: Record<string, Message[]>;
  isStreaming: boolean; // 全局共享
}

为什么会出问题

当用户点"New Chat"时:

  1. router.push('/') 触发路由跳转
  2. 服务器生成新 ID:uuid-123
  3. 客户端组件挂载,currentSessionId 被设置为 uuid-123
  4. 但用户快速输入消息,此时 handleSubmit 闭包里捕获的可能还是旧 ID:uuid-456
  5. 消息发到了错误的会话

或者另一种情况:

  1. 会话 A 正在流式输出,isStreaming = true
  2. 用户点"New Chat"切换到会话 B
  3. 会话 B 的输入框检查 isStreaming,发现是 true
  4. 输入框被禁用

三本账本各说各话,系统行为变得不可预测。

重构策略:确立唯一主权

不是修修补补,而是明确回答一个问题:谁拥有 session ID 的生成和管理权?

答案是:客户端

服务器只负责持久化数据,不参与 ID 的生命周期管理。所有状态的变更都以客户端的 currentSessionId 为准。

重构步骤

第一步:ID 主权收归客户端

tsx 复制代码
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const { chatId } = useParams(); // 从 URL 读取

useEffect(() => {
  if (chatId) {
    // URL 有 ID,就用它
    setCurrentSessionId(chatId);
  } else {
    // 没有 ID,客户端自己生成
    const newId = generateUUID();
    setCurrentSessionId(newId);
  }
}, [chatId]);

关键点

客户端在挂载时立即决定用哪个 ID,不等服务器,不依赖 props。URL 是唯一的真相来源。

同时调用 ensureSessionExists(newId),在 store 里创建会话记录(标题"New Chat"、默认模型、私有可见性)。这是原子操作,不会出现"ID 有了但数据还没准备好"的中间状态。

第二步:删除状态副本

直接删掉 messagesState 和同步的 useEffect

tsx 复制代码
// ❌ 删掉
// const [messagesState, setMessagesState] = useState<Message[]>([]);
// useEffect(() => { setMessagesState(storeMessages); }, [storeMessages]);

// ✅ 直接从 store 读
const messages = useChatStore(state => state.getSessionMessages(currentSessionId));

为什么这样做

维护两份数据(store + local state)必然会出现同步问题。useEffect 的执行时机由 React 调度,切换会话时可能延迟,导致 UI 显示旧数据。

现在 getSessionMessages 是一个 selector,从 store 直接读取。Store 更新 → 组件自动重渲染。单向数据流,没有中间状态。

同时 sendChatMessagelib/services/chat-service.ts,64-91 行)直接往 store 写消息:

  1. 添加用户消息
  2. 添加 assistant 占位消息(content 为空)
  3. 流式数据到达时,逐字追加到占位消息

第三步:状态隔离到会话级

把全局的 isStreaming 改成会话级的 streamingStatus

tsx 复制代码
interface Session {
  id: string;
  title: string;
  streamingStatus: 'idle' | 'streaming' | 'awaiting_message';
}

// 新 API
setStreaming(sessionId: string, isStreaming: boolean): void;

使用位置

  • Line 506:handleSubmit 开始时调用 setStreaming(currentSessionId, true)
  • Line 643:流式结束时调用 setStreaming(currentSessionId, false)
  • Line 678:异常处理时也会重置状态

为什么这样做

会话 A 的流式状态不应该影响会话 B。每个会话是独立的状态机。

输入框的禁用逻辑改成:

tsx 复制代码
const status = useChatStore(state => state.sessions[currentSessionId]?.streamingStatus);
<Input disabled={status === 'streaming'} />

第四步:数据流动态绑定

DataStreamHandler 的初始化挪进 useEffect

tsx 复制代码
useEffect(() => {
  if (!currentSessionId) return;
  
  const handler = new DataStreamHandler({
    sessionId: currentSessionId,
    onArtifact: (data) => {
      useChatStore.getState().addArtifact(currentSessionId, data);
    },
    onGuardrail: (data) => {
      useChatStore.getState().addGuardrail(currentSessionId, data);
    }
  });
  
  return () => handler.cleanup(); // 清理旧 handler
}, [currentSessionId]); // 依赖当前 ID

为什么这样做

AI 响应不只有文本,还有 artifacts(代码块)、guardrails(安全检查)、suggestions(推荐问题)。这些数据通过 SSE 流式传输,必须写入正确的会话。

之前 DataStreamHandler 在模块加载时创建,绑定的是静态 ID。切换会话后,文本内容是对的(因为 sendMessage 用的是新 ID),但附加数据还在往旧会话写。

现在 useEffect 监听 currentSessionId,ID 变化时:

  1. 旧 handler 执行 cleanup()
  2. 新 handler 创建,闭包捕获最新 ID
  3. 所有回调都写入正确的会话

修复后的完整流程

用户点"New Chat"会发生什么:

T1 :点击按钮 → router.push('/')

T2 :URL 变成根路径 → chatId 变为 undefined

T3useEffect 检测到 chatId 为空 → 生成新 ID abc-123setCurrentSessionId('abc-123')ensureSessionExists('abc-123') 在 store 创建空会话

T4:用户输入消息,点击发送

T5handleSubmit 执行 → 检查 sessions['abc-123'].streamingStatus'idle'setStreaming('abc-123', true)router.replace('/chat/abc-123') 更新 URL → sendChatMessage({ sessionId: 'abc-123', content: '...' })

T6sendChatMessage 内部 → 添加用户消息 → 添加 assistant 占位 → 发起 API 请求

T7 :SSE 数据到达 → DataStreamHandler 事件触发 → onText 追加文本 → onArtifact 添加代码块 → 都写入 'abc-123'

T8 :流式结束 → setStreaming('abc-123', false)

整个过程中,ID 始终是 'abc-123'。没有竞态,没有数据写错。

对比:重构前后的架构差异

重构前

css 复制代码
服务器生成 ID-A
    ↓
客户端维护 ID-B(可能不同步)
    ↓
handleSubmit 用 ID-B 发送
    ↓
消息写入 store,但 UI 渲染 local state(可能延迟)
    ↓
全局 isStreaming 锁住所有会话
    ↓
DataStreamHandler 绑定静态 ID-C(不会更新)

每一层都可能拿到不同的 ID,状态分散在多个地方。

重构后

markdown 复制代码
客户端生成 ID(唯一真相源)
    ↓
所有操作都用这个 ID
    ↓
Store 是唯一数据源
    ↓
UI 订阅 store(自动更新)
    ↓
会话级状态(互不影响)
    ↓
DataStreamHandler 动态绑定(跟随 ID)

单向数据流,每一层都从上一层获取 ID。

以前的代码在 components/chat.tsx 里这样做:

tsx 复制代码
const [messages, setMessagesState] = useState(initialMessages); 
// ... 
useEffect(() => { 
    if (autoResume) { 
        const latestStoreMessages = getSessionMessages(currentSessionId); 
        if (latestStoreMessages.length > 0) {
            setMessagesState(latestStoreMessages); } } 
        else { setStoreMessages(currentSessionId, []); 
        } 
     }, [...]);

messages 是组件私有 state,而真正的真相还在 useChatStore 里。React 的 useEffect 受调度影响:切换到新会话后,在 effect 将 store 数据写回本地 state 之前,UI 仍然渲染旧的 messages,于是"新对话"短暂显示上一场的内容;如果 effect 被打断或依赖变化顺序不一致,还会出现 store 和 UI 互相覆盖的问题。

现在的版本删掉了这层本地 state,在 components/chat.tsx (lines 145-188) 直接用 selector:

tsx 复制代码
const messages = useChatStore((state) => getSessionMessages(currentSessionId), );

getSessionMessages 自身就是 store 方法,内部返回 sessions.get(id)?.messages ?? [],所以"store 更新 → selector 触发 → React 重渲染",数据流是单向的,没有任何 useEffect 负责同步,自然也就不会出现"短暂显示旧会话"的窗口。

配合这一点,消息的写入全部集中在 lib/services/chat-service.ts 的 sendChatMessage(64-91 行开头):

tsx 复制代码
const userMessage: UIMessage = { ... }; 
addMessage(sessionId, userMessage); 
const assistantMessage: UIMessage = { 
    id: generateUUID(), 
    role: 'assistant', 
    content: '', 
    parts: [], 
}; 
addMessage(sessionId, assistantMessage); 
setStreaming(sessionId, true, assistantMessage.id);

也就是说:

  1. 用户提交表单,sendChatMessage 先 addMessage 一条 role: 'user' 的消息,所以 UI 会立刻显示"我"刚输入的文字。
  2. 紧接着再插入一条 role: 'assistant' 且 content 为空的占位气泡,UI 上会看到一个空的助手消息(即"Thinking...")。
  3. SSE 流回来的每个增量在 for (const line of lines) 里解析后,通过 updateMessage(sessionId, assistantMessage.id, { parts: [...] }) 逐步把文本追加到同一个消息对象上,直到流结束再 setStreaming(sessionId, false)。

整个过程中组件不再维护本地副本,所有变更都通过 store 的 addMessage / updateMessage / setStreaming 完成。这样"新对话"一创建,store 里就是一份干净的消息数组;而 UI 只需要订阅 getSessionMessages,无需等待 useEffect,也就彻底避免了"同步时机"带来的脏读。

三个可复用的架构原则

原则一:客户端拥有 ID 主权

服务器只负责持久化,不参与 ID 的生命周期管理。URL 是唯一真相源,useEffect 监听 URL 参数变化,动态创建或激活实例。

原则二:不维护状态副本

如果你发现自己在写 const [localData, setLocalData] = useState(storeData),停下来想想:为什么需要副本?直接从 store 读,用 selector 或 memoization 优化性能,而不是复制数据。

原则三:异步状态绑定到实例

isLoadingisErrorprogress 不能是全局的。存在实例对象里(instances[id].isLoading),或用 Map 结构(loadingStates.get(id)),总之要能区分"哪个实例在加载"。

任何需要管理"多实例"的系统都会遇到:

多文档编辑器(Google Docs、Notion):在文档 A 输入,内容出现在文档 B

多标签浏览器:在标签 A 提交表单,数据发到标签 B 的 API

游戏房间:在房间 A 发消息,消息出现在房间 B

实时协作白板:在画布 A 画图,笔迹出现在画布 B

核心问题一样:实例 ID 的管理权不清晰,状态归属混乱,数据流绑定是静态的而非动态的

最后一个细节

你可能注意到 handleSubmit 里有:

tsx 复制代码
startTransition(() => {
  router.replace(`/chat/${currentSessionId}`);
});

为什么用 startTransition

因为它告诉 React:"这个更新不着急,别阻塞用户输入"。URL 更新是次要的,消息发送才是主要的。startTransition 让 React 把这次更新标记为"可中断",优先处理用户操作。

这是 React 18 并发特性的典型用法,不起眼但能显著改善体验。

相关推荐
ERIC_s2 小时前
记一次 Next.js + K8s + CDN 缓存导致 RSC 泄漏的排查与修复
前端·react.js·程序员
168清纯女高2 小时前
路由动态Title实现说明(工作问题处理总结)
前端
庙堂龙吟奈我何3 小时前
js中哪些数据在栈上,哪些数据在堆上?
开发语言·javascript·ecmascript
二川bro3 小时前
第30节:大规模地形渲染与LOD技术
前端·threejs
景早3 小时前
商品案例-组件封装(vue)
前端·javascript·vue.js
不说别的就是很菜3 小时前
【前端面试】Vue篇
前端·vue.js·面试
IT_陈寒3 小时前
Java 17实战:我从老旧Spring项目迁移中总结的7个关键避坑点
前端·人工智能·后端
倚肆3 小时前
CSS 动画与变换属性详解
前端·css