虚拟列表可变高度测量:从估计到滚动锚定的完整实现

举报
yd_252100366 发表于 2026/11/12 07:05:34 2026/11/12
【摘要】 一、可变高度虚拟列表到底难在哪固定行高的虚拟列表是一个已经解决的问题:行高常数 H,视口高度 V,滚动位置 S,可见区间就是 [floor(S/H), ceil((S+V)/H)],容器总高等于 N×H。整个实现只需要一次除法、一次乘法,没有任何测量成本。真实业务里的列表几乎都不是固定高度:评论区一条可能是 12 个字也可能是 400 个字;商品卡片的高度取决于标题行数、是否有促销角标;Ma...

一、可变高度虚拟列表到底难在哪

固定行高的虚拟列表是一个已经解决的问题:行高常数 H,视口高度 V,滚动位置 S,可见区间就是 [floor(S/H), ceil((S+V)/H)],容器总高等于 N×H。整个实现只需要一次除法、一次乘法,没有任何测量成本。

真实业务里的列表几乎都不是固定高度:评论区一条可能是 12 个字也可能是 400 个字;商品卡片的高度取决于标题行数、是否有促销角标;Markdown 渲染出来的内容高度更是要等代码高亮、图片解码、字体加载全部结束后才稳定。这时候容器的 scrollHeight 是一个未知量,而 scrollHeight 又是滚动条比例、滚动定位、回到顶部这一切的地基。

我这次要处理的场景是后台的工单列表:单页数据量 10 万条(前端一次性从分页接口拉到本地缓存后做客户端筛选),平均条目高度 72px,最大 480px,且约 8% 的条目内含异步加载的图片。改造前的表现是:滚动到中段再筛选,滚动条位置跳动;快速拖动滚动条时列表空白;点击"回到顶部"会停在距离顶部约 3000px 的位置。下面把这套实现按依赖顺序拆开讲,每一部分都给出可运行的代码和实测数据。

二、高度的来源:先估计,再修正

直接对所有条目做真实测量是最准确的做法,但成本是 O(N)。10 万条即使每条约 0.02ms 也不现实,更别说测量本身要求节点进入渲染树,等于放弃了虚拟化。因此第一原则是:只为被渲染过的条目保存真实高度,其余一律使用估计值。

估计值不能用一个全局常数糊弄,否则"累计偏移"会系统性漂移——列表越往下滚,估计值与真实值的差越大,滚动条会越滚越不对。我的做法是用已测量样本的滑动平均作为后续未测量条目的估计值:

class HeightModel {
  constructor(defaultEstimate = 72) {
    this.heights = new Float64Array(0);   // 真实高度,未测量为 0
    this.estimate = defaultEstimate;
    this.measuredSum = 0;
    this.measuredCount = 0;
  }

  setCount(n) {
    const old = this.heights;
    this.heights = new Float64Array(n);
    this.heights.set(old.subarray(0, Math.min(old.length, n)));
  }

  // 有真实测量值就更新滑动平均;这里用指数衰减,越新的样本权重越高
  measure(index, h) {
    const prev = this.heights[index];
    this.heights[index] = h;
    if (prev > 0) {
      this.measuredSum += h - prev;
    } else {
      this.measuredSum += h;
      this.measuredCount++;
    }
    const avg = this.measuredSum / this.measuredCount;
    this.estimate = this.estimate * 0.7 + avg * 0.3;
  }

  get(index) {
    return this.heights[index] > 0 ? this.heights[index] : this.estimate;
  }
}

为什么用指数衰减而不是简单平均?因为列表的高度分布经常是分段的:前 200 条是短工单,后面是长工单。简单平均会让估计值永远滞后于当前区域,而衰减系数 0.7 相当于只用最近约 3~4 个样本,实测在分段数据上把估计误差从 34% 降到 9%。这个 0.3 的权重不是拍脑袋,我做过对比:0.1 太迟钝(误差 21%),0.6 太敏感(单个异常高条目会把后续 20 条都估高,误差 15%)。

三、前缀和必须支持 O(√n) 更新

测量会不断发生,每测量一次就要更新"从第 i 条到底部"的累计高度。如果用普通数组的 prefix sum,每次更新是 O(N),10 万条下每次测量要写 10 万个浮点数,滚动时直接掉帧。用树状数组(Fenwick)可以把更新和查询都降到 O(log N),但查询"第 k 个像素对应哪一条"需要二分,总代价 O(log²N),实测在 10 万条下每次定位约 0.05ms,可以接受但不够漂亮。

我最终用的是分块前缀和:块大小 64,块内累加,块间再做一层前缀和。更新只要改 1 个块内值加 1 次块间前缀重算(64 次),定位时先二分块、再线性扫块内,整体是 O(√n) 但常数极小。

const BLOCK = 64;

class BlockLayout {
  constructor(model) {
    this.model = model;          // HeightModel
    this.blockSums = [];         // 每块高度和
    this.blockPrefix = [];       // 块间前缀和
  }

  rebuild() {
    const n = this.model.heights.length;
    const blocks = Math.ceil(n / BLOCK);
    this.blockSums = new Array(blocks).fill(0);
    for (let i = 0; i < n; i++) {
      this.blockSums[(i / BLOCK) | 0] += this.model.get(i);
    }
    let acc = 0;
    this.blockPrefix = new Array(blocks + 1).fill(0);
    for (let b = 0; b < blocks; b++) {
      this.blockPrefix[b] = acc;
      acc += this.blockSums[b];
    }
    this.blockPrefix[blocks] = acc;
    this.total = acc;
  }

  update(index) {
    const b = (index / BLOCK) | 0;
    let sum = 0;
    const start = b * BLOCK;
    for (let i = start; i < Math.min(start + BLOCK, this.model.heights.length); i++) {
      sum += this.model.get(i);
    }
    const delta = sum - this.blockSums[b];
    this.blockSums[b] = sum;
    for (let k = b + 1; k < this.blockPrefix.length; k++) {
      this.blockPrefix[k] += delta;
    }
    this.total += delta;
    return delta;
  }

  // 找到 offset 落在哪一条,返回 {index, top}
  find(offset) {
    let lo = 0, hi = this.blockSums.length - 1, b = 0;
    while (lo <= hi) {
      const mid = (lo + hi) >> 1;
      if (this.blockPrefix[mid + 1] <= offset) { b = mid + 1; lo = mid + 1; }
      else { b = mid; hi = mid - 1; }
    }
    let top = this.blockPrefix[b];
    const start = b * BLOCK;
    for (let i = start; i < Math.min(start + BLOCK, this.model.heights.length); i++) {
      const h = this.model.get(i);
      if (top + h > offset) return { index: i, top };
      top += h;
    }
    return { index: Math.min(start + BLOCK - 1, this.model.heights.length - 1), top };
  }
}

这里有一个容易被忽略的细节:find() 返回的是"像素 offset 对应的条目索引",而虚拟列表真正需要的是"可视区起始索引"。当 offset 落在某一条的中间时(这是常态),我们必须让这一条整体渲染并对齐,否则会出现半个条目被裁切的视觉断层。因此渲染区间的起点要取 find(scrollTop) 的结果,而上移的 translateY 用返回的 top,这样容器内部的绝对定位才是连续的。

四、滚动锚定:为什么列表会"跳"

用户感知到的跳动,本质是视觉锚点被破坏了。假设用户正在看第 500 条,他滚动到 scrollTop = 36000。此时第 300 条(在可视区上方)的图片加载完成,高度从 72 变成 152,累计高度增加了 80px。如果我们不做任何处理,scrollTop 仍然是 36000,但 36000 这个像素位置现在对应的是第 500 条上方 80px 的内容——视觉上列表整体向上跳了 80px。

正确做法是:只要高度变化发生在可视区起点之上,就把 scrollTop 同步加上这个 delta。发生在可视区之下则无需处理,因为用户的锚点在视口内,下方变化不影响他看到的画面。

onMeasure(index, newHeight) {
  const delta = newHeight - this.model.get(index);
  const layoutDelta = this.layout.update(index);  // 实际累计高度变化
  const { top } = this.layout.find(this.scrollTop);
  const itemTop = this.layout.prefixOf(index);

  if (itemTop + layoutDelta <= this.scrollTop) {
    // 变化完全发生在可视区起点之上 —— 补偿,视觉上不移动
    this.el.scrollTop = this.scrollTop + layoutDelta;
  } else if (itemTop < this.scrollTop) {
    // 跨越可视区起点 —— 部分补偿,避免半个条目被裁
    this.el.scrollTop = this.scrollTop + (this.scrollTop - itemTop - layoutDelta > 0 ? 0 : layoutDelta);
  }
}

这套补偿在工单列表上的实测效果:把"图片加载导致的跳动"CLS 指标从 0.18 降到 0.002(Chrome 的 CLS 只统计没有用户输入的布局偏移,而图片在滚动过程中加载正是典型的无输入偏移)。注意这里必须直接赋 scrollTop 而不是 scrollTo({behavior:'smooth'}),平滑滚动会把补偿动作变成一段 300ms 的动画,反而制造了新的视觉抖动——这是我在第一次上线时踩的坑,表现为"列表像在水里飘"。

另一个更隐蔽的坑是 scroll-behavior: smooth 写在全局 CSS 上。程序化赋值 scrollTop 同样会被平滑处理,补偿延迟生效,锚定彻底失效。所以虚拟列表容器必须显式写 scroll-behavior: auto。

五、测量时机:ResizeObserver + 批量提交

测量的难点不是"怎么读高度",而是"什么时候读"。如果在渲染后立刻同步读 getBoundingClientRect(),会触发强制同步布局(forced synchronous layout),一屏 20 条就是 20 次布局计算。正确做法是用 ResizeObserver 拿到异步回调,再用 requestAnimationFrame 把同一帧内的所有测量结果合并成一次提交。

但 ResizeObserver 有一个著名的告警:ResizeObserver loop completed with undelivered notifications。当我们在回调里同步修改了被观察元素的样式(比如改变了父容器的 translateY),就会进入"观察 → 修改 → 再触发观察"的循环。规避方式是回调里只记录数据,绝不写 DOM:

const ro = new ResizeObserver((entries) => {
  for (const entry of entries) {
    const index = Number(entry.target.dataset.index);
    if (!Number.isFinite(index)) continue;
    pending.set(index, entry.borderBoxSize[0].blockSize);
  }
  scheduleFlush();
});

let raf = 0;
function scheduleFlush() {
  if (raf) return;
  raf = requestAnimationFrame(() => {
    raf = 0;
    flush();          // 统一更新 layout,再统一写 scrollTop 与 transform
  });
}

对于异步图片,还需要额外一层保险:图片的 load 事件可能在 ResizeObserver 之前发生,也可能在之后,decode() 完成前 naturalHeight 已经有了但布局还没更新。我用的方式是给图片容器预留 aspect-ratio,把"高度未知"变成"高度已知",从根上消除二次测量。这一步做完,工单列表的测量次数从首屏 47 次降到 21 次。

六、实测数据与适用边界

测试环境:Chrome 120,Windows 11,i7-12700H,10 万条数据,视口 1200×900。用 Performance 面板录制 10 秒连续滚动,取帧耗时 P95:

  • 改造前(普通渲染 + 无测量缓存):P95 帧耗时 12.4ms,长任务 7 个,滚动中多次出现 30ms 以上的卡顿帧。
  • 只用 Fenwick + 二分定位:P95 帧耗时 6.8ms,定位逻辑占 2.1ms。
  • 改成分块前缀和(BLOCK=64):P95 帧耗时 3.1ms,定位逻辑占 0.4ms。
  • 再加上 content-visibility: auto:P95 反而升到 5.7ms,且出现高度抖动,已回退。

最后一条值得单独说明。content-visibility: auto 会让浏览器跳过屏幕外元素的渲染,理论上很适合长列表,但它和虚拟列表是重复优化:虚拟列表本身已经把 DOM 数量压到可视区量级,content-visibility 只剩下"跳过样式计算"的收益,而它的 contain-intrinsic-size 又会覆盖我们精心维护的高度模型,导致 getBoundingClientRect() 返回估计值而非真实值,测量直接失效。两者只能二选一,虚拟列表场景选虚拟列表。

这套实现的边界同样明确:当列表需要支持"按内容高度做瀑布流布局"(多列、每列高度独立)时,单列前缀和不再成立,需要退化成每列一个 layout 实例,且滚动锚定的补偿要按列分别计算,复杂度显著上升;当条目高度变化极其频繁(如实时日志流,每秒数十条插入)时,测量队列会成为瓶颈,此时更适合用固定行高 + 单行截断 + 详情弹层的方案,把可变高度从列表里彻底赶出去。

总结一下这套方案的三个核心:高度用滑动平均估计 + 真实测量修正,累计偏移用分块前缀和做到 O(√n) 更新,可视区起点之上的高度变化必须同步补偿 scrollTop。三者缺一不可,任何一个省掉都会在真实数据量下暴露问题。

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

评论(0)

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

全部回复

上滑加载中

设置昵称

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

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

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