跳到主内容

十万行列表不掉帧:虚拟列表从定高到动态高度的渐进优化实录 ​

运维后台的日志查询页,一次检索返回十万条记录,页面直接白屏十几秒才能交互,滚动像在泥里游泳——这是我接手那个项目时最真实的体感。用 Lighthouse 一跑,DOM 节点数审计直接红牌告警。问题不难诊断:十万条数据全量渲染成 DOM,浏览器还没开始布局就已经不堪重负。

而用户同一时刻真正看得见的,不过屏幕里的二三十条。虚拟列表的思路由此而来:只渲染可视区附近的条目,其余数据留在内存里不进 DOM。本文按「定高 → 动态高度 → 现成库」的顺序,记录这次渐进优化的完整路径,每一步都是可以直接抄走的最小实现。

为什么普通长列表必然卡 ​

先明确卡顿的三重来源,优化才有靶子。节点数量:十万条列表项连同内部结构轻松产生百万级 DOM 节点,每个节点都占内存、都参与样式计算,Lighthouse 对超大 DOM 的审计阈值不过千余节点,十万行的页面超限几十倍。布局与重绘:滚动触发的每一帧都要重新计算可视部分的位置,DOM 越大,样式重算与布局树维护的成本越高,帧率从 60 直线下探。一次性构建:首次渲染十万个节点是一个巨型长任务,主线程被独占,输入事件全部排队。

虚拟列表把三者一并解决:DOM 数量恒定为一屏几十条,滚动时只是替换渲染窗口内的数据,首次渲染从「十万节点长任务」变成「几十节点 instant」。代价是维护一套「滚动位置 ↔ 数据索引」的映射逻辑,复杂度都藏在这套映射里——下面的三级实现,就是在不同精度要求下维护这套映射。

第一级:定高虚拟列表的最小实现 ​

如果列表项高度固定(表格行、统一卡片),映射关系只是一次除法。用 Vue3 组合式函数实现核心逻辑,二十行以内:

ts
// useFixedSizeVirtualList.ts:定高虚拟列表核心
import { ref, computed } from "vue";

export function useFixedSizeVirtualList(itemHeight: number, viewportHeight: number, total: number) {
  const scrollTop = ref(0);
  // 可视区起始索引:滚动偏移除以行高,向上多渲染 3 行做缓冲
  const start = computed(() => Math.max(0, Math.floor(scrollTop.value / itemHeight) - 3));
  // 窗口内条数:可视高度切分 + 上下各 3 行缓冲,防止快速滚动露白
  const visibleCount = Math.ceil(viewportHeight / itemHeight) + 6;
  const end = computed(() => Math.min(total, start.value + visibleCount));
  // 用 transform 把列表整体推移到当前窗口位置,替代海量占位节点
  const offsetY = computed(() => start.value * itemHeight);
  return { scrollTop, start, end, offsetY };
}

模板侧的结构是三层:外层容器固定高度并监听滚动;内层撑起一个 total * itemHeight 高的占位层(让滚动条长度真实);渲染层只输出 start ~ end 的切片并应用 transform: translateY(offsetY)。接入后十万行数据的 DOM 稳定在 30 个节点上下,滚动帧率恢复满帧。这一级实现没有外部依赖,适合快速验证收益,也是理解虚拟列表原理的最佳入口。

第二级:动态高度——测量与位置缓存 ​

真实业务里定高是奢侈品:聊天消息、评论、日志详情,行高各不相同。动态高度的核心矛盾是「没渲染过的条目不知道多高」,业界标准解法是三步:先用预估高度渲染,渲染后实测,再缓存进位置索引。

维护一个偏移量数组 offsets,其中 offsets[i] 是第 i 条数据的顶部位置(前缀和)。已测量的条目用真实高度,未测量的用预估高度补齐。滚动时要在有序数组里找到「scrollTop 落在哪两条之间」,线性查找在十万级数据下太慢,二分是标配:

ts
// 在偏移量前缀和(升序)中二分查找 scrollTop 对应的起始索引
function findStartIndex(offsets: number[], scrollTop: number): number {
  let lo = 0;
  let hi = offsets.length - 1;
  while (lo < hi) {
    const mid = (lo + hi + 1) >> 1; // 上取整,避免两元素时死循环
    if (offsets[mid] <= scrollTop) {
      lo = mid;      // 该条顶部在视口上方,起始索引只可能更靠后
    } else {
      hi = mid - 1;
    }
  }
  return lo;
}

测量时机决定这套方案的精度:条目挂载后用 getBoundingClientRect 实测一次;图片加载完成、内容异步填充后高度会变化,需要监听并二次修正缓存,同步更新其后的所有前缀和。预估高度不需要多准——只要误差不大,滚动过程中会逐步被真实值替换,位置索引随之自愈。

第三级:成熟库选型,别重复造轮子 ​

第一、二级的实现是为了讲透原理。真实项目里,除非有极特殊的交互定制,优先用成熟库:

方案特点适用场景
@tanstack/virtualHeadless 设计,无内置 UI,React/Vue/Solid 多框架支持需要完全掌控渲染结构与样式
vue-virtual-scrollerVue 生态开箱即用,提供 RecycleScroller 等组件Vue 项目快速接入
原生 CSS content-visibility浏览器内置跳过屏外渲染,非严格虚拟列表长页面分段渲染,接受近似方案

Headless 库的好处是把「滚动锚定、动态测量、缓冲管理」这些易错细节都替你处理了,同时不限制你的 DOM 结构;开箱即用组件则胜在十分钟上线。那个日志页最终用了 @tanstack/virtual:因为需求里混着行内编辑、吸顶分组头,自绘结构更可控。分组吸顶 + 虚拟列表的组合要格外小心——分组头的偏移和列表偏移是两套坐标,先各自单独验证,再组合调试。

避坑总结 ​

  1. 缓冲区太窄会滚动露白。只渲染可视区一条余量都不留,快速滚动时新内容来不及渲染就是白屏;缓冲至少半屏,滚动速度快的场景(长日志、行情列表)按速度动态扩缓冲。
  2. 锚点漂移必须补偿 scrollTop。向上插入数据(下拉加载历史消息)会让原有条目的偏移整体后移,若不把新增高度补偿进 scrollTop,页面会瞬间跳动;这正是聊天类界面最容易翻车的点。
  3. 异步内容要二次修正。图片、富文本懒加载完成后行高变化,位置缓存如果不更新,后续滚动定位会持续积累误差;监听内容尺寸变化(ResizeObserver)并同步前缀和。
  4. 别用模拟滚动替代原生滚动。有的实现为了省事用鼠标事件自己驱动偏移,键盘 PageDown、End、无障碍阅读器全部失效;容器滚动交给原生,虚拟列表只做「渲染窗口计算」。
  5. 滚动事件要配合 rAF 节流。scroll 事件触发频率高于帧率,回调里直接算索引会重复计算;用 requestAnimationFrame 对齐渲染帧,一轮只算一次窗口。

写在最后 ​

十万行列表的治理路径其实是一条精度阶梯:定高除法 → 动态测量 + 二分索引 → 成熟库接管。理解了「只渲染可视窗口 + 维护位置映射」这个核心模型,三级方案不过是同一思想的不同实现成本。性能优化的复盘里最值得记住的,是先用数据确认瓶颈(DOM 数量、长任务)再动手——如果那十万条数据真正的问题在渲染策略之外,虚拟列表修得再好也白搭。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。