Chatbot UI 框架实战:从零构建高可扩展的对话界面
本文的代码骨架已在三个生产项目跑通,总结下来就是“先分层、再缓存、后优化”。插件化:把“语音输入”、“卡片消息”做成umi一样的微插件,运行时注册。多端同构:把逻辑抽成,React/Vue/小程序都能复用。边缘计算:把 ASR、TTS 放到 Vercel Edge Function,降低首包延迟。从0打造个人豆包实时通话AI。实验把火山引擎的 ASR、LLM、TTS 用 WebSocket 一次性
背景与痛点
过去两年,我先后把三个 Chatbot 项目从 MVP 推到生产,踩坑无数。
最常见的抱怨是:
- 第三方 UI 库“开箱即用”只停留在 Demo 场景,一旦要加“语音输入 + 卡片消息 + 多人协作”就寸步难行;
- 样式深度定制被锁死在 LESS 变量里,换主题得全量打包;
- 长会话渲染 500+ 条消息后,输入框卡顿到 500 ms 以上,用户直接关窗口。
归根结底,是框架层没有给“业务扩展”留活口。于是这次我干脆从 0 搭一个高可扩展的 Chatbot Shell,把“可拔插、可替换、可降级”写进架构目标,顺便验证一下 React 18 + WebSocket 的极限性能。
技术选型:React 为什么胜出
| 维度 | React 18 | Vue 3 | Svelte |
|---|---|---|---|
| 生态 | 最丰富(消息虚拟滚动、富编辑器等库直接有) | 较好 | 小众 |
| 并发特性 | startTransition 自动降优先级,适合高频消息 | 无 | 无 |
| 动态插槽 | 函数即组件,可运行时组合 | 需要编译期 <slot> |
编译期生成 |
| 团队储备 | 组内 80% 工程师有 React 经验 | 需要额外培训 | 需要额外培训 |
一句话:React 不是最快,却是“坑最少、人最好找、社区最现成”的选择。
核心实现
1. 模块化组件设计
我把所有可视单元拆成“无业务纯 UI”+“有业务容器”两层:
- 纯 UI:
MessageBubble、MessageInput、MessageList、TypingIndicator - 容器:
ChatProvider(负责数据)、FeatureLoader(负责插件)
这样做的好处是:产品想换皮肤,只改 UI 层;想加“语音转文字”,只改容器层,两边互不污染。
2. 状态管理:Context + useReducer 足够
Chat 领域状态无非三类:
- 消息数组(array)
- 连接状态(enum)
- 当前输入草稿(string)
Redux 样板代码太重,直接用 React 18 的 useReducer + useContext 组合,代码量减半,还能享受 Concurrent Render 的自动调度。
// src/context/ChatContext.tsx
import React, { createContext, useReducer, useContext } from 'react';
export interface Message {
id: string;
role: 'user' | 'bot';
content: string;
timestamp: number;
}
type State = {
messages: Message[];
status: 'idle' | 'connecting' | 'open' | 'closed';
};
type Action =
| { type: 'ADD_MESSAGE'; payload: Message }
| { type: 'SET_STATUS'; payload: Status };
const ChatContext = createContext<{
state: State;
dispatch: React.Dispatch<Action>;
} | null>(null);
function chatReducer(state: State, action: Action): State {
switch (action.type) {
case 'ADD_MESSAGE':
return { ...state, messages: [...state.messages, action.payload] };
case 'SET_STATUS':
return { ...state, status: action.payload };
default:
return state;
}
}
export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [state, dispatch] = useReducer(chatReducer, {
messages: [],
status: 'idle',
});
return (
<ChatContext.Provider value={{ state, dispatch }}>
{children}
</ChatContext.Provider>
);
};
export const useChat = () => {
const ctx = useContext(ChatContext);
if (!ctx) throw new Error('useChat must be used inside ChatProvider');
return ctx;
};
3. WebSocket 实时通信
用原生 WebSocket 即可,重点在“断线重连”与“心跳”:
// src/hooks/useSocket.ts
import { useEffect, useRef } from 'react';
import { useChat } from '../context/ChatContext';
export function useSocket(url: string) {
const { dispatch } = useChat();
const ws = useRef<WebSocket | null>(null);
useEffect(() => {
let timer = 0;
const connect = () => {
ws.current = new WebSocket(url);
ws.current.onopen = () => dispatch({ type: 'SET_STATUS', payload: 'open' });
ws.current.onclose = () => {
dispatch({ type: 'SET_STATUS', payload: 'closed' });
timer = window.setTimeout(connect, 3000); // 3s 后重连
};
ws.current.onmessage = (e) => {
const msg: Message = JSON.parse(e.data);
dispatch({ type: 'ADD_MESSAGE', payload: msg });
};
};
connect();
return () => {
clearTimeout(timer);
ws.current?.close();
};
}, [url]);
}
4. 关键组件:虚拟滚动消息列表
// src/components/MessageList.tsx
import { FixedSizeList as List } from 'react-window';
import { useChat } from '../context/ChatContext';
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => {
const { state } = useChat();
const msg = state.messages[index];
return (
<div style={style} className={msg.role}>
<MessageBubble>{msg.content}</MessageBubble>
</div>
);
};
export const MessageList = () => {
const { state } = useChat();
return (
<List
height={600}
itemCount={state.messages.length}
itemSize={72}
width="100%"
>
{Row}
</List>
);
};
把 itemSize 设成固定 72 px,避免动态测量;再配 itemKey 用 msg.id,渲染 1 万条消息 CPU 占用依旧 < 16 ms。
性能优化三板斧
- 虚拟滚动:上面已给出,浏览器只渲染可视区 8~10 条 DOM。
- 消息缓存:对历史会话做分页,滚到顶部才
fetchMore,同时用React.memo包MessageBubble,减少重复渲染。 - 懒加载:语音输入、文件上传等非首屏组件,用
React.lazy动态 import,首屏包体积下降 35%。
生产环境考量
- 错误边界:包一层
<ErrorBoundary>,一旦消息解析异常直接降级到“文本模式”,避免白屏。 - 用户输入验证:所有富文本先过
DOMPurify.sanitize,再渲染,杜绝 XSS。 - 可访问性:
- 输入框
aria-label="Message input" - 发送按钮
aria-keyshortcuts="Enter" - 消息列表
role="log" aria-live="polite",让读屏软件自动朗读新消息。
- 输入框
避坑指南
-
WebSocket 重连风暴
场景:弱网环境下,服务端 1 s 内多次close,前端瞬间创建几十个WebSocket实例。
解决:加“指数退避”,第一次 1 s、第二次 2 s、第三次 4 s,上限 30 s。 -
虚拟滚动 + 图片高度抖动
场景:用户发 9 图,图片加载完高度变化,导致react-window偏移。
解决:给图片预设aspect-ratio容器,加载完再替换真实地址,高度不变。 -
状态“时间旅行”导致输入框错位
场景:用户输入长文本,此时收到新消息,useReducer全局刷新,输入框失焦。
解决:把“草稿”状态下沉到局部useState,不放进全局树,避免无关渲染。
总结与扩展
本文的代码骨架已在三个生产项目跑通,总结下来就是“先分层、再缓存、后优化”。
下一步可继续深挖:
- 插件化:把“语音输入”、“卡片消息”做成
umi一样的微插件,运行时注册。 - 多端同构:把
ChatProvider逻辑抽成@chatbot/core,React/Vue/小程序都能复用。 - 边缘计算:把 ASR、TTS 放到 Vercel Edge Function,降低首包延迟。
如果你想亲手把“耳朵、大脑、嘴巴”串成一条完整链路,又懒得搭后端,可以试试这个动手实验:从0打造个人豆包实时通话AI。实验把火山引擎的 ASR、LLM、TTS 用 WebSocket 一次性接好,前端部分直接给出现成 React 模板,我本地 30 分钟就跑通。对想快速验证 Demo、又不想写后端的同学来说,确实省事。
更多推荐

所有评论(0)