最近在做 DuckBoard 的"数据明细表"功能时,遇到一个典型问题:当数据量超过 5000 条时,列表滚动开始明显卡顿,甚至偶尔会出现页面短暂无响应。
排查、修复、压测,整个过程花了大概两天时间。这篇文章把过程中验证有效的五个优化方法整理出来,希望能帮到遇到同样问题的同学。
1. 虚拟滚动:只渲染看得见的部分
这是最核心也是收益最大的优化。原理很简单:用户的视口里其实只能看到十几条数据,没必要把5000条全部渲染到 DOM 里。
我用的是 VueUse 提供的 useVirtualList,接入成本很低:
// 引入虚拟列表组合式函数 import { useVirtualList } from '@vueuse/core' const { list, containerProps, wrapperProps } = useVirtualList( rawData, // 5000+ 条原始数据 { itemHeight: 48 } )
接入之后,无论数据量是 500 条还是 50000 条,DOM 节点数量始终保持在视口能容纳的数量+缓冲区,滚动帧率从原来的 20fps 左右回到了稳定 60fps。
2. 用 shallowRef 代替 ref 处理大数组
Vue 3 的响应式系统默认会对数据做深度代理。对于一个5000条记录的数组,ref() 会递归地把每一条记录、每一个字段都变成响应式对象——这本身就是一笔不小的开销,而且大多数场景下根本用不到这种细粒度的响应式。
解决办法是用 shallowRef,只让数组的引用本身是响应式的:
// 浅层响应式:只追踪 .value 的替换,不深度代理内部字段 const tableData = shallowRef([]) async function loadData() { const res = await fetchTableData() // 整体替换触发更新,内部字段不会被逐一代理 tableData.value = res.data }
实测下来,初次渲染 5000 条数据的耗时从约 420ms 降到了 90ms 左右,效果立竿见影。
3. v-memo 跳过不必要的重新渲染
当列表中只有部分行的数据发生变化时(比如某一行的状态从"待处理"变成"已完成"),Vue 默认仍然会对所有行重新执行 diff。v-memo 可以告诉 Vue:"如果这些依赖项没变,直接跳过这个节点的更新"。
<tr v-for="row in visibleRows" :key="row.id" v-memo="[row.status, row.updatedAt]" > <td>{{ row.name }}</td> <td>{{ row.status }}</td> </tr>
这个优化在数据高频更新(比如 DuckBoard 的实时看板每秒刷新一次)的场景下效果尤其明显。
4. 合理设置 key,避免 diff 全量重建
这是个老生常谈但仍然经常被忽视的点。如果用数组下标作为 key,当数据顺序变化(比如排序、筛选)时,Vue 会认为"每一项都变了",从而触发大量不必要的 DOM 操作甚至组件重新挂载。
务必使用数据本身稳定的唯一标识(比如数据库主键 id)作为 key。这是几乎零成本但收益很高的优化。
5. 滚动事件节流与防抖
如果在滚动过程中需要做一些额外计算(比如更新"返回顶部"按钮的显示状态、懒加载图片),一定要对滚动事件做节流处理,避免每一帧都触发计算逻辑。
// 使用 requestAnimationFrame 节流,与浏览器渲染节奏对齐 function throttleRAF(fn) { let ticking = false return (...args) => { if (!ticking) { requestAnimationFrame(() => { fn(...args) ticking = false }) ticking = true } } }
小结
这五个优化技巧组合使用之后,DuckBoard 的明细表在 8000 条数据下依然能保持流畅滚动,初次渲染时间从接近 1 秒降到了 100ms 以内。
如果只能选一个优先做,我会推荐虚拟滚动——它解决的是"渲染数量"这个根本问题,其他优化更多是锦上添花。