1. 背景与痛点:对话式 UI 的三座大山
做 Chatbot 前端,最怕的不是“写不出界面”,而是“写不出能用的界面”。
实时性、状态同步、多端适配,这三座大山把无数项目卡在 60 分及格线以下。
- 实时性:HTTP 轮询 1 s 一次,延迟肉眼可见;WebSocket 掉线重连没做好,用户一句话发 3 遍。
- 状态同步:同一账号在 PC 和 App 同时在线,消息顺序、已读未读、输入提示全乱。
- 多端适配:键盘弹出把输入框顶飞、iOS 橡皮筋效果把滚动条吞掉、Android 低端机渲染 300 条消息直接卡成 PPT。
一句话:Chatbot UserUI 不是“画气泡”,而是“在 200 ms 内把气泡画对、画稳、画好看”。
2. 技术选型:React / Vue / Angular 谁更适合聊天场景?
| 维度 | React 18 | Vue 3 | Angular 17 |
|---|---|---|---|
| 响应粒度 | 组件级 | 组件级 | 框架级 |
| 并发优势 | Hooks+并发模式时间切片 | 响应式 API 简洁 | 依赖注入+RxJS 一流 |
| 包体积 | 42 kB | 34 kB | 130 kB |
| 生态 WebSocket 库 | use-ws / socket.io | vue-socket.io | rxjs-websocket |
| SSR 同构 | Next.js 成熟 | Nuxt 3 稳定 | Angular Universal 重 |
结论:
- 需要极致可扩展、团队 TS 基建成熟 → React
- 需要快速交付、模板上手成本低 → Vue
- 需要企业级内置方案、愿意接受全家桶 → Angular
下文以 React 18 为例,思路同样适用于 Vue 3 Composition API。
3. 核心实现:React Hooks + WebSocket 最小可用模型
目标:200 行内跑通“发-收-渲染”闭环,代码可单元测试、可复用。
3.1 目录约定
src/ ├─ hooks/ │ ├─ useChatSocket.ts // 长连接+重连 │ └─ useMessageList.ts // 虚拟列表+状态 ├─ components/ │ ├─ MessageList.tsx │ └─ MessageInput.tsx └─ utils/ ├─ message.ts // 类型守卫、排序 └─ logger.ts // 统一日志3.2 关键代码(Clean Code 版)
useChatSocket.ts
import { useEffect, useRef, useState } from 'react'; import { io, Socket } from 'socket.io-client'; import { ChatMessage } from '@/types'; const WS_URL = import.meta.env.VITE_WS_URL; export function useChatSocket(roomId: string) { const [connected, setConnected] = useState(false); const [transport, setTransport] = useState<string>('polling'); const socketRef = useRef<Socket | null>(null); // 对外只暴露只读状态,防止组件直接改 socket useEffect(() => { const socket: Socket = io(WS_URL, { query: { roomId }, transports: ['websocket', 'polling'], timeout: 20000, reconnection: true, reconnectionAttempts: 5, reconnectionDelay: 1000, }); socket.on('connect', () => { setConnected(true); setTransport(socket.io.engine.transport.name); }); socket.on('disconnect', reason => { setConnected(false); console.warn('[ws] disconnected:', reason); }); socketRef.current = socket; return () => { socket.close(); }; }, [roomId]); const send = (payload: Omit<ChatMessage, 'id' | 'ts'>) => { socketRef.current?.emit('chat', payload); }; return { socket: socketRef.current, connected, transport, send }; }useMessageList.ts
import { useReducer, useCallback } from 'react'; import { ChatMessage } from '@/types'; type State = { items: ChatMessage[]; hasMore: boolean; loading: boolean; }; type Action = | { type: 'prepend'; payload: ChatMessage[] } | { type: 'append'; payload: ChatMessage } | { type: 'update'; id: string; partial: Partial<ChatMessage> } | { type: 'setLoading'; loading: boolean }; const init: State = { items: [], hasMore: true, loading: false }; function reducer(state: State, action: Action): State { switch (action.type) { case 'prepend': return { ...state, items: [...action.payload, ...state.items], hasMore: action.payload.length === 20, }; case 'append': return { ...state, items: [...state.items, action.payload] }; case 'update': return { ...state, items: state.items.map(m => m.id === action.id ? { ...m, ...action.partial } : m ), }; case 'setLoading': return { ...state, loading: action.loading }; default: return state; } } export function useMessageList() { const [state, dispatch] = useReducer(reducer, init); const prepend = (list: ChatMessage[]) => dispatch({ type: 'prepend', payload: list }); const append = (msg: ChatMessage) => dispatch({ type: 'append', payload: msg }); const update = (id: string, partial: Partial<ChatMessage>) => dispatch({ type: 'update', id, partial }); return { state, prepend, append, update }; }MessageList.tsx(虚拟列表核心)
import { FixedSizeList as List } from 'react-window'; import { useMessageList } from '@/hooks/useMessageList'; import { useChatSocket } from '@/hooks/useChatSocket'; import { useEffect, useRef } from 'react'; export default function MessageList({ roomId }: { roomId: string }) { const { state, append } = useMessageList(); const { socket } = useChatSocket(roomId); const listRef = useRef<List>(null); useEffect(() => { if (!socket) return; socket.on('chat', (msg: ChatMessage) => { append(msg); // 滚动到底部 setTimeout(() => listRef.current?.scrollToItem(state.items.length, 'end')); }); }, [socket, append, state.items.length]); return ( <List ref={listRef} height={600} itemCount={state.items.length} itemSize={60} itemData={state.items} itemKey={(idx, data) => state.items[idx].id} > {({ index, style, data }) => ( <div style={style} className="msg-row"> <MessageBubble msg={data[index]} /> </div> )} </List> ); }要点:
- 自定义 Hook 只做一件事,返回稳定 API。
- 所有副作用收敛到 useEffect,方便写 RTL 单测。
- 虚拟列表仅渲染可视区,3000 条消息在 iPhone 6 也能 60 FPS。
4. 性能优化:把 300 ms 延迟压到 30 ms
虚拟列表
已集成 react-window;若需要动态高度,改用 react-virtualized-auto-sizer + CellMeasurer。消息压缩
文本 gzip 后再发 WebSocket,实测 5 kB 消息→1.2 kB;对弱网 3G 提升 30 % 到达率。缓存策略
对“历史消息”做 SWR:进入房间先读本地 IndexedDB,再后台静默拉 20 条,减少白屏 400 ms。输入节流
“对方正在输入”状态 300 ms 防抖;节流窗口内合并 diff,只发一次 socket 包。React 层
用 startTransition 把“已读回执”设为低优先级,不阻塞用户滚动。
5. 避坑指南:上线血与泪的 6 条笔记
状态管理别用全局 Mutable 对象
曾经直接 push 到数组,导致同一消息在 StrictMode 下渲染两次。用 useReducer 或 immer 保证 immutable。重连风暴
服务端重启,1000 客户端同时重连,QPS 瞬间打满。指数退避 + 随机 jitter(0~1 s)解决。iOS 键盘遮挡
视口高度在键盘弹出时变化,用 visualViewport API 动态改 bottom padding,别写死 100 px。消息乱序
服务端时钟不一致,用“客户端本地单调递增 snowflake + 服务端校正”双保险。并发编辑
用户 A 正在编辑,用户 B 删除该消息,前端需回滚输入框并 toast 提示“消息已撤回”。日志与监控
线上白屏 5 s 才发现 CDN 把 socket.io 的 ESM 文件 404。接入 Sentry + 自定义 WebSocket 延迟指标,告警阈值 500 ms。
6. 扩展思考:LLM 时代,Chatbot UserUI 的下一步
流式渲染
LLM 采用 SSE 或 WebSocket 分片返回,前端按句子级做打字机效果,需控制 50 ms 一帧,避免 setState 频繁导致掉帧。多模态气泡
用户发语音→ASR→LLM→TTS,全程在同一气泡内切换状态,UI 状态机比文本复杂 3 倍,建议用 XState 描述。个性化记忆
把用户最近 20 条消息摘要向量化,存在 IndexedDB,LLM 做上下文召回,前端负责摘要缓存命中,减少 30 % 网络传输。边缘计算
对超大模型,用 WebGPU 在本地跑 3 B 参数小模型做“草稿”,先给用户瞬时反馈,云端大模型校正后再替换,体验“零等待”。
如果你也想亲手把“耳朵-大脑-嘴巴”串成一条完整链路,推荐试试这个动手实验——
从0打造个人豆包实时通话AI
实验把火山引擎的 ASR、LLM、TTS 三件套封装成可插拔模块,Web 端代码开箱即用。
我跟着跑了一遍,30 分钟就能在浏览器里跟虚拟角色语音唠嗑,延迟稳定在 200 ms 左右,比自己东拼西凑省心多了。