为什么我的Vue列表页滚动时会掉帧?从diff与渲染看性能优化

阿乐
阿乐 管理员 黑卡会员
发布于 2026-09-10 10:42 ·9 浏览 ·0 回复

列表页滚动掉帧,几乎是每个前端都踩过的坑:数据量不算夸张,接口也很快,但手指一滑,页面就像在放 PPT。更让人困惑的是,打开 Vue Devtools 一看,组件也没更新几次,为什么帧率还是上不去?

答案往往不在“Vue 慢”,而在于滚动这个高频行为,恰好撞上了 Vue 的响应式更新、虚拟 DOM diff 和浏览器渲染流水线三件事。只要其中一环在主线程上耗时过长,16ms 的帧预算就会被击穿。

掉帧的本质:主线程被谁占住了

浏览器要维持 60fps,每帧只有约 16.6ms。滚动时,浏览器需要处理事件、执行 JS、计算样式、布局、绘制、合成。如果 JS 执行时间过长,后面的步骤只能排队,用户看到的就是卡顿。

Vue 的更新是异步批量的,但批量不代表不耗时。当响应式数据变化时,Vue 会重新执行组件渲染函数,生成新的 VNode,再和旧 VNode 做 diff,最后把差异应用到真实 DOM。这一整套流程都在主线程完成。列表越长、组件越复杂,单次更新的成本就越高。

所以,滚动掉帧通常不是“滚动本身”导致的,而是滚动过程中触发了不该触发的更新,或者更新范围远远超过了必要范围。

滚动时,Vue 可能在做哪些无用功

一个很常见的写法是监听滚动,把 `scrollTop` 存进响应式数据:

const scrollTop = ref(0)
window.addEventListener('scroll', () => {
  scrollTop.value = window.scrollY
})

如果这个 `scrollTop` 被模板中的多个组件依赖,那么每一次滚动都会触发这些组件重新渲染。滚动事件触发频率极高,一秒可能几十次,Vue 的调度器虽然会合并同一 tick 的更新,但下一帧又来一轮。主线程就这样被反复占用。

更隐蔽的是,有些列表项内部用了 `IntersectionObserver` 或 `getBoundingClientRect` 来判断可见性,回调里又修改响应式状态。滚动时,观察器频繁触发,状态频繁更新,组件频繁 patch,掉帧几乎是必然的。

diff 的成本:key 与更新范围

Vue 的 diff 算法在同层比较,复杂度与节点数量相关。Vue 2 使用双端比较,Vue 3 在编译时标记动态节点,并借助最长递增子序列减少移动操作。但无论哪个版本,diff 都不是免费的。

如果列表使用 `index` 作为 key,在插入、删除、排序时,Vue 会按位置复用节点,导致大量不必要的 DOM 更新,甚至状态错乱。用唯一 id 作为 key,能让 diff 更准确地识别节点,减少真实 DOM 操作。

另一个容易被忽略的点是组件粒度。如果整个列表是一个大组件,任何一条数据变化都会导致整个列表重新渲染。把列表项拆成独立组件,让响应式依赖落在更小的范围内,diff 的规模会显著下降。Vue 3 的 `v-memo` 也能缓存子树,在依赖不变时跳过更新。

渲染的成本:DOM 数量与样式复杂度

即使 diff 很快,真实 DOM 的创建、插入、样式计算和布局也可能很贵。一个列表渲染上千个节点,每个节点又有阴影、圆角、渐变、复杂选择器,浏览器在滚动时就需要不断合成和绘制。

这时候,虚拟滚动是最直接的手段。只渲染视口内可见的几十个节点,滚动时复用 DOM,把节点数量从几千降到几十,diff 和渲染的开销都会断崖式下降。`vue-virtual-scroller`、`@vueuse/core` 的 `useVirtualList` 都是成熟方案。

如果列表项高度不固定,虚拟滚动会复杂一些,但依然比全量渲染划算。还可以配合 `content-visibility: auto`,让浏览器跳过屏幕外元素的渲染工作;用 `contain: layout paint` 限制布局和绘制范围;用 `will-change: transform` 把滚动动画交给合成层。

几个真正有效的优化方向

第一,先测量再优化。用 Chrome Performance 面板录制一段滚动,看 Long Task 出现在哪里,是 JS 执行、样式计算还是布局。不要凭感觉猜。

第二,减少滚动过程中的响应式写入。滚动回调里如果必须更新状态,用 `requestAnimationFrame` 节流,或者干脆用 CSS 变量、`transform` 直接操作 DOM,绕开 Vue 的更新流程。

第三,冻结不需要响应式的大数据。用 `Object.freeze`、`shallowRef`、`markRaw` 告诉 Vue “这些数据不会变”,省掉 Proxy 或 `defineProperty` 的递归转换开销。

第四,合理拆分组件,善用 `computed` 缓存派生数据,避免在模板里写复杂表达式和函数调用。Vue 3 的 `v-memo`、`v-once` 在合适场景下也能省下大量 diff。

第五,别忘了 CSS。`contain`、`content-visibility`、`will-change` 这些属性不是玄学,它们能直接影响浏览器的渲染决策。

小结

Vue 列表页滚动掉帧,表面看是滚动问题,根子通常在响应式更新范围过大、diff 规模失控、真实 DOM 数量过多,以及滚动事件里做了太多同步工作。优化的核心思路只有一句话:让每一次滚动帧里,主线程要做的事情尽可能少。先定位瓶颈,再选择虚拟滚动、组件拆分、数据冻结或 CSS 隔离,才能把帧率真正拉回来。

本文转载自 阿乐技术社区,原文地址:https://www.leleweb.cn/thread-204.html
转载请注明出处,版权归原作者所有。

全部回复 0

还没有回复,来抢沙发~