基于排队显示的前端性能优化实践与细节打磨
项目初期的技术选型
这个功能是做在线预约系统的排队显示,用户提交预约后会进入一个队列,前端需要实时展示当前排到多少人、前面还有多少人。本来是个简单需求,但客户非要加个“动态流动”的视觉效果——不是单纯数字刷新,而是要像地铁站那种屏幕一样,新来一个人,整个队列往下“滚”一下。
一开始我寻思用 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 优化渲染,欢迎评论区交流。我自己也在持续学习怎么写更轻量的实时交互组件。
前端这行就是这样,看着简单的功能,背后全是细节堆出来的。有时候真想对着产品经理说一句:你要的不只是一个数字变化,是你根本看不见的一堆妥协和取舍。

暂无评论