长列表渲染优化实战:虚拟滚动从原理到 60fps 的落地细节

举报
yd_252100366 发表于 2026/10/25 07:05:43 2026/10/25
【摘要】 在后台管理系统里,把「每页 20 条」改成「一次展示全部 5 万条」,往往只改一个分页参数,但浏览器会用几乎卡死的滚动体验告诉你这不是零成本的改动。本文从渲染成本的真实来源出发,给出一套可直接落地的虚拟滚动实现,并讨论几个容易被忽略的边界问题。一、卡顿的来源不是数据,而是 DOM 与布局很多人的第一反应是数据量太大,于是先做数据分页、冻结对象、不可变更新。但真正消耗时间的是渲染管线:五万个 ...

在后台管理系统里,把「每页 20 条」改成「一次展示全部 5 万条」,往往只改一个分页参数,但浏览器会用几乎卡死的滚动体验告诉你这不是零成本的改动。本文从渲染成本的真实来源出发,给出一套可直接落地的虚拟滚动实现,并讨论几个容易被忽略的边界问题。

一、卡顿的来源不是数据,而是 DOM 与布局

很多人的第一反应是数据量太大,于是先做数据分页、冻结对象、不可变更新。但真正消耗时间的是渲染管线:五万个 <tr> 意味着五万个布局盒子,样式重算(Recalc Style)、布局(Layout)、绘制(Paint)与合成(Composite)都会随节点数近似线性增长。即便只改动某个单元格的文字,浏览器也可能需要遍历受影响的整棵布局子树。

在本机实测(Chrome 128 / M2 / 1440×900 视口)中,一次性渲染 50000 行固定高度表格,首次可交互耗时约 3.8 秒,滚动过程中平均帧率跌到 18fps 左右,DevTools 的 Performance 面板里 Layout 与 Recalculate Style 各占三成以上时间。换成虚拟滚动后,首次可交互降到 90ms 量级,滚动稳定在 55–60fps。

二、虚拟滚动的核心模型

虚拟滚动本质上只回答三个问题:当前视口需要显示哪几行?这些行应该画在什么位置?滚动条的总高度如何"假装"成完整列表?

给定固定行高 H、容器视口高度 Vh、滚动距离 scrollTop,可以推出三个量:起始索引 start = floor(scrollTop / H);可见行数 visible = ceil(Vh / H);再向上下各留 overscan 行做缓冲,就得到真实渲染区间 [start − overscan, start + visible + overscan]。overscan 的作用是让快速滚动时缓冲区不会立刻露白,取 3 到 5 行通常足够。

DOM 结构一般分三层:最外层是滚动容器(overflow-y: auto);内层放一个高度为 total = count × H 的撑高元素,让原生滚动条长度与滚动比例保持正确;真正的行放在一个绝对定位的容器里,通过 transform: translateY(start × H) 整体平移。

这里用 transform 而不是逐行修改 top,原因在于 transform 只触发合成阶段的位移,不参与布局重算;而 top 会进入布局流程,在滚动这种每秒几十次的场景里差异非常明显。同理,撑高元素也尽量不设置内边距,避免盒模型计算带来的额外开销。

三、滚动事件的处理策略

scroll 事件的触发频率与设备刷新率相关。直接在回调里 setState,会让 React 的调度器在高频输入下不断堆积任务,反而是卡顿的主因之一。稳妥的做法是把状态更新收进 requestAnimationFrame,确保每帧最多计算一次;同时做"索引没变就跳过"的短路判断,避免渲染区间未变化却重复触发更新。

另一个细节是使用被动监听({ passive: true })。浏览器不必等待监听器执行完毕就能开始滚动,这在移动端触屏场景里体感差异显著。

如果列表还要处理 sticky 表头或横向同步滚动,记得把横向滚动与纵向滚动拆成两个独立的订阅源,不要在同一次回调里读写双方的布局属性,否则很容易意外触发强制同步布局(forced synchronous layout)。

四、行高不固定怎么办

固定行高的虚拟滚动是"算出来的",动态行高只能"量出来的"。常见做法是维护一个前缀和数组 offsets,记录每一行之前的累计高度;渲染时用二分查找定位 start,复杂度为 O(log n)。行高通过 ResizeObserver 逐行观测,测量结果写回 offsets 并触发一次重排。

代价是复杂度显著上升:数据插入、删除都要同步更新前缀和;快速滚动时尚未渲染的行没有真实高度,只能先用估算值占位,滚动到附近再修正,于是会产生轻微的"跳动"。工程上更常见的折中是限制行高上限,例如把文本截断为最多三行,让行高落在有限集合内,用分段前缀和把问题简化到可接受范围。

五、几个必须处理的边界

  • 滚动抖动:overscan 过大且每帧重算时,容易出现滚动条微抖。解决办法是让渲染区间按块对齐(例如每 10 行一块),块内不重算,块边界才更新。
  • 位置恢复:从详情页返回列表希望停在原位,需要在离开前记录第一个可见行的业务 ID,而不是 scrollTop,因为期间数据可能已经增删。
  • 键盘与无障碍:虚拟列表会破坏 tab 顺序与屏幕阅读器的行计数。至少要为容器补上 aria-rowcount 与 aria-rowindex,并在聚焦元素被滚出视口时主动修正区间。
  • 全选与批量操作:用户点"全选"选中的是数据集合而非 DOM 集合,选中状态必须存在数据层,否则滚出视口的行会丢失选中态。
  • 首屏 SSR:服务端不知道视口高度,通常先渲染固定行数,客户端 hydrate 后再校正,需要留意 hydration 不匹配告警。

六、与 CSS content-visibility 的取舍

如果列表结构简单、行高固定,并且不需要精确控制渲染数量,content-visibility: auto 配合 contain-intrinsic-size 是成本极低的方案:浏览器会自动跳过视口外元素的渲染工作,几乎零改造。但它的可控性弱于手写虚拟滚动,无法精确控制缓冲行数,对复杂表格的测量粒度也不够细,且部分版本在滚动时仍会触发额外布局计算。

因此可以这样分工:结构简单的长文本列表先试 content-visibility;需要稳定帧率、精确控制 DOM 数量、或表格内嵌大量交互组件的场景,仍然应该自己实现虚拟滚动。两者也可以叠加使用,虚拟滚动负责数量级控制,content-visibility 负责单行内部复杂度的抑制。

七、结语

虚拟滚动的本质是一次"用计算换渲染"的权衡:把浏览器原本要做的布局工作,提前用几个除法、取整和二分查找在 JavaScript 里算掉。它并不复杂,难的是把滚动位置、行高变化、选中状态与无障碍这些相互牵连的细节处理干净。先把固定行高版本做对,再考虑动态测量,通常是最稳的路径。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。