Chatbot UI 全局变量自定义实战:从原理到最佳实践
面向人群:已经能独立搭 React 组件、却总在“状态到底放哪”上纠结的中级前端同学
1. 背景:为什么全局变量总在 Chatbot 里翻车
做 Chatbot 界面时,我们很容易陷入“Props 地狱”:
- 左侧会话列表、右侧消息区、顶部工具栏都要知道当前
sessionId; - 底部输入框一打字,就要把
typing状态同步到全局标题栏; - 再来一个“夜间模式”开关,三个组件都要响应,结果一传 props 传五层,调试时翻组件树翻到怀疑人生。
更糟的是,Chatbot 的业务状态天然“高频+异步”:用户一句话可能触发 ASR、LLM、TTS 三条异步流,如果全局变量设计随意,就会出现:
- 多组件状态不同步,A 组件已切换会话,B 组件还在拉旧消息;
- 直接在 Context 里改引用,导致 React DevTools 跳变追踪失灵;
- 页面刷新后草稿消息全丢,用户骂娘。
一句话:没有一套“可预测、可调试、可持久化”的全局变量方案,Chatbot UI 的复杂度会指数级爆炸。
2. 技术选型:Context 还是 Redux?
先给出结论速查表:
| 维度 | React Context + useReducer | Redux(@reduxjs/toolkit) |
|---|---|---|
| 样板代码 | 少,原生 Hook 即可 | 多,需 configureStore、slice |
| 性能隐患 | 大对象时容易连带渲染 | 依赖 selector 可精准订阅 |
| 调试体验 | 依赖 eslint-plugin-react-hooks | DevTools 时间旅行爽翻 |
| 异步流 | 自己写 middleware 或 useEffect | 直接集成 thunk / RTK Query |
| 包体积 | 0 额外 kb | ~18kb(gzip) |
| 学习成本 | 低 | 中 |
建议:
- 原型阶段、状态形状简单(只有
sessionId、theme、messages三个字段)→ Context 足够; - 需要跨标签页同步、或未来做协同编辑 → 直接上 Redux,后续搭配 redux-persist、redux-state-sync 插件更省心。
下文给出两套可抄作业的代码,你可以按项目阶段随时迁移。
3. 方案 A:轻量级 Context + 自定义 Hook
3.1 目录结构
src/ └─ store/ ├─ ChatbotContext.tsx // 创建上下文 ├─ chatbotReducer.ts // 纯函数 ├─ useChatbot.ts // 自定义 hook └─ index.ts // 统一导出3.2 类型定义(TypeScript)
// chatbotReducer.ts export interface ChatbotState { sessionId: string; theme: 'light' | 'dark'; draft: string; // 当前输入框草稿 messages: Array<{ id: string; text: string; role: 'user' | 'bot'; }>; } export type ChatbotAction = | { type: 'SET_SESSION'; payload: string } | { type: 'SET_THEME'; payload: 'light' | 'dark' } | { type: 'SET_DRAFT'; payload: string } | { type: 'ADD_MESSAGE'; payload: ChatbotState['messages'][0] };3.3 纯函数 reducer
export function chatbotReducer( state: ChatbotState, action: ChatbotAction ): ChatbotState { switch (action.type) { case 'SET_SESSION': return { ...state, sessionId: action.payload }; case 'SET_THEME': return { ...state, theme: action.payload }; case 'SET_DRAFT': return { ...state, draft: action.payload }; case 'ADD_MESSAGE': return { ...state, messages: [...state.messages, action.payload] }; default: return state; } }3.4 创建 Context & Provider
// ChatbotContext.tsx import React, { createContext, useReducer, useContext, ReactNode } from 'react'; import { chatbotReducer, ChatbotState, ChatbotAction } from './chatbotReducer'; const initial: ChatbotState = { sessionId: 'default', theme: 'light', draft: '', messages: [], }; export const ChatbotCtx = createContext<{ state: ChatbotState; dispatch: React.Dispatch<ChatbotAction>; }>({ state: initial, dispatch: () => null }); export const ChatbotProvider = ({ children }: { children: ReactNode }) => { const [state, dispatch] = useReducer(chatbotReducer, initial, (init) => { // ① 从 localStorage 读缓存 try { const raw = localStorage.getItem('chatbot_v1'); return raw ? JSON.parse(raw) : init; } catch { return init; } }); // ② 持久化 React.useEffect(() => { localStorage.setItem('chatbot_v1', JSON.stringify(state)); }, [state]); return ( <ChatbotCtx.Provider value={{ state, dispatch }}> {children} </ChatbotCtx.Provider> ); };3.5 自定义 Hook(带错误边界)
// useChatbot.ts import { useContext, useCallback } from 'react'; import { ChatbotCtx } from './ChatbotContext'; export const useChatbot = () => { const ctx = useContext(ChatbotCtx); if (!ctx) { throw new Error('useChatbot must be used inside ChatbotProvider'); } // 返回只读状态 + 分发函数 return ctx; }; // 进一步封装常用 action,组件层不直接碰 dispatch export const useSession = () => { const { state, dispatch } = useChatbot(); const setSession = (id: string) => dispatch({ type: 'SET_SESSION', payload: id }); return [state.sessionId, setSession] as const; };3.6 在组件里使用
import { useSession } from '@/store'; function SessionList() { const [sessionId, setSessionId] = useSession(); return ( <ul> {['s1', 's2', 's3'].map((id) => ( <li key={id} className={id === sessionId ? 'active' : ''} onClick={() => setSessionId(id)} > 会话 {id} </li> ))} </ul> ); }4. 方案 B:Redux Toolkit(同一套状态,迁移版)
// store/chatbotSlice.ts import { createSlice,, PayloadAction } from '@reduxjs/toolkit'; const chatbotSlice = createSlice({ name: 'chatbot', initialState: initial, // 同 Context 的 initial reducers: { setSession(state, action: PayloadAction<string>) { state.sessionId = action.payload; }, setTheme(state, action: PayloadAction<'light' | 'dark'>) { state.theme = action.payload; }, setDraft(state, action: PayloadAction<string>) { state.draft = action.payload; }, addMessage(state, action) { state.messages.push(action.payload); }, }, }); export const { setSession, setTheme, setDraft, addMessage } = chatbotSlice.actions; export default chatbotSlice.reducer;// store/index.ts import { configureStore } from '@reduxjs/toolkit'; import chatbotReducer from './chatbotSlice'; export const store = configureStore({ reducer: { chatbot: chatbotReducer, }, }); export type RootState = ReturnType<typeof store.getState>; export type AppDispatch = typeof store.dispatch;组件层用useSelector((s: RootState) => s.chatbot.sessionId)精准订阅,避免整树渲染。
5. 生产环境必须补的 4 个补丁
5.1 防止内存泄漏
在 Provider 里如果监听事件,记得清理:
useEffect(() => { const handler = () => /* 同步网络状态 */; window.addEventListener('online', handler); return () => window.removeEventListener('online', handler); // 清理 }, []);5.2 异步竞争条件
用户快速切换会话时,可能前一个fetchMessages后返回,覆盖新会话。
解决:
- 用
AbortController取消过时请求; - 或给每个 action 带
sessionId戳,在 reducer 里忽略旧戳。
5.3 避免不必要的 re-render
- Context 方案把“读”与“写”拆成两个 Context,或配合
useMemo; - Redux 方案用
shallowEqual对比数组长度,或写 selector 时返回原始值而非新对象。
5.4 持久化性能
localStorage 同步是同步 IO,大消息列表可:
- 只持久化关键字段(
sessionId、theme、draft), - 消息走 IndexedDB(dexie),
- 或做节流:防抖 500 ms 再写盘。
6. 避坑指南 Top3
直接修改 context 引用
错误:state.draft = newDraft
解决:永远返回新对象,immer 语法或展开运算符。把 async 逻辑塞进 reducer
reducer 必须是纯函数。异步放useEffect或 RTK 的extraReducers。忘记给状态加版本号
升级模型后字段变了,localStorage 反序列化失败直接白屏。
解决:const migrate = (raw: any): ChatbotState => { if (raw.version === 1) return raw; return { ...initial, ...raw, version: 1 }; };
7. 架构流程图(文字版)
┌------------┐ 语音输入 ┌-----------┐ 文本 ┌---------┐ 音频 ┌--------┐ │ 麦克风采集 │----------▶│ 实时 ASR │--------▶│ LLM大脑 │-------▶│ TTS播放│ └------------┘ └-----------┘ └---------┘ └--------┘ ▲ │ │ 全局变量层(Context / Redux) │ └--------------------反馈------------------------┘全局变量层贯穿三个环节,负责:
- 把 ASR 结果写进
draft; - 把 LLM 返回 push 到
messages; - 让 UI 订阅
theme做深色切换。
8. 延伸思考:WebSocket 跨标签页同步
当用户打开两个标签页同时聊 bot,状态要实时对齐。思路:
- 建立 WebSocket 连接,以
clientId区分标签; - 任一标签 dispatch 动作后,把 action 对象通过 ws 广播;
- 其他标签收到后
store.dispatch(remoteAction); - 为防止回声,给 action 加
fromPeer标记,来源与自己相同则忽略。
可尝试用redux-state-sync插件,或自己写 30 行代码实现。
9. 写在最后:把“状态”玩明白,Chatbot 就成功了一半
全局变量管理没有银弹,只有“适合当前阶段”的方案。
把本文的 Context 模板直接粘进项目,10 分钟就能跑通会话切换、主题换肤、草稿恢复三大刚需;等异步流复杂了,再迁移到 Redux 也不慌——因为 reducer 和类型定义已经写好了,迁移成本就是 copy & paste。
如果你想亲手搭一个能语音通话的 AI,顺便把上面这套状态管理实战跑通,推荐试试这个动手实验:
从0打造个人豆包实时通话AI
我跟着文档边写边调,一下午就把“耳朵-大脑-嘴巴”整条链路跑通,顺带把全局变量持久化也嵌进去,第二天给同事演示时他们都以为我偷偷加班卷了三天。小白也能顺利体验,不妨拿它当你的下一个 side project 练手素材。