基于WebSocket和MessagePack实现的即时消息系统实战
优化前:卡得不行
上线前压测,我盯着消息列表滚动,手指刚划两下,页面就掉帧——肉眼可见的卡顿。发 10 条消息,UI 延迟 300ms 才渲染;翻到第 200 条历史消息,输入框直接失焦,键盘弹了又收、收了又弹。用户反馈里写着:“发完消息要等两秒才看到自己说的话”,“滑动像在拖砖头”。这不是体验问题,是功能半瘫痪。
我们用的是 WebSocket + Vue 3 + Composition API,消息列表用 v-for 渲染,每条消息带头像、时间戳、状态图标、富文本解析(支持 emoji 和链接高亮)。看起来挺常规,但一上量就崩。最夸张的一次:模拟 500 条未读消息进站,首页加载花了 5.2 秒,main thread 占满,DevTools Performance 面板红得像警报灯。
找到瘼颈了!
先跑一遍 Lighthouse,Performance 分数 32。点开 Performance 录制,放大看主线程——90% 时间耗在 Update DOM 和 Layout。再切到 Memory,强制 GC 后拍个堆快照,发现 MessageItem 实例有 684 个,每个都绑着一个 computed(用于格式化时间)、一个 ref(记录是否已读)、还有一堆 watch 监听状态变化……这哪是消息组件,这是内存永动机。
接着开 Vue Devtools 的 Performance 标签,过滤 render 调用栈,发现每次新消息进来,整个列表都在 patch,哪怕只加一条,也在重比对全部 500 个 vnode。更离谱的是,富文本解析那段正则(/[^u4e00-u9fa5ws]/g)被调用了 500 次/秒——因为我在模板里写了 {{ parseRichText(msg.content) }}。
定位清楚了:不是 WebSocket 慢,不是后端吐得慢,是前端在“自己打自己脸”——无节制的响应式绑定 + 模板层重复计算 + 全量 DOM 更新。
优化后:流畅多了
试了几种方案:用 shallowRef 包消息数组?没用,v-for 里还是得访问每个 item 的属性;换虚拟滚动?太重,我们的场景是“偶尔长列表+高频追加”,不是无限滚动;最后决定三刀砍下去:
- 第一刀:消息对象去响应式 —— 把
messages改成普通数组,新增消息用push,不走reactive;显示层用toRaw确保不意外触发 proxy - 第二刀:富文本预处理 —— 接收到消息时立刻解析好 HTML 字符串,存进
msg.parsedContent,模板里直接v-html - 第三刀:局部更新 + key 强制复用 —— 不用
v-for直接遍历,改用template v-for+ 精确key,且新消息只appendChild,老消息绝不 rerender
核心代码就这几行:
// 接收消息时预处理(WebSocket onmessage)
function handleMessage(rawMsg) {
const msg = { ...rawMsg }
// ✅ 关键:这里就做完所有计算,后续不碰
msg.parsedContent = parseRichText(rawMsg.content)
msg.formattedTime = formatTime(rawMsg.timestamp)
msg.isOwn = rawMsg.senderId === currentUser.id
// ✅ 关键:不 push 到 reactive 数组,而是普通数组
messages.push(msg)
// ✅ 关键:手动触发一次更新,但只通知视图“新增了”
triggerNewMessage()
}
// 模板里这样写(Vue 3 + script setup)
<ul class="message-list">
<li
v-for="msg in messages"
:key="msg-${msg.id}-${msg.updatedAt}"
class="message-item"
>
<div class="avatar" :style="{ backgroundImage: url(${msg.avatar}) }"></div>
<div class="content" v-html="msg.parsedContent"></div>
<div class="time">{{ msg.formattedTime }}</div>
</li>
</ul>
这里注意我踩过好几次坑:一开始 key 只用 msg.id,结果编辑消息后界面错乱——因为 id 没变但内容变了,Vue 复用节点时把旧 DOM 挂到了新内容上。后来改成 ,问题消失。msg-${msg.id}-${msg.updatedAt}
还有个细节:v-html 渲染前加了白名单过滤(防 XSS),但这个过滤函数我抽出来单独 memoize 了,避免重复执行。代码如下:
// memoized 版本,避免每次渲染都 new RegExp
const safeHtml = memoize((raw) => {
const div = document.createElement('div')
div.textContent = raw
return div.innerHTML
.replace(/<scriptb[^<]*(?:(?!</script>)<[^<]*)*</script>/gi, '')
.replace(/onw+="[^"]*"/gi, '')
})
另外,时间格式化也从模板挪到预处理里。原来模板里是 {{ new Date(msg.timestamp).toLocaleString() }},现在变成 msg.formattedTime = new Intl.DateTimeFormat('zh-CN').format(new Date(msg.timestamp)),快了 8 倍不止(V8 对 Intl 有深度优化)。
性能数据对比
改完打包上线,本地和 staging 环境全量压测:
- 首屏消息列表加载(500 条):从 5200ms → 780ms
- 单条消息追加渲染耗时:从 120ms → 9ms(Chrome Profiler 测量 render + paint)
- 滚动帧率(100 条消息持续滚动):从平均 24fps → 稳定 58–60fps
- 内存占用(滚动到 300 条后):从 142MB → 68MB
- Lighthouse Performance 分数:从 32 → 89
最直观的感受:现在发消息,眼睛还没眨完,“已发送”状态图标就亮了;往上快速滑动 200 条,毫无压力。测试同学说:“终于不像在用老年机了。”
当然,没做到 100% 完美。比如极端情况(同一秒涌入 30 条消息),还是会有轻微卡顿——但我们加了个节流合并策略:100ms 内的新消息攒批触发一次 DOM 更新,实测下来感知不到延迟,代码也就多 4 行:
let pendingMessages = []
let updateTimer = null
function batchAppend(msg) {
pendingMessages.push(msg)
if (!updateTimer) {
updateTimer = setTimeout(() => {
messages.push(...pendingMessages)
triggerNewMessage()
pendingMessages = []
updateTimer = null
}, 100)
}
}
以上是我的优化经验,有更好的方案欢迎交流
这个方案不是理论上最优的(比如真要用虚拟滚动或 Web Worker 解析富文本),但它上线零事故、开发成本低、维护简单,团队里新人也能看懂。有时候,“能跑、够快、别出事”就是最好的架构。
如果你也遇到类似问题,建议先跑一遍 Performance 录制,别猜,直接看火焰图里哪块最红。别迷信“响应式一定好”,该用普通对象的时候就别硬套 ref。
这个技巧的拓展用法还有很多,比如结合 IntersectionObserver 做图片懒加载、用 requestIdleCallback 做消息已读上报,后续会继续分享这类博客。
以上是我踩坑后的总结,希望对你有帮助。

暂无评论