返回技术文章

工程实践

前端性能优化:虚拟列表真实适用场景与完整优化链路

列表卡顿别急着上虚拟列表,先排查网络、数据、渲染整条链路,最后才是它。

前端列表一卡顿,很多人的第一反应就是"上虚拟列表"。我之前也这么干过,结果问题没解决,反而把代码搞复杂了。后来才明白,虚拟列表是渲染层的兜底方案,不是性能优化的万能药。

先说清楚列表卡顿的根源。它从来不是一个原因,而是整条链路都有问题。

卡顿是整条链路的问题

网络层,一次接口返回了几千条数据;数据层,没有分页、没有按需过滤;渲染层,几千个 DOM 节点堆在页面上,一滚动就疯狂重排重绘;逻辑层,每个列表项里还塞着复杂的计算和无效的监听。四层问题叠加,主线程直接被拖垮,页面自然卡成 PPT。

要理解渲染层的问题,得先懂浏览器的渲染管线。DOM 变动会触发"重排"(重新计算布局)和"重绘"(重新画像素),列表滚动时如果每个节点都在变,浏览器要不断重新计算几千个节点的布局,主线程就堵死了。所以"减少 DOM 节点数量"才是治本方向,虚拟列表就是干这个的。

这里再补充一个概念:浏览器渲染是"主线程"干的活,JS 执行、布局、绘制都在主线程上排队。一旦主线程被长任务占满,页面就卡。所以性能优化的本质,是"减少主线程的工作量"——要么减少 DOM 节点,要么减少无效计算,要么把耗时操作丢给 Web Worker。

优化的正确顺序:从根源到兜底

所以我现在的优化思路是"从上往下,从根源到兜底"。

第一步先治网络层:接口必须分页。GET /articles?page=1&size=20,后端限制最大 pageSize,别让一次请求返回几千条,这是治本。

第二步治数据层:前端按需过滤字段,别把全部字段都塞进组件里。接口返回的字段越多,前端处理越慢,能精简就精简。

第三步治逻辑层:把模板里的复杂函数挪进计算属性,长列表每行只绑定必要的数据,多余的 watcher 和事件监听该销毁就销毁。Vue 的响应式系统里,每个 watcher 都是开销,列表里的每一项都绑一堆监听,性能自然崩。这里还有个细节:列表项组件尽量用 v-memo 或稳定的 key,减少不必要的重新渲染。

最后才轮到渲染层:如果数据量大到实在没法分页(比如日志流、实时消息、未分页的监控数据),再考虑虚拟列表。

虚拟列表怎么用才对

虚拟列表的原理是可视区 DOM 复用:只渲染当前能看到的那些节点,滚出去的销毁,让常驻 DOM 数量维持在几十个,从而解决海量节点的渲染问题。它确实好用,但使用场景很挑——只适合那种"不可分页、数据量巨大、持续滚动"的长列表。普通分页表格、几十条数据的小列表,你上虚拟列表纯属过度优化,除了增加复杂度没任何好处。

用虚拟列表还有个容易翻车的点:行高不固定。我用的方案是维护一个 Map 记录每行实测高度,用 ResizeObserver 监听高度变化实时更新总偏移;筛选数据后还要重新计算起始索引,不能直接复用原来的 index,不然滚动位置就乱了。另外虚拟列表和滚动事件要配合好,最好用 requestAnimationFrame 节流滚动更新,不然滚动会掉帧。

固定行高的虚拟列表实现起来简单,但内容不固定就麻烦——所以绝大多数真实项目都需要"动态行高"方案,这也是虚拟列表最容易踩坑的地方。还有防抖(debounce)和节流(throttle)的区别也要分清:防抖是"停下才执行",适合搜索;节流是"固定频率执行",适合滚动。

补充两个思路

除了虚拟列表,还有两个前端优化思路值得提:一个是"懒渲染",配合 IntersectionObserver,只有当元素进入视口时才真正渲染,适合瀑布流这类场景;另一个是"骨架屏",在数据加载时先渲染占位框架,避免白屏闪烁,让用户感知"内容在路上"而不是"页面卡了"。

说到底,性能优化是系统工程,虚拟列表只是最后一环的兜底。把该分页的分页、该精简的精简,绝大多数卡顿根本轮不到用虚拟列表。先排查整条链路,再决定要不要上大招,这才是正确的姿势。

分享这篇文章

如果你在这里有了 10 分钟的顿悟,那它折叠得刚刚好。