react 虚拟滚动列表的实现 —— 动态高度

js 复制代码
import React, { useState, useEffect, useLayoutEffect, useCallback, useRef } from 'react';

// 模拟API,每次返回20条数据
const mockFetchData = (page, action = 'load') => {
  return new Promise((resolve) => {
    setTimeout(() => {
      const startIndex = (page - 1) * 20 + 1;
      const newData = Array.from({ length: 20 }, (_, i) => {
        const id = action === 'refresh' ? `new_${page}_${i}` : startIndex + i;
        // 通过重复内容制造不同高度(1-4行),以演示可变高度
        const lines = (startIndex + i) % 4 + 1;
        const content = `数据项 ${startIndex + i} ` + '内容 '.repeat(lines);
        return { id, content };
      });
      resolve(newData);
    }, 500);
  });
};

const VirtualListWithScrollLoad = () => {
  // 状态管理
  const [dataList, setDataList] = useState([]); // 所有已加载的数据
  const [loading, setLoading] = useState(false); // 是否正在加载(上拉)
  const [refreshing, setRefreshing] = useState(false); // 是否正在下拉刷新
  const [page, setPage] = useState(1); // 当前页码
  const [finished, setFinished] = useState(false); // 数据是否已全部加载完毕
  const containerRef = useRef(null); // 滚动容器Ref

  // 虚拟列表相关参数
  const estimatedItemHeight = 60; // 估算高度(用于未测量项)
  const [visibleRange, setVisibleRange] = useState({ start: 0, end: 0 });
  const containerHeight = 400; // 滚动容器可视高度
  const [itemHeights, setItemHeights] = useState([]); // 每项的真实高度(测量后更新)
  const [prefixSums, setPrefixSums] = useState([0]); // 高度前缀和数组,prefixSums[i] = 0..i-1之和
  const itemRefs = useRef(new Map()); // 保存可见项的DOM引用,按索引存
  const bufferPx = 120; // 额外缓冲高度,提升滚动顺滑

  // 加载更多数据(上拉)
  const loadMore = useCallback(async () => {
    if (loading || finished) return;
    setLoading(true);
    try {
      const newData = await mockFetchData(page, 'load');
      if (newData.length === 0) {
        setFinished(true); // 没有新数据了
      } else {
        setDataList(prev => [...prev, ...newData]);
        // 为新增项预置估算高度,待渲染后测量真实高度再修正
        setItemHeights(prev => [...prev, ...Array(newData.length).fill(estimatedItemHeight)]);
        setPage(prev => prev + 1);
      }
    } catch (error) {
      console.error('加载数据失败:', error);
    } finally {
      setLoading(false);
    }
  }, [loading, finished, page]);

  // 下拉刷新
  const onRefresh = useCallback(async () => {
    if (refreshing) return;
    setRefreshing(true);
    setFinished(false);
    try {
      const newData = await mockFetchData(1, 'refresh'); // 总是请求第一页
      setDataList(newData); // 用新数据替换旧数据
      setItemHeights(Array(newData.length).fill(estimatedItemHeight));
      setPage(2); // 下次加载从第二页开始
      setVisibleRange({ start: 0, end: Math.min(Math.ceil(containerHeight / estimatedItemHeight) + 2, newData.length) });
    } catch (error) {
      console.error('刷新数据失败:', error);
    } finally {
      setRefreshing(false);
    }
  }, [refreshing]);

  // 根据itemHeights构建前缀和
  useEffect(() => {
    const sums = new Array(itemHeights.length + 1);
    sums[0] = 0;
    for (let i = 0; i < itemHeights.length; i++) {
      sums[i + 1] = sums[i] + (itemHeights[i] || estimatedItemHeight);
    }
    setPrefixSums(sums);
  }, [itemHeights]);

  // 计算可视区域(支持可变高度)
  const calculateVisibleRange = useCallback(() => {
    const container = containerRef.current;
    const scrollTop = container?.scrollTop ?? 0;
    const totalItems = dataList.length;
    const sums = prefixSums;

    // 二分查找startIndex,使得prefixSums[startIndex] <= scrollTop < prefixSums[startIndex + 1]
    let startIndex;
    if (sums.length === totalItems + 1) {
      let low = 0, high = totalItems;
      while (low < high) {
        const mid = Math.floor((low + high) / 2);
        if (sums[mid] <= scrollTop) {
          low = mid + 1;
        } else {
          high = mid;
        }
      }
      startIndex = Math.max(0, low - 1);
    } else {
      // 前缀和未就绪时,用估算高度计算
      startIndex = Math.floor(scrollTop / estimatedItemHeight);
    }

    // 计算endIndex,覆盖视窗高度 + 缓冲高度
    const viewportBottom = scrollTop + containerHeight + bufferPx;
    let endIndex = startIndex;
    if (sums.length === totalItems + 1) {
      while (endIndex < totalItems && sums[endIndex + 1] <= viewportBottom) {
        endIndex++;
      }
    } else {
      endIndex = Math.min(startIndex + Math.ceil(containerHeight / estimatedItemHeight) + 2, totalItems);
    }

    setVisibleRange({ start: startIndex, end: endIndex });
  }, [dataList.length, prefixSums, containerHeight]);

  // 初始化数据和监听滚动
  useEffect(() => {
    loadMore(); // 初始化加载第一页数据
  }, []);

  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const handleScroll = () => {
      calculateVisibleRange(); // 滚动时重新计算可视区域

      // 检查是否滚动到底部(上拉加载)
      const { scrollTop, scrollHeight, clientHeight } = container;
      if (scrollHeight - scrollTop - clientHeight < 100 && !loading && !finished) { // 距离底部100px触发
        loadMore();
      }

      // 检查是否滚动到顶部并下拉(下拉刷新)
      if (scrollTop < -80 && !refreshing) { // 下拉超过80px触发
        onRefresh();
      }
    };

    // 使用节流函数优化性能,避免滚动事件触发过于频繁
    const throttledScroll = throttle(handleScroll, 100);
    container.addEventListener('scroll', throttledScroll);
    calculateVisibleRange(); // 初始计算一次

    return () => container.removeEventListener('scroll', throttledScroll);
  }, [loadMore, onRefresh, loading, refreshing, finished, calculateVisibleRange]);

  // 当前缀和变化时,基于最新高度再算一遍可视区,避免跳动
  useEffect(() => {
    calculateVisibleRange();
  }, [prefixSums]);

  // 渲染可见的项目
  const visibleItems = dataList.slice(visibleRange.start, visibleRange.end + 1);

  // 渲染后测量可见项的真实高度,并写回itemHeights
  useLayoutEffect(() => {
    visibleItems.forEach((item, idx) => {
      const index = visibleRange.start + idx;
      const el = itemRefs.current.get(index);
      if (el) {
        const h = el.getBoundingClientRect().height;
        if (h && itemHeights[index] !== h) {
          setItemHeights(prev => {
            const next = prev.slice();
            next[index] = h;
            return next;
          });
        }
      }
    });
    // 依赖可见项和起始索引
  }, [visibleItems, visibleRange.start]);

  const totalHeight = (prefixSums[prefixSums.length - 1] ?? 0) || dataList.length * estimatedItemHeight;
  return (
    <div
      ref={containerRef}
      style={{
        height: `${containerHeight}px`,
        overflow: 'auto',
        border: '1px solid #ccc',
        position: 'relative'
      }}
    >
      {/* 下拉刷新指示器 */}
      <div style={{ textAlign: 'center', height: refreshing ? '50px' : '0', transition: 'height 0.2s' }}>
        {refreshing && <div>刷新中...</div>}
      </div>

      {/* 虚拟列表容器,其高度撑开滚动条 */}
      <div style={{ height: `${totalHeight}px`, position: 'relative' }}>
        {/* 可视项目的容器,通过定位偏移到正确位置 */}
        <div style={{
          position: 'absolute',
          top: 0,
          transform: `translateY(${(prefixSums[visibleRange.start] ?? visibleRange.start * estimatedItemHeight)}px)`,
          width: '100%'
        }}>
          {visibleItems.map((item, idx) => {
            const index = visibleRange.start + idx;
            return (
              <div
                key={item.id}
                ref={el => {
                  if (el) itemRefs.current.set(index, el);
                  else itemRefs.current.delete(index);
                }}
                style={{ borderBottom: '1px solid #eee', padding: '8px 12px', boxSizing: 'border-box' }}
              >
                {item.content}
              </div>
            );
          })}
        </div>
      </div>

      {/* 上拉加载指示器 */}
      <div style={{ textAlign: 'center', padding: '10px' }}>
        {loading && <div>加载中...</div>}
        {finished && <div>没有更多数据了</div>}
      </div>
    </div>
  );
};

// 简单的节流函数
function throttle(func, delay) {
  let timeoutId;
  return function (...args) {
    if (!timeoutId) {
      timeoutId = setTimeout(() => {
        func.apply(this, args);
        timeoutId = null;
      }, delay);
    }
  };
}

export default VirtualListWithScrollLoad;

难点

二分查找startIndex,使得prefixSums[startIndex] <= scrollTop < prefixSums[startIndex + 1]

js 复制代码
// 二分查找startIndex,使得prefixSums[startIndex] <= scrollTop < prefixSums[startIndex + 1]
    let startIndex;
    if (sums.length === totalItems + 1) {
      let low = 0, high = totalItems;
      while (low < high) {
        const mid = Math.floor((low + high) / 2);
        if (sums[mid] <= scrollTop) {
          low = mid + 1;
        } else {
          high = mid;
        }
      }
      startIndex = Math.max(0, low - 1);
    } else {
      // 前缀和未就绪时,用估算高度计算
      startIndex = Math.floor(scrollTop / estimatedItemHeight);
    }

    // 计算endIndex,覆盖视窗高度 + 缓冲高度
    const viewportBottom = scrollTop + containerHeight + bufferPx;
    let endIndex = startIndex;
    if (sums.length === totalItems + 1) {
      while (endIndex < totalItems && sums[endIndex + 1] <= viewportBottom) {
        endIndex++;
      }
    } else {
      endIndex = Math.min(startIndex + Math.ceil(containerHeight / estimatedItemHeight) + 2, totalItems);
    }

这段在做什么

  • 把滚动条位置 scrollTop 换算成"当前视口从第几个元素开始渲染",得到 startIndex
  • 再计算"要渲染到第几个元素才能填满视口并留一点缓冲",得到 endIndex
  • 最终把 { start, end } 存到状态,页面只渲染这段区间的元素。
关键概念(先建立直觉)
  • 把列表看成一栋楼,每层楼高不一样。
  • prefixSums 是"到每一层为止的累计高度"(0楼=0,1楼=第0项高度累加,2楼=第0、1项高度累加...)。
  • scrollTop 是电梯当前上升的高度,我们要知道"电梯此刻停在哪一层"。
  • 找到这一层的索引就是 startIndex ;再往下"加楼层高度"直到超过视口底部就是endIndex
1. 如何找到 startIndex对半查找的直观版
  • 目标:找到最大的 i ,使得 prefixSums[i] <= scrollTop < prefixSums[i+1]
  • 思路:因为 prefixSums 是递增的,我们可以"每次看中间那层楼"来缩小范围:
    • 如果"中间那层的累计高度"还不超过 scrollTop ,说明你在更高楼层,去右半边找。
    • 如果超过了,说明你在更低楼层,去左半边找。
    • 这样一半一半地排除,直到收敛到正确的楼层。
  • 代码里用 lowhigh 表示"还没排除的楼层范围",循环结束后:
    • low 指向"第一个累计高度大于 scrollTop 的楼层",所以真正所在楼层是 low - 1 。
    • Math.max(0, low - 1) 防止出现负数索引。
  • 如果 prefixSums 还没准备好(比如首次渲染还没测到真实高度),就用均高近似
    • startIndex = Math.floor(scrollTop / estimatedItemHeight) ,粗略猜一个开始位置。
2. 如何确定 endIndex
  • 先算视口底部的物理位置: viewportBottom = scrollTop + containerHeight + bufferPx
  • startIndex 开始往下走,累计高度不超过 viewportBottom 的都算"需要渲染"(这样能填满视口,还多渲染一点缓冲,滚动更顺)。
  • 如果没有前缀和(还未测量),就用"估算高度"近似地算需要的个数: startIndex + ceil(containerHeight/estimated) + 2
一个小例子
  • 假设三项的真实高度是 [50, 80, 60] ,则 prefixSums = [0, 50, 130, 190]
  • 当前 scrollTop = 120 :
    • 因为 50 <= 120 < 130 ,所以你"在第1项所在的楼层", startIndex = 1
    • 视口高度 400,加缓冲 120, viewportBottom = 640 ,往下累加到不超过 640 的索引就是 endIndex

记忆法

  • startIndex :在累计高度表里找"刚好不超过视口顶部"的那一项(对半排除法)。
  • endIndex :从 startIndex 开始往下累积,直到"超过视口底部 + 缓冲"为止。
  • 没测量时用估算高度先近似,测完再用前缀和精确计算。
相关推荐
CptW3 小时前
手撕 Promise 一文搞定
前端·面试
温宇飞3 小时前
Web 异步编程
前端
腹黑天蝎座3 小时前
浅谈React19的破坏性更新
前端·react.js
东华帝君3 小时前
react组件常见的性能优化
前端
第七种黄昏3 小时前
【前端高频面试题】深入浏览器渲染原理:从输入 URL 到页面绘制的完整流程解析
前端·面试·职场和发展
angelQ3 小时前
前端fetch手动解析SSE消息体,字符串双引号去除不掉的问题定位
前端·javascript
Huangyi3 小时前
第一节:Flow的基础知识
android·前端·kotlin
林希_Rachel_傻希希3 小时前
JavaScript 解构赋值详解,一文通其意。
前端·javascript
Yeats_Liao3 小时前
Go Web 编程快速入门 02 - 认识 net/http 与 Handler 接口
前端·http·golang