电商客服场景里,用户问完“我的券在哪”后,往往三秒内就想看到答案;大促高峰每秒上千条咨询,页面既要保证毫秒级响应,又得让客服无缝接管;一旦掉线重连导致记录丢失,投诉单就会像雪片一样飞来——这就是 Chatbot UI 的“三高”需求:高实时、高并发、高可用。
1. 通信方案选型:轮询、SSE 还是 WebSocket?
先把大家最关心的数据摆出来,本地 4 核 8 G 笔记本压测 1 分钟,消息量 10 k 条,三种方案对比如下:
| 方案 | 吞吐量(msg/s) | 内存峰值 | CPU 占比 | 长尾延迟 P99 |
|---|---|---|---|---|
| 轮询(1 s) | 1.1 k | 210 MB | 38 % | 1.1 s |
| SSE | 9.8 k | 185 MB | 22 % | 220 ms |
| WebSocket | 11.2 k | 160 MB | 18 % | 45 ms |
结论很直观:WebSocket 双工通道在吞吐、资源、延迟三项全部占优,还能天然压缩帧头,是电商客服这种“弹幕式”对话的首选。
2. React 18 + TypeScript 核心组件树
下面给出可直接粘贴跑的最小可运行骨架,重点看三个部分:消息气泡、输入框、打字指示器,以及自定义useWebSocketHook。
目录结构:
src/ ├─ components/ │ ├─ MessageList.tsx │ ├─ MessageBubble.tsx │ ├─ ChatInput.tsx │ ├─ TypingIndicator.tsx ├─ hooks/ │ ├─ useWebSocket.ts ├─ utils/ │ ├─ msgId.ts │ ├─ dedup.ts │ ├─ heartbeat.ts2.1 MessageBubble.tsx
import React from 'react'; import type { Message } from '../types'; interface Props { msg: Message; self: boolean; } const MessageBubble: React.FC<Props> = ({ msg, self }) ( <div className={self ? 'bubble self' : 'bubble other'}> <p>{msg.text}</p> <time>{new Date(msg.ts).toLocaleTimeString()}</time> </div> ); export default React.memo(MessageBubble);2.2 ChatInput.tsx
import React, { useState, FormEvent } from 'react'; interface Props { onSend: (text: string) => void; disabled: boolean; } const ChatInput: React.FC<Props> = ({ onSend, disabled }) => { const [text, setText] = useState(''); const submit = (e: FormEvent) => { e.preventDefault(); if (!text.trim()) return; onSend(text.trim()); setText(''); }; return ( <form onSubmit={submit}> <input value={text} onChange={(e) => setText(e.target.value)} disabled={disabled} placeholder="输入消息..." /> <button type="submit" disabled={disabled}>发送</button> </form> ); }; export default React.memo(ChatInput);2.3 TypingIndicator.tsx
import React from 'react'; const TypingIndicator: React.FC = () => ( <div className="typing"> <span /> <span /> <span /> </div> ); export default React.memo(TypingIndicator);2.4 MessageList.tsx(调度优先级细节)
import React, { useEffect, useRef } from 'react'; import { unstable_getCurrentPriorityLevel, unstable_ImmediatePriority } from 'scheduler'; import MessageBubble from './MessageBubble'; import TypingIndicator from './TypingIndicator'; import type { Message } from '../types'; interface Props { list: Message[]; typing: boolean; uid: string; } const MessageList: React.FC<Props> = ({ list, typing, uid }) => { const endRef = useRef<HTMLDivElement>(null); useEffect(() => { // 高优先级滚动到底部,确保用户永远看到最新消息 if (unstable_getCurrentPriorityLevel() <= unstable_ImmediatePriority) { endRef.current?.scrollIntoView({ behavior: 'smooth' }); } }, [list, typing]); return ( <div className="message-list"> {list.map((m) => ( <MessageBubble key={m.id} msg={m} self={m.uid === uid} /> ))} {typing && <TypingIndicator />} <div ref={endRef} /> </div> ); }; export default React.memo(MessageList);2.5 useWebSocket.ts(核心封装)
import { useEffect, useRef, useState, useCallback } from 'react'; import { encode, decode } from 'msgpack-lite'; // 二进制压缩 import { heartbeat } from '../utils/heartbeat'; import type { Message } from '../types'; const WS_URL = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/ws`; export default function useWebSocket(uid: string) { const ws = useRef<WebSocket | null>(null); const [online, setOnline] = useState(false); const [messages, setMessages] = useState<Message[]>([]); const [typing, setTyping] = useState(false); const send = useCallback((text: string) => { if (!ws.current || ws.current.readyState !== WebSocket.OPEN) return; const payload = { uid, text, ts: Date.now() }; ws.current.send(encode(payload)); // 二进制压缩发送 }, [uid]); useEffect(() => { const conn = new WebSocket(WS_URL); conn.binaryType = 'arraybuffer'; ws.current = conn; conn.onopen = () => setOnline(true); conn.onclose = () => setOnline(false); conn.onmessage = (e) => { const msg = decode(new Uint8Array(e.data)) as any; // 根据 type 分发 if (msg.type === 'typing') setTyping(msg.value); else { setMessages((prev) => [...prev, { ...msg, id: msgId(msg) }]); setTyping(false); } }; heartbeat(conn, 30_000); // 30 s 心跳 return () => conn.close(); }, [uid]); return { online, messages, typing, send }; }3. 性能优化三板斧
3.1 消息去重算法
客服系统常因“重连补推”导致同一条消息重复渲染。利用环形哈希队列,维护最近 200 条 msgId,插入前先看命中:
const seen = new Set<string>(); export function dedup(msg: Message): boolean { if (seen.has(msg.id)) return true; seen.add(msg.id); if (seen.size > 200) { const iter = seen.values(); seen.delete(iter.next().value); } return false; }在useWebSocket的onmessage里先if (dedup(msg)) return;即可。
3.2 二进制压缩
文本消息平均 120 bytes,JSON 带字段名后膨胀到 200 +。用 msgpack-lite 序列化,体积减少 35 %,解析耗时 < 1 ms,对 10 k 条/秒的场景可节省约 30 % 带宽。
3.3 心跳检测与自动重连
heartbeat.ts实现:
export function heartbeat(ws: WebSocket, ms: number) { const timer = setInterval(() => { if (ws.readyState === WebSocket.OPEN) ws.send('#PING'); }, ms); ws.addEventListener('pong', () => {/* 刷新 lastPong */}); ws.addEventListener('close', () => clearInterval(timer)); }服务端配合返回#PONG,前端若 2 次无应答即主动ws.close()并触发指数退避重连,避免半开连接占 fd。
4. 避坑指南
- 跨域安全策略:
wss与https保持同源,如需跨子域,务必在 Nginx 层统一反代,并设置Access-Control-Allow-Origin为具体域名,杜绝*。 - JWT 令牌刷新:在
onopen时携带Authorization: Bearer <token>,服务端返回 403 即调用/refresh接口,新令牌到手后先close()旧连接再用新令牌重连,防止并发竞态。 - XSS 防护:气泡渲染一律走
textContent,禁止dangerouslySetInnerHTML;若需支持富文本,先过DOMPurify.sanitize(),并开启ALLOWED_TAGS白名单。
5. 留给你的思考题
当用户问“上次那张 200 元神券我用掉了吗?”时,Bot 需要把近 30 天的订单、券包、履约状态全部召回,再总结回答。传统方案把历史记录全塞给 LLM,既贵又慢。何不试试 RAG(Retrieval-Augmented Generation)?先本地向量检索 Top-K 相关单据,再把精简片段喂给豆包·大模型,让回答既实时又省钱。动手实验里已给出向量库初始化脚本,等你把“记忆”接进来。
写完这篇小记,我把整套代码丢到线上,客服同学反馈“延迟肉眼可见地降了”。如果你也想从零撸一个能听、会说、带记忆的 Chatbot UI,不妨直接冲这个动手实验——从0打造个人豆包实时通话AI,官方把 WebSocket、ASR、LLM、TTS 全链路都封装好了,跟着步骤一路 Next,小白也能跑通。祝调试愉快,记得回来分享你的 RAG 调参笔记!