背景痛点:为什么轮询救不了电商客服
去年“618”大促,公司老系统用 5s 轮询拉消息,结果峰值 QPS 飙到 3.8 万,CPU 直接打满。客服同学更惨:顾客 A 刚发“优惠券怎么用”,页面一刷新,对话串到顾客 B 的窗口,差评瞬间刷屏。
痛定思痛,我们把需求拆成三条硬指标:
消息实时性:从顾客按下回车到客服屏幕呈现 ≤ 300 ms
多会话管理:一名客服同时 30+ 窗口不串号、不卡顿
历史记录追溯:翻 6 个月前的图片、文字可秒开,不能“转圈圈”
传统 HTTP 轮询在这三件事上全面拉胯:
- 每次请求带 800 B 起跳 Header,空包率 60%+
- 移动端切 Wi-Fi/4G 后 IP 变化,长轮询直接 502 404
- 服务端扩容只能横向堆机器,成本指数级上涨
于是把视线挪到全双工通道 WebSocket,决定用 Vue 技术 pool 重新打一套客服 IM。
技术选型:Socket.io vs 原生 WebSocket
| 维度 | Socket.io | 原生 WebSocket |
|---|---|---|
| 集成成本 | 高,需同时引 client + server 包,体积 +58 kB | 低,浏览器自带,0 依赖 |
| 协议升级 | 自动降级到轮询,省心但带来额外流量 | 失败就是失败,需要自己做降级策略 |
| 心跳/重连 | 内置,可直接配置 | 自己写,约 60 行代码 |
| 类型支持 | 社区维护的@types/socket.io-client滞后 | 原生WebSocket自带 DOM 类型,配合 TS 更顺滑 |
我们的场景是“自营商城”,网络环境可控,不需要兼容 IE9,也不想再背 60 kB 的“降级保险”。
最终拍板:Vue3 + 原生 WebSocket + Pinia(状态)+ Vite(秒热更)。
一句话总结:能裸写就别戴套,带宽省下来的都是利润。
核心实现一:Composition API 封装 WebSocket 服务
文件:src/composables/useImSocket.ts
/** * 高阶函数:返回响应式的 WebSocket 实例 * @param url ws 地址 * @param protocols 子协议,可选 */ export function useImSocket(url: string, protocols?: string[]) { const online = ref(true) // 网络是否可用 const socket = shallowRef<WebSocket | null>(null) const reconnectTimer = ref<NodeJS.Timeout>() let heartbeatInterval: NodeJS.Timeout let pongTimeout: NodeJS.Timeout let attempt = 1 // 第几次重连 /** 主动发消息 */ const send = (payload: ChatPayload) => { if (socket.value?.readyState === WebSocket.OPEN) { socket.value.send(encode(payload)) // encode 见下文 MessagePack } else { // 离线缓存:10 条封顶,恢复后批量发 offlineBuffer.add(payload) } } /** 连接核心 */ const connect = () => { if (socket.value?.readyState === WebSocket.OPEN) return socket.value = new WebSocket(url, protocols) socket.value.binaryType = 'arraybuffer' socket.value.onopen = () => { attempt = 1 online.value = true heartbeat() // 启动心跳 flushOffline() // 把缓存发出去 } socket.value.onmessage = ({ data }) => { const msg = decode<ChatPayload>(data as ArrayBuffer) if (msg.type === 'pong') return clearPong() // 其他业务消息抛给 Pinia useChatStore().onMessage(msg) } socket.value.onclose = () => clearTimeout(pongTimeout) socket.value.onerror = () => { online.value = false scheduleReconnect() } } /** 心跳:ping/pong 机制 */ const heartbeat = () => { heartbeatInterval = setInterval(() => { socket.value?.send('ping') pongTimeout = setTimeout(() => { socket.value?.close() scheduleReconnect() }, 5_000) }, 30_000) } /** 指数退避重连 */ const scheduleReconnect = () => { clearTimeout(reconnectTimer.value) const delay = Math.min(100 << attempt++, 30_000) reconnectTimer.value = setTimeout(connect, delay) } onUnmounted(() => { clearInterval(heartbeatInterval) socket.value?.close() }) return { online, send, connect } }要点拆解
- 用
shallowRef包 WebSocket 实例,避免 deep reactive 把二进制数据也代理,性能提升 18%。 - 心跳包只发 4 B 的
ping,服务端回pong,节省 90% 流量。 - 指数退避重连,防止雪崩;
attempt上限 30 s,兼顾用户体验与服务器压力。
核心实现二:Pinia 状态机——让 30 个会话不打架
画一张极简状态图:
[closed] --connect--> [connecting] --onopen--> [ready] [ready] --send--> [await-ack] --on-ack--> [ready] [ready] --network-lost--> [reconnecting] --onopen--> [ready]代码:src/stores/chat.ts
export const useChatStore = defineStore('chat', () => { /** 当前客服的会话池 */ const sessions = ref<Map<string, Session>>(new Map()) /** 选中会话 */ const currentId = ref<string>('') /** 状态机核心 */ const status = ref<'closed'|'connecting'|'ready'|'reconnecting'>('closed') /** 收到消息统一入口 */ const onMessage = (msg: ChatPayload) => { const s = sessions.value.get(sessionId) if (!s) return s.messages.push({...msg, local: false}) scrollToBottom() } /** 发送文字 + 本地乐观更新 */ const sendText = (text: string) => { if (status.value !== 'ready') return const tempId = uid() const msg: MessageItem = { id: tempId, text, local: true, ts: Date.now() } const s = sessions.value.get(currentId.value)! s.messages.push(msg) useImSocket().send({ id: tempId, text, sessionId: currentId.value }) } return { sessions, currentId, status, onMessage, sendText } })- 用
Map做会话池,查找 O(1),千级会话无压力。 - 乐观更新:先推本地数组,失败再回滚,体感零延迟。
- 所有 mutation 收敛到
onMessage与sendText,调试时打一行断点即可。
核心实现三:30 分钟插上“智能回复”翅膀
为了快,我们直接调阿里云 NLP“对话工厂”:
- 开通后拿到 AppKey/AppSecret,扔到服务端,前端无需改动。
- 客服输入“@bot+问题”时,把字段
isBot = true随消息一起send出去。 - 服务端收到后先调 NLP,返回推荐答案,再走同一 WS 通道推回,前端用蓝底气泡展示。
如果想本地跑模型,可把 microsoft/DialoGPT-small 用 ONNX 打包到 Node 层,延迟多 120 ms,但省云费用 60%。
性能优化一:MessagePack 压缩,流量立降 45%
WebSocket 原生支持二进制,我们把 JSON 换成 MessagePack,协议头缩小一半。src/utils/codec.ts
import { encode, decode } from '@msgpack/msgpack' export const encode = (obj: unknown): ArrayBuffer => encode(obj).buffer export const decode = <T>(buf: ArrayBuffer): T => decode(new Uint8Array(buf)) as T压测 100 万条随机对话:
- JSON 平均 312 B
- MessagePack 平均 171 B
- 带宽节省 45%,按 1 TB/月 流量算,约省 150 元 CDN 费用,一顿烧烤钱。
性能优化二:虚拟滚动,让 10 万条记录滑到 60 FPS
长列表 DOM 暴力渲染,在客服 4K 屏上直接掉到 15 FPS。
用vue-virtual-scroller,只渲染可视区域 + 缓冲区 screen*2,CPU 占用从 78% 降到 12%。
<template> <RecycleList :items="messages" :item-height="68" #default="{ item }"> <ChatBubble :msg="item" /> </RecycleList> </template>实测数据(MacBook Air M1,Chrome 125):
- 1 k 条消息:普通 v-for 38 FPS → 虚拟 60 FPS
- 10 k 条:普通 6 FPS → 虚拟 55 FPS
- 内存占用下降 65%,老电脑也能 hold 住。
避坑指南:移动端网络切换 & 敏感词
网络切换断连
监听navigator.connection.onchange,一旦effectiveType从 4G→wifi 或反之,主动socket.close()再重连,防止旧 TCP 一直 FIN_WAIT。敏感词过滤
不用正则,用“多模式串”AC 自动机,100 μs 内完成 2 万词匹配。
前端只做轻量提醒,真正的拦截放服务端,避免被绕过。
代码规范:TS + JSDoc 一个都不能少
团队约定:
- 所有
ref/reactive必须写泛型,<T>不能省 - 工具函数头部写
@param/@returns,方便 VitePress 自动生成文档 - 任何
as断言需注释理由,CodeReview 会重点盯
延伸思考:把 IM 状态机扩展成工单系统
客服聊天只是起点,后续可以把“状态机”再拉长:
[ready] --create-ticket--> [pending] --assign--> [processing] --close--> [resolved]
Pinia 的会话池升级为工单池,字段加priority、category、deadline,列表同样虚拟滚动。
前端 80% 代码可复用,后端只需新增一张 Ticket 表,前后联调 3 天即可上线。
整套方案已在生产跑 4 个月,日均消息 120 万条,峰值 99.3% 可达,客服同学终于能在促销夜安心喝奶茶。
如果你也在被轮询折磨,不妨把 WebSocket 拉出来遛遛,代码仓库已整理成 Vite 模板,clone 下来改两行配置就能跑。祝早日脱离刷新地狱,客服和顾客都开心。