基于排队显示的前端性能优化实践与细节打磨

南宫国娟 交互 阅读 2,662
赞 27 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

这个功能是做在线预约系统的排队显示,用户提交预约后会进入一个队列,前端需要实时展示当前排到多少人、前面还有多少人。本来是个简单需求,但客户非要加个“动态流动”的视觉效果——不是单纯数字刷新,而是要像地铁站那种屏幕一样,新来一个人,整个队列往下“滚”一下。

基于排队显示的前端性能优化实践与细节打磨

一开始我寻思用 setInterval 每秒轮询一次接口,把最新列表渲染出来,简单粗暴。代码也写了,跑起来没问题。但上线测试那天直接翻车:50个人同时刷页面,接口崩了,而且页面卡得连按钮都点不动。

后来改用 WebSocket 推送更新,这下数据实时性解决了,但新的问题来了:怎么高效地渲染这个“滚动”动画?最开始我用 Vue 的 v-for 直接绑数组,每次新增一项就 unshift 进去,靠 CSS 动画实现滑动。看着挺顺,实际一测才发现,老用户的浏览器(尤其是安卓低端机)撑不过三分钟就开始掉帧。

最大的坑:性能问题

问题出在 DOM 节点太多。我们允许显示前 20 名,加上自己,最多渲染 21 条记录。听着不多对吧?但每条记录里有头像、昵称、时间戳、状态标签,还带圆角阴影,整下来一个 item 就快 1KB 的 DOM 结构。21 个就是两万字节的节点操作,哪怕你用虚拟列表,diff 成本也不低。

更麻烦的是那个“滚动”动画。我用了 transform: translateY 触发动画,按理说走 GPU,应该不占主线程才对。但问题是每次更新都要插入一个新元素,触发重排,浏览器还是会卡一下。尤其在 Safari 上,动画一顿一顿的,用户体验极差。

折腾了半天发现,根本不在动画本身,而在“批量更新”策略。之前是服务端推一条,我就立马更新一次视图。结果高峰期一秒推五条,页面连续触发五次 reflow,CPU 直接飙到 90%。

最终的解决方案

后来我改了个思路:不追求“每一条都实时”,而是“每一帧最多处理一次更新”。用 requestAnimationFrame 做节流,把所有 incoming 的消息暂存进 buffer,等下一帧统一 diff 并渲染。

核心代码其实没几行:

class QueueRenderer {
  constructor() {
    this.buffer = []
    this.isRendering = false
    this.currentList = []
    this.element = document.getElementById('queue-list')
  }

  enqueueUpdate(newItems) {
    this.buffer.push(...newItems)
    this.scheduleRender()
  }

  scheduleRender() {
    if (this.isRendering) return
    this.isRendering = true
    requestAnimationFrame(() => {
      this.flush()
      this.isRendering = false
    })
  }

  flush() {
    if (this.buffer.length === 0) return

    // 合并去重(根据用户ID)
    const map = new Map()
    this.buffer.forEach(item => map.set(item.userId, item))
    this.buffer.length = 0

    const updates = Array.from(map.values()).slice(0, 20)

    // 只有变化才重新渲染
    if (!this.isEqual(this.currentList, updates)) {
      this.currentList = updates
      this.render(updates)
    }
  }

  isEqual(arr1, arr2) {
    if (arr1.length !== arr2.length) return false
    return arr1.every((item, i) => item.userId === arr2[i].userId)
  }

  render(list) {
    const html = list.map(item => 
      <div class="queue-item" data-id="${item.userId}">
        <img src="${item.avatar}" alt="avatar" class="avatar">
        <span class="name">${item.name}</span>
        <span class="time">${item.time}</span>
        <span class="status ${item.status}">${item.status}</span>
      </div>
    ).join('')
    this.element.innerHTML = html
  }
}

然后配合 WebSocket 接收逻辑:

const renderer = new QueueRenderer()

const ws = new WebSocket('wss://jztheme.com/ws/queue')

ws.onmessage = (e) => {
  const data = JSON.parse(e.data)
  if (data.type === 'queue_update') {
    renderer.enqueueUpdate(data.payload)
  }
}

就这么改完之后,内存占用降了一半,动画流畅多了。虽然极端情况下还是会有轻微延迟(比如突发 10 条消息),但用户感知不强,毕竟谁也不会盯着队列看毫秒级差异。

样式和动画的小细节

CSS 方面也没啥花活,就是用了 will-change 提示浏览器提前升层,避免频繁创建图层影响性能:

#queue-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
  overflow: hidden;
}

.queue-item {
  display: flex;
  align-items: center;
  padding: 12px;
  background: #fff;
  border-radius: 8px;
  box-shadow: 0 1px 3px rgba(0,0,0,0.1);
  animation: slide-up 0.3s ease forwards;
  opacity: 0;
  transform: translateY(10px);
  will-change: transform, opacity;
}

@keyframes slide-up {
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

这里注意我踩过好几次坑:一开始给整个 container 加了 will-change: transform,结果 Chrome 报 warning 说过度使用会导致内存泄漏。后来改成只给 item 加,并且动画结束后 remove will-change —— 但这又增加复杂度,最后干脆不管了,因为实测影响不大。

回顾与反思

现在回头看,有几个点做得还行:

  • 用 rAF 节流代替 setInterval,有效控制渲染频率
  • 合并批量更新,减少不必要的 DOM 操作
  • 通过 userId 去重,避免重复插入同一用户

但也有些地方还能优化:

  • 没上虚拟列表。其实可以只渲染可视区域的 5~7 条,其余用占位符,这样更省内存。但考虑到最大也就 21 条,加虚拟滚动成本太高,就没搞。
  • 错误处理不够健壮。WebSocket 断线重连时可能丢消息,目前靠页面定时 fallback 到轮询兜底,体验有点割裂。
  • 无障碍支持基本为零。屏幕阅读器用户完全不知道队列变了,这点后续打算加 aria-live 区域补上。

还有一个小问题到现在都没完美解决:当用户快速上下滑动页面时,偶尔会出现“动画卡住”的现象,查了是 iOS Safari 的 repaint 触发机制问题,force repainting 又太耗性能,权衡之下选择放过。

总的来说,这个方案不是最优解,但足够稳定,上线一个月没出过大 bug。对于这种中低频交互场景,我觉得“简单可维护”比“极致性能”更重要。

以上是我的项目经验,希望对你有帮助

这个技巧的拓展用法还有很多,比如可以用在直播弹幕、实时日志监控等场景。如果你有更好的实现方式,比如用 Web Worker 做 diff 或者用 CSS Containment 优化渲染,欢迎评论区交流。我自己也在持续学习怎么写更轻量的实时交互组件。

前端这行就是这样,看着简单的功能,背后全是细节堆出来的。有时候真想对着产品经理说一句:你要的不只是一个数字变化,是你根本看不见的一堆妥协和取舍。

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

暂无评论