基于WebSocket和MessagePack实现的即时消息系统实战

UX-俊凤 交互 阅读 621
赞 10 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

上线前压测,我盯着消息列表滚动,手指刚划两下,页面就掉帧——肉眼可见的卡顿。发 10 条消息,UI 延迟 300ms 才渲染;翻到第 200 条历史消息,输入框直接失焦,键盘弹了又收、收了又弹。用户反馈里写着:“发完消息要等两秒才看到自己说的话”,“滑动像在拖砖头”。这不是体验问题,是功能半瘫痪。

基于WebSocket和MessagePack实现的即时消息系统实战

我们用的是 WebSocket + Vue 3 + Composition API,消息列表用 v-for 渲染,每条消息带头像、时间戳、状态图标、富文本解析(支持 emoji 和链接高亮)。看起来挺常规,但一上量就崩。最夸张的一次:模拟 500 条未读消息进站,首页加载花了 5.2 秒,main thread 占满,DevTools Performance 面板红得像警报灯。

找到瘼颈了!

先跑一遍 Lighthouse,Performance 分数 32。点开 Performance 录制,放大看主线程——90% 时间耗在 Update DOMLayout。再切到 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 做消息已读上报,后续会继续分享这类博客。

以上是我踩坑后的总结,希望对你有帮助。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论