背景痛点:客服前端的三座大山
- 消息实时性:HTTP 长轮询 1 s 一次,高峰期 30 % 请求落在 504,用户骂“机器人卡死”。
- 会话状态同步:PC 端把问题描述到第 5 轮,切到手机小程序,记录凭空消失,客服只能“从头再来”。
- 多设备适配:折叠屏、小窗、车载横屏,气泡被键盘顶飞,按钮被刘海挡住,体验分直接腰斩。
技术选型:为什么放弃长轮询拥抱 WebSocket + React
- 长轮询在 3 G 弱网平均 RTT 1.2 s,WebSocket 握手后仅 40 ms,差距 30 倍。
- 公司 Node 网关已支持 MQTT over WebSocket,可直接复用 Qos1 重发机制,零额外成本。
- Redux Toolkit 的 slice + extraReducer 天然适合“会话-消息”两级状态,比 Zustand 多设备时间旅行调试更香。
- React 18 的 concurrent render 能把高优先级“正在输入”与低优先级“历史消息”拆开,保证首帧 60 fps。
核心实现:三层架构拆解
1. 消息顺序保障:MessageQueue 队列
- 客户端本地维护一个
sendQueue: Map<snowflakeId, Message>。 - 发送失败时
unshift.shift()重试,成功ackQueue.set(id, true),保证服务端回包顺序与本地渲染顺序一致。 - 渲染层用
useVirtualizer只挂载可视区域 10 条消息,DOM 节点数从 1200 降到 60,滚动掉帧率 0。
2. 断线重连:SWR 的妙用
- 自定义
useWsSWR(key, fetcher),key 为ws://broker/chat/${sessionId}。 - 断网时 SWR 自动重试,配合指数退避:1 s → 2 s → 4 s,最大 30 s。
- 重连成功后拉取
/api/miss?lastId=${lastAckId},增量合并到本地队列,用户无感刷新。
3. 自适应气泡:一个组件吃遍全端
- 采用 CSS
clamp()动态计算最大宽度:min(80vw, 480px)。 - 键盘弹起时通过
visualViewport.addEventListener('resize')拿到真实可视高度,把输入框translateY顶起,避免被遮挡。 - 图片气泡使用
object-fit: cover+aspect-ratio: 16/9,防止超长图刷屏。
代码示例:可直接粘贴的 React Hook
// hooks/useChat.ts import { useCallback, useEffect, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { sendMessage, ackMessage } from '@/store/chatSlice'; import { snowflakeId } from '@/utils/snowflake'; import { ws } from '@/utils/websocket'; export default function useChat() { const dispatch = useDispatch(); const { sessionId, sendQueue } = useSelector((s: RootState) => s.chat); // 发送消息 const send = useCallback( (text: string) => { const id = snowflakeId(); // 雪花算法防冲突 const msg = { id, text, from: 'user', ts: Date.now() }; dispatch(sendMessage(msg)); // 先写本地,乐观更新 ws.emit('chat', { sessionId, ...msg }); // 再发网络 }, [dispatch, sessionId] ); // 监听服务端 ack useEffect(() => { const onAck = (data: { id: string }) => dispatch(ackMessage(data.id)); ws.on('ack', onAck); return () => ws.off('ack', onAck); }, [dispatch]); return { send }; } // 会话持久化 useEffect(() => { const raw = localStorage.getItem(`chat_${sessionId}`); if (raw) dispatch(loadSession(JSON.parse(raw))); window.addEventListener('beforeunload', () => { localStorage.setItem(`chat_${sessionId}`, JSON.stringify(chatSlice.getInitialState())); }); }, [sessionId, dispatch]); // 性能埋点 useEffect(() => { const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { if (entry.name.includes('websocket')) { analytics.track('ws_latency', { value: entry.duration }); } } }); observer.observe({ entryTypes: ['resource'] }); return () => observer.disconnect(); }, []);生产考量:上线前必须回答的三个问题
- WebSocket 连接数优化
单机 4 C8 G 经过ulimit -n 65535调优,单进程可扛 5000 并发,CPU 65 %,内存 2.3 G;再涨就加 Pod,横向扩容比调内核更划算。 - 敏感信息过滤
采用“本地正则预检 + 云端 NLP 二次复核”双保险,正则 0.8 ms 拦截 90 % 关键词,剩下 10 % 走 NLP,平均延迟 120 ms,可接受。 - 负载测试数据
用 k6 模拟 5000 用户同时发消息,P99 延迟 380 ms,比业务目标 500 ms 低一个身段;断线重连成功率 99.7 %,未达 100 % 是因为 2 % 用户主动杀进程。
避坑指南:踩过的坑比你头发还多
- 消息 ID 冲突:前端 snowflake 低位用
Math.floor(Math.random()*16)曾撞车,后改workerId = hash(userId) % 32,百万消息零碰撞。 - 移动端输入法抖动:安卓弹起时
window.innerHeight变化 280 ms 一次,用debounce 300 ms再重算布局,终于不再“跳迪斯科”。 - 对话上下文缓存:IndexedDB 存 7 天历史,超过 200 M 自动 LRU 清理,防止“缓存爆炸”把小程序闪退。
写在最后
把 WebSocket、React、Redux Toolkit 拼在一起,只是智能客服的“文本时代”。下一步,语音、图片、视频多模态齐飞,前端如何设计一套插件化架构,让语音转文字、OCR 识图、甚至视频客服都能像搭积木一样插进来?你有什么好思路,欢迎留言一起头脑风暴。