React 虚拟化滚动进阶:处理动态高度项(Dynamic Height)的预估偏移与滚动抖动消除

各位前端同仁,下午好!

欢迎来到今天的“React 虚拟化滚动进阶”现场。我是你们的老朋友,一个在浏览器里搬砖搬了多年的“资深”专家。

今天我们不聊那些花里胡哨的框架新特性,也不聊怎么把 TypeScript 写得像 TypeScript。我们要聊一个痛点,一个让无数新手抓耳挠腮、让资深工程师半夜惊醒的“千古难题”——虚拟化滚动中的动态高度项

如果你还在用 map 循环渲染几千条数据,恭喜你,你的浏览器内存正在哭泣,你的用户正在疯狂点击“返回上一页”。但如果你已经实现了虚拟化,却发现列表里的图片、卡片高度各不相同,滚动时列表像得了帕金森一样抖动,那……好吧,至少你在正确的路上,只是你还没找到那把钥匙。

今天,我们就来把这把钥匙——动态高度预估与抖动消除——彻底掰开揉碎了讲。


第一部分:为什么“动态高度”是个坑?

我们先来回顾一下。静态高度,多简单啊。就像盖俄罗斯方块,你心里清楚每个方块都是 20×20 像素。计算偏移量?小学数学题。scrollTop / itemHeight,完事。

但是,现实世界不是俄罗斯方块,现实世界是乱七八糟的 HTML。

想象一下你的列表项:

  1. 一张 400×400 的美女照片。
  2. 一段 5 行的文本,高度 60px。
  3. 一个复杂的表单,高度 120px。
  4. 一个只有两行字的摘要,高度 30px。

这就是动态高度。每个 Item 的 clientHeight 都是不一样的。

当我们滚动列表时,浏览器需要知道:“嘿,我现在滚到了位置 Y,这个位置底下压着的是第几行数据?”

如果高度是固定的,这很简单。但如果是动态的,你就得知道从第 1 行到第 100 行,它们的总高度加起来是多少。如果你算错了,好,你的列表会跳变;如果你算得太慢,你的页面就会卡顿。

这就是我们今天要解决的两个核心问题:

  1. 如何快速、准确地计算偏移量?
  2. 如何消除那些令人作呕的抖动?

第二部分:偏移量的艺术——从暴力遍历到二分查找

1. 暴力破解法(不可取)

最朴素的想法是:既然不知道高度,那就每次滚动都算一遍呗。

function getOffsetForIndex(items, index) {
  let offset = 0;
  for (let i = 0; i < index; i++) {
    offset += items[i].height; // 假设我们拿到了真实高度
  }
  return offset;
}

听起来很合理对吧?如果你有 100 个项目,没问题。如果你有 100,000 个项目,而且你滚动的速度很快,这个函数会被调用成千上万次。每次调用都要循环几千次,浏览器直接给你表演一个“假死”。

2. 策略性猜测(动态高度的核心)

既然不能每次都从头算,那我们怎么算?

这里我们引入一个核心概念:预估

因为动态高度项目很多,你不可能在渲染前就把所有项目的高度都测出来(那不叫虚拟化,那叫全量渲染)。所以,我们通常会有一个“预估高度”。

比如,我们规定一个 estimatedItemSize = 100

当我们想找第 50 个项目在哪里时,我们会说:“嘿,前 49 个项目平均高度是 100,所以第 50 个项目大概在 4900px 的位置。”

但是,这是“大概”。如果第 50 个项目其实只有 50px,那我们的偏移量就错了。

这就引出了“抖动”的来源。

3. 进阶技巧:缓存与二分查找(二分查找法)

为了优化性能,我们不能每次都暴力循环。我们需要一种数据结构来记录高度。

const itemSizeMap = new Map(); // key: index, value: height

但是,每次都要遍历 Map 找 offset 吗?Map 的遍历也是 O(N)。

这时候,我们的老朋友——二分查找 就要登场了。是的,二分查找不仅用于排序,也用于快速定位。

如果我们把数据按高度排个序(或者更常见的是,我们维护一个有序的索引列表,记录了每个项目的真实高度),我们就可以在 O(log N) 的时间内找到偏移量。

但是! 在 React 虚拟化中,我们通常不需要那么复杂的排序。我们通常采用一种更实用的“累积高度数组”策略,或者简单的“缓存计算结果”。

不过,为了今天的讲座深度,我们来点更硬核的。

核心逻辑:

  1. 我们维护一个 heightCache
  2. 当我们首次渲染一个项目时,用 ResizeObservergetBoundingClientRect 拿到它的真实高度。
  3. 更新 heightCache
  4. 当滚动时,如果发现 heightCache 不够,我们再动态计算。

这听起来很完美,但有个坑:初始渲染时,heightCache 是空的! 那怎么办?

答案就是:初始渲染必须用预估值,渲染后再修正。 这就是“抖动”的温床。


第三部分:抖动——是幽灵,还是恶魔?

让我们来模拟一下抖动的产生过程。

场景: 列表有 1000 项,预估高度是 100px。
操作: 用户滚动到第 50 项。

  1. 渲染阶段:

    • 滚动位置:5000px。
    • 算法认为:5000 / 100 = 第 50 项。
    • 算法计算偏移量:0 + 100 + … + 100 = 5000px。
    • 结果: 第 50 项被渲染在 5000px 的位置。
  2. 测量阶段(下一帧):

    • React 渲染完 DOM。
    • ResizeObserver 触发,告诉我们要第 50 项的真实高度是 60px(而不是 100px)。
    • 我们更新了状态,告诉列表:“嘿,刚才那个位置不对,现在偏移量要减去 40px。”
  3. 重绘阶段:

    • 列表发现状态变了。
    • 它重新计算偏移量:应该是 4960px。
    • 它重新渲染。
    • 视觉结果: 第 50 项从 5000px 跳到了 4960px。往下跳了 40px。
    • 用户体验: 咦?怎么抖了一下?

这就是“抖动”。如果这种跳变发生在滚动过程中,用户会觉得你的软件像是在“抽筋”。


第四部分:如何消灭抖动?(干货时间)

消灭抖动,我们需要一套组合拳。

1. 增加缓冲区

这是最简单、最有效的“掩耳盗铃”法。

不要只渲染 Math.floor(scrollTop / estimatedSize) 附近的几个项目。多渲染几个!

比如,你只渲染当前可见的 10 个,加上可见区域上下各 5 个。这样,当真实高度测量出来并导致偏移量变化时,受影响的只是那些多渲染的“缓冲区”项目,而不是正在滚动的核心项目。

代码示例:

const buffer = 5; // 缓冲区大小

const startIndex = Math.max(0, Math.floor(scrollTop / estimatedSize) - buffer);
const endIndex = Math.min(totalItems, startIndex + visibleCount + buffer * 2);

虽然这增加了 DOM 节点数量,但换来的是极其稳定的体验。

2. 使用 ResizeObserver 而不是 onResize

window.onresize 是老大爷用的,太慢了,而且还会触发防抖,导致测量延迟。

ResizeObserver 是现代浏览器的亲儿子。它直接监听 DOM 节点大小变化。当项目内容变长(比如图片加载完)或者变短时,立即通知我们。

关键点: 必须在 useEffect 的依赖里加上 ResizeObserver,确保组件卸载时取消监听,防止内存泄漏。

3. useMemo 的神助攻

这是最容易被忽视的一点。

在计算偏移量时,如果计算逻辑里包含了大量的循环或复杂判断,一定要用 useMemo 包裹。

为什么?因为 React 的渲染是同步的。如果每次滚动事件都触发一个复杂的计算函数,那么在计算完成之前,浏览器无法渲染下一帧,导致滚动卡顿。抖动往往伴随着卡顿。

const totalHeight = useMemo(() => {
  let height = 0;
  for (let i = 0; i < visibleEndIndex; i++) {
    height += getItemSize(i);
  }
  return height;
}, [visibleEndIndex, getItemSize]); // 依赖变化才重算

4. “交错测量”策略

这是高端玩家的玩法。

不要等所有项目都渲染完了再测。项目渲染出来了,它的大小也就确定了(比如图片加载完了)。

我们可以设置一个 measureTimeout。当项目进入视口时,不要立刻测量。等 100ms(比如下一帧)再测量。

如果测量结果和预估值有差异,不要直接修改 scrollTop(那样会导致页面跳动),而是把这个差异记录下来,作为一个“修正值”,应用到下一次渲染中。


第五部分:实战代码——打造一个健壮的 useDynamicVirtual

好了,理论讲得差不多了,口水都流干了。现在,我们手把手写一个能处理动态高度、能消除抖动的 Hook。

这个组件需要解决以下问题:

  1. 动态高度计算
  2. 滚动位置计算
  3. 基于二分查找或累积数组的偏移量计算
  4. 防抖处理
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';

// 1. 模拟数据生成器
const generateItems = (count) => {
  return Array.from({ length: count }, (_, i) => ({
    id: i,
    content: `这是一条动态长度的数据,内容长度随机变化。第 ${i + 1} 条。`,
    // 模拟不同高度:有的短,有的长(图片或大文本)
    height: i % 5 === 0 ? 200 : (i % 3 === 0 ? 300 : 100)
  }));
};

const DynamicVirtualList = ({ itemCount = 1000, containerHeight = 600 }) => {
  const [items, setItems] = useState(generateItems(itemCount));

  // 状态管理
  const [scrollTop, setScrollTop] = useState(0);
  const [startIdx, setStartIdx] = useState(0);
  const [endIdx, setEndIdx] = useState(0);

  // 核心数据结构:存储真实高度
  const itemSizeMapRef = useRef(new Map());
  const containerRef = useRef(null);

  // 预估高度,用于初始计算
  const ESTIMATED_SIZE = 100;

  // 2. 计算偏移量的函数 (使用二分查找逻辑的简化版,为了演示清晰,这里用线性查找+Map缓存优化)
  // 在生产环境中,建议对 itemSizeMap 进行排序或使用二分查找
  const getOffsetForIndex = useCallback((index) => {
    let offset = 0;
    for (let i = 0; i < index; i++) {
      // 优先从 Map 取真实高度,没有则用预估高度
      offset += itemSizeMapRef.current.get(i) || ESTIMATED_SIZE;
    }
    return offset;
  }, []);

  // 3. 核心:计算可见区域
  useEffect(() => {
    // 计算起始索引
    // 这里的逻辑是:找到第一个使得 offset >= scrollTop 的索引
    let low = 0;
    let high = itemCount;
    let start = 0;

    while (low < high) {
      const mid = Math.floor((low + high) / 2);
      const offset = getOffsetForIndex(mid);

      if (offset < scrollTop) {
        low = mid + 1;
      } else {
        high = mid;
      }
    }

    // 修正:如果正好等于,可能要再往前找找,防止切分点在半空中
    // 这里简化处理
    let calculatedStart = Math.max(0, low - 1);

    // 4. 增加缓冲区
    const buffer = 2; 
    calculatedStart = Math.max(0, calculatedStart - buffer);

    // 计算结束索引
    let end = calculatedStart;
    let totalHeight = 0;

    // 简单的循环累加,直到超出容器高度
    while (totalHeight < containerHeight + scrollTop && end < itemCount) {
      totalHeight += itemSizeMapRef.current.get(end) || ESTIMATED_SIZE;
      end++;
    }

    setStartIdx(calculatedStart);
    setEndIdx(Math.min(end, itemCount));

  }, [scrollTop, itemCount, containerHeight, getOffsetForIndex]);

  // 5. 处理滚动事件
  const handleScroll = useCallback((e) => {
    // 使用 requestAnimationFrame 优化滚动性能
    requestAnimationFrame(() => {
      setScrollTop(e.target.scrollTop);
    });
  }, []);

  // 6. 测量真实高度 (ResizeObserver)
  useEffect(() => {
    const observer = new ResizeObserver((entries) => {
      for (let entry of entries) {
        const { target } = entry;
        const index = target.dataset.index;
        const height = entry.contentRect.height;

        if (height > 0) {
          // 只有当高度变化且不为0时才更新
          itemSizeMapRef.current.set(Number(index), height);
        }
      }
    });

    // 监听所有可视区域内的元素
    const visibleElements = containerRef.current?.querySelectorAll('[data-index]');
    if (visibleElements) {
      visibleElements.forEach(el => observer.observe(el));
    }

    return () => observer.disconnect();
  }, [startIdx, endIdx]);

  // 7. 计算总高度
  const totalHeight = useMemo(() => {
    let h = 0;
    for (let i = 0; i < itemCount; i++) {
      h += itemSizeMapRef.current.get(i) || ESTIMATED_SIZE;
    }
    return h;
  }, [itemCount]);

  return (
    <div style={{ height: '100vh', display: 'flex', flexDirection: 'column', fontFamily: 'sans-serif' }}>
      <h2>React 动态高度虚拟化列表</h2>

      {/* 外层容器,撑开总高度 */}
      <div 
        ref={containerRef}
        style={{ 
          height: `${containerHeight}px`, 
          overflowY: 'auto', 
          position: 'relative',
          border: '2px solid #ccc',
          background: '#f0f0f0'
        }}
        onScroll={handleScroll}
      >
        {/* 占位符:撑开滚动条,防止高度塌陷 */}
        <div style={{ height: `${totalHeight}px`, position: 'absolute', top: 0, left: 0, width: '1px' }}></div>

        {/* 实际渲染区域 */}
        <div style={{ position: 'absolute', top: 0, left: 0, width: '100%' }}>
          {items.slice(startIdx, endIdx).map((item) => {
            const offset = getOffsetForIndex(item.id);

            return (
              <div
                key={item.id}
                data-index={item.id}
                style={{ 
                  height: `${itemSizeMapRef.current.get(item.id) || ESTIMATED_SIZE}px`,
                  position: 'absolute',
                  top: `${offset}px`,
                  width: '100%',
                  backgroundColor: item.id % 2 === 0 ? '#fff' : '#f9f9f9',
                  border: '1px solid #eee',
                  padding: '10px',
                  boxSizing: 'border-box',
                  overflow: 'hidden',
                  textOverflow: 'ellipsis',
                  whiteSpace: 'nowrap'
                }}
              >
                {item.content}
              </div>
            );
          })}
        </div>
      </div>

      <div style={{ padding: '10px' }}>
        当前渲染范围: {startIdx} - {endIdx} (共 {endIdx - startIdx} 项)
      </div>
    </div>
  );
};

export default DynamicVirtualList;

第六部分:深度解析代码中的“黑魔法”

上面的代码跑起来了吗?如果跑起来了,恭喜你,你已经实现了一个基础版。

但让我们来聊聊代码里那些容易翻车的地方。

1. position: absolute 的奥秘

你可能会问:“为什么不用 marginTop?”

如果用 marginTop,浏览器在计算布局时需要重新计算每个元素的偏移。对于虚拟化列表,这意味着每次滚动,浏览器都要重新计算几千个元素的位置。这会导致巨大的性能开销。

而使用 position: absolute 并手动计算 top 值,我们是在告诉浏览器:“我知道它在哪里,你别管了,直接画上去。” 这就是性能优化的精髓——减少浏览器的重排

2. ResizeObserver 的异步陷阱

注意看 useEffect 里的 ResizeObserver。

const observer = new ResizeObserver((entries) => { ... });

当 DOM 更新后,ResizeObserver 会回调。但是,这个回调是在浏览器的渲染循环里执行的。

如果我们在回调里直接修改了 React 的 state(比如 setStartIdx),React 会触发一个重渲染。重渲染会再次计算偏移量。

这时候,如果两个状态更新同时发生,可能会导致计算逻辑混乱。

最佳实践:
ResizeObserver 的回调只负责更新 itemSizeMapRef(Map 是可变的,不需要触发重渲染)。只有当所有测量都完成(或者至少当前可见区域的测量完成)后,才通过某种机制去更新 scrollTop 或者触发一次重绘。

在我们的代码里,我们利用了 getOffsetForIndex 的读取机制。只要 itemSizeMapRef 更新了,下一次 useEffect 计算 startIdx 时,就会自动使用新的高度,从而修正位置。这实际上是一种“反应式”的修正方式,非常优雅。

3. 滚动条抖动

有时候你会发现,列表不动,但滚动条自己在动。

这是因为我们的 totalHeight 是基于 itemSizeMap 计算的,而 itemSizeMap 是异步更新的。

如果图片加载慢,itemSizeMap 是空的,totalHeight 就会偏小。滚动条就上不去。

修复方案:
totalHeight 的计算逻辑里,对于未测量的项,必须使用一个“保底高度”(比如图片的最小高度 50px)。不要用 0,否则高度会塌陷。


第七部分:进阶优化——从“能用”到“好用”

现在我们的代码能跑,也不抖了。但作为一个资深专家,我们还能做得更好。

1. 窗口调整大小

别忘了 window.resize。当容器高度改变时,我们需要重新计算可见范围。

useEffect(() => {
  const handleResize = () => {
    // 获取新的容器高度
    const newHeight = containerRef.current.clientHeight;
    // 重新触发计算逻辑...
  };
  window.addEventListener('resize', handleResize);
  return () => window.removeEventListener('resize', handleResize);
}, []);

2. 懒加载图片

如果你的动态高度项目里有图片,图片加载完之前,高度是未知的。

这就形成了一个死循环:

  1. 图片没加载,高度是 0(或默认 100)。
  2. 列表渲染,位置正确。
  3. 图片加载完,触发 ResizeObserver。
  4. 高度变成 200。
  5. 列表重排,位置跳变。

解决方案:
在图片加载前,给图片容器设置一个 min-height。等图片加载完,ResizeObserver 会把高度从 min-height 调整为真实高度。虽然会有跳变,但因为 min-height 已经占位了,视觉上会平滑很多。

3. 性能监控

在生产环境中,如何知道虚拟化是否生效?

你可以加一个 console.log,打印每次渲染的 startIdxendIdx

如果 endIdx - startIdx 始终保持在 20 左右,说明虚拟化工作正常。
如果它随着滚动一直增加到 1000,说明你渲染了太多项目,虚拟化失效了。


第八部分:总结——成为虚拟化大师

好了,各位,今天的讲座就到这里。

回顾一下我们今天攻克了什么:

  1. 理解了动态高度的复杂性:它不是简单的加减法,而是充满了“预估”和“修正”的艺术。
  2. 掌握了偏移量计算:从暴力遍历到基于 Map 的优化计算。
  3. 消灭了抖动:通过缓冲区、ResizeObserver 和异步修正策略。
  4. 编写了健壮的代码:使用了 position: absoluteuseCallbackuseMemo 来优化性能。

最后送给大家一句话:
虚拟化滚动的本质,不是“偷懒不渲染”,而是“精确计算”。你要像一位严谨的会计,精确记录每一像素的高度,才能让用户的滚动体验丝般顺滑。

不要害怕抖动,抖动是调试的信号。只要你能用代码解释它,你就能消灭它。

现在,去你的项目里试试吧。如果还是抖,那就把缓冲区再加大点,实在不行,多渲染两个项目,用户的眼睛比你的算法更宽容。

谢谢大家,下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注