1. 真实案例:一次“小”需求引发的连锁爆炸
去年我在一家 SaaS 公司接手 Chatbot 项目,老板一句“把输入框从底部挪到顶部”,让三位前端同学通宵加班。原因很直接:
- 所有样式写死在全局
chatbot.scss里,改一行bottom: 0导致气泡错位; - 业务逻辑与 UI 耦合,输入框位置一变,键盘高度计算、滚动锚点、工具栏定位全部崩;
- 没有统一状态源,消息列表、输入框、快捷回复各玩各的
useState,改一处动全身。
那一晚我们深刻体会到:高耦合 = 低效率。于是第二次迭代,我们决定用模块化思路把“可变的”和“不变的”彻底拆开。
2. 主流方案对比:改源码、插件化还是微前端?
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 直接改源码 | 上手快,不用额外学习成本 | 后续升级寸步难行,合并冲突爆炸 | 一次性交付,无持续迭代 |
| 插件化架构(slot + 插件注册表) | 核心包不改动,扩展点清晰 | 需要提前设计插槽,插件规范难统一 | 产品型公司,需要多客户定制 |
| 微前端/模块联邦 | 技术栈无关,独立部署 | 构建配置复杂,运行时开销大 | 多团队并行,巨型平台 |
我们最终采用“插件化 + 模块联邦”混合模式:核心聊天包保持纯净,业务方通过动态组件注册注入个性模块,既保留插件化的轻量,又能让不同团队用不同 React 版本并肩作战。
3. 核心实现:把 Chatbot 拆成乐高积木
3.1 React Context + Custom Hook 管理对话状态
先建一个只负责数据的层,任何 UI 想读/写消息都通过 Hook,而不是层层props。
// ChatContext.ts export interface Message { id: string; role: 'user' | 'bot'; content: string; timestamp: number; } type ChatContextValue = { messages: Message[]; sendMessage: (text: string) => void; loading: boolean; }; export const ChatContext = createContext<ChatContextValue | undefined>(undefined); export const useChat = () => { const ctx = useContext(ChatContext); if (!ctx) throw new Error('useChat must be used inside ChatProvider'); return ctx; };Provider 内部用useReducer集中处理追加、撤回、错误重试等逻辑,UI 层只负责useChat()拿数据,彻底解耦。
3.2 动态组件注册机制(TypeScript 版)
我们希望“快捷按钮”、“卡片模板”等业务方组件不编译进核心包,而是在运行时挂进来。
// registry.ts export type ComponentType = 'QuickReply' | 'CardTemplate' | 'RichInput'; interface RegistryItem { type: ComponentType; component: ComponentType<any>; } class ComponentRegistry { private store = new Map<ComponentType, ComponentType<any>>(); register({ type, component }: RegistryItem) { this.store.set(type, component); } get(type: ComponentType): ComponentType<any> | undefined { return this.store.get(type); } } export const registry = new ComponentRegistry(); // 业务方调用 registry.register({ type: 'QuickReply', component: QuickReply, // 任意 React 组件 });核心渲染引擎用React.createElement(registry.get('QuickReply'), props)动态挂载,实现“0 源码改动”注入新 UI。
3.3 Webpack 模块联邦让“插件”独立部署
核心仓库webpack.config.js暴露聊天组件:
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin'); new ModuleFederationPlugin({ name: 'chatCore', filename: 'remoteEntry.js', exposes: { './ChatWindow': './src/ChatWindow', './useChat': './src/hooks/useChat', }, shared: { react: { singleton: true }, 'react-dom': { singleton: true } }, });业务方仓库把ChatWindow当普通 npm 包引用,却能在运行时拿到最新版;核心包升级后,业务方只需刷新 CDN 缓存即可,无需重新发版。
4. 性能优化:别让消息列表拖垮 60 FPS
4.1 虚拟滚动实现消息列表
1000 条历史消息一次性渲染?页面直接卡死。我们用react-window只画可视区:
import { FixedSizeList as List } from 'react-window'; const Row = ({ index, style }) => ( <div style={style}> <MessageItem msg={messages[index]} /> </div> ); <List height={600} itemCount={messages.length} itemSize={72} // 单行预估高度 width="100%" > {Row} </List>实测 5k 条消息滚动流畅度从 18 FPS 提到 58 FPS。
4.2 useMemo 隔离高频更新
输入框每敲一次字母,父组件状态变更,导致所有消息气泡重渲染。给MessageItem加一层“浅比较”盾牌:
const MessageItem = React.memo(({ msg }) => { /* 渲染逻辑 */ }, (prev, next) => prev.msg.id === next.msg.id);同时把“发送中”动画抽成独立组件,只让局部 20 ms 刷新,避免整树抖动。
5. 生产环境避坑指南
5.1 跨 iframe 通信的 XSS 防护
客户要把 Chatbot 嵌到旧官网(不同域)。我们用postMessage传数据,但必须做两道门:
- 白名单校验
event.origin; - 使用
DOMPurify清洗富文本,防止 XSS 通过消息注入。
window.addEventListener('message', (e) => { if (!ALLOWED_ORIGINS.includes(e.origin)) { return; } const action = JSON.parse(e.data); if (action.type === 'SEND_MSG') { sendMessage(DOMPurify.sanitize(action.text)); } });5.2 对话状态持久化选型
- 刷新页面丢消息?客户投诉“聊天记录去哪了”。
- indexedDB > localStorage:单条消息 4 k 限制,量大易阻塞主线程。
- 选型:用 indexedDB 做“冷存储”,进入页面异步批量写;内存中只保留最近 100 条,保证首次渲染速度。
- 敏感字段(如用户手机号)AES 加密后再落盘,符合公司合规审计。
6. 开放问题:自定义需求 vs 后续升级兼容性
模块化让我们尝到甜头,但也带来新矛盾:插件一旦依赖核心包内部私有 API,后续官方重构就会击穿。你是否愿意:
- 让核心团队暴露更稳定的“官方插槽”,牺牲部分灵活性?
- 还是坚持深度定制,接受每次升级手工合并代码?
这个问题没有银弹,只能在“业务交付速度”与“技术债务”之间反复权衡。欢迎留言聊聊你们的做法。
我按上面的思路把 Chatbot UI 重新拆了一遍,开发效率肉眼可见提升:同规模需求从 3 人日降到 1.8 人日,代码冲突率下降 60%。如果你也想从零搭一套可插拔 + 实时语音的完整方案,不妨体验一下 从0打造个人豆包实时通话AI 动手实验,里面把 ASR、LLM、TTS 整条链路都封装好了,UI 部分正好可以套用本文的模块化思路,边学边改,小白也能跑通。祝编码愉快,早点下班!