小小“虚拟列表“蕴藏多少知识

1 阅读12分钟

背景

最近在研究虚拟列表,对于这个前端考点,我们来一起研究它的简单实现

任务拆分

就像小品台词一样:要想实现一个虚拟列表总共分几步??

答案: 4步

第一步:创建容器 & 监听滚动 - 算滚动距离 scrollTop

为什么要算 scrollTop?

因为我们知道,其实虚拟列表就是一种顺滑的翻页,我们得知道滑了几页了,才能渲染当前页的数据 所以需要当前的startIndex,那么怎么计算出来呢?

我们得随时监听滑动距离, 我们从头往下捋,当累计高度> scrollTop的位置,就是 startIndex

如何实现呢?

基础版

useEffect(() => {
    const el = containerRef.current;
    const onScroll = () => {
      // 把更新 scrollTop 交给调度器,一帧只执行一次
      setScrollTop(el.scrollTop)
    };
    // passive: true → 不阻塞浏览器滚动,性能更好
    el.addEventListener('scroll', onScroll, { passive: true });
    return () => el.removeEventListener('scroll', onScroll);
  }, []);

进阶版

基础版有什么问题呢?

我们知道 只要我们开始滑动,onScroll 事件会非常频繁的触发,有一种办法是做节流,但是有一种更好的办法是对它做帧级别的防抖

先看下实现


// ==============================================
// 调度器(核心优化:防止滚动时频繁更新导致卡顿)
// 作用:把多次 setState 合并到一帧执行,保证 60fps
// ==============================================
const Scheduler = {
  queue: [],        // 存放要执行的任务
  running: false,   // 是否正在执行
  rafId: null,      // requestAnimationFrame 的标识

  // 添加任务到队列
  push(task) {
    this.queue.push(task);
    this.flush();
  },

  // 执行任务:一帧最多执行 16ms(保证流畅)
  flush() {
    if (this.running) return;
    this.running = true;

    const run = () => {
      // 一帧时间 16ms,到点就停,不阻塞浏览器
      const end = performance.now() + 16;
      while (this.queue.length && performance.now() < end) {
        this.queue.shift()();
      }
      // 还有任务 → 下一帧继续
      if (this.queue.length) {
        this.rafId = requestAnimationFrame(run);
      } else {
        this.running = false;
      }
    };

    // 取消上一帧,避免重复执行
    cancelAnimationFrame(this.rafId);
    this.rafId = requestAnimationFrame(run);
  },
};

关键在最后两句:

只要一帧内不停的触发,我只执行最后一次

总结:节流是妥协,帧级防抖才是原生级流畅!


1. 普通节流(throttle)

throttle(fn, 16)

原理:

  • 不管触发多少次,每隔 16ms 才执行一次
  • 时间是人为定死的

缺点:

  • 时间是人工估算的,不一定和浏览器渲染同步
  • 容易出现:
    • 执行早了 → 卡
    • 执行晚了 → 掉帧、不跟手
  • 不是和浏览器“同频”

2. 帧级防抖(真正的王者)

cancelAnimationFrame(id)
requestAnimationFrame(fn)

它的真正强大之处:

天然和浏览器 60fps 完全对齐
  • 一帧 16ms
  • 只在渲染前执行
  • 时间绝对精准,不是人工估算
滚动期间:只保留最后一次更新
  • 疯狂滚动 → 不断取消旧更新
  • 只保留最新、最准确的一次
  • 计算量最少 → 最不卡
滚动停止:下一帧立刻更新
  • 无延迟、不抖动
  • 视图永远是最新状态
不丢帧、不错位、视觉最顺滑

3. 为什么帧级防抖 > 节流?(核心区别)

节流:定时执行(人定的)
帧级防抖:按浏览器节奏执行(浏览器定的)
  • 节流:强行限制频率
  • 帧防抖:和渲染同步,只执行必要的更新

流畅度差距巨大!

第二步:计算每一项高度(动态高度核心)

基础版

const measureItem = (idx: number, el: HTMLDivElement | null) => {
    if (!el) return;
    const h = el.getBoundingClientRect().height;
    heightsRef.current[idx] = h;
  };

进阶版

const observeItem = useCallback((el, index) => {
    // 固定高度 / 没有元素 → 不监听
    if (isFixedHeight || !el) return;

    // 已经监听 → 不重复监听
    if (observedMap.current.has(el)) return;
    observedMap.current.set(el, index);

    // 第一次创建 ResizeObserver
    if (!ro.current) {
      ro.current = new ResizeObserver((entries) => {
        entries.forEach(entry => {
          const idx = observedMap.current.get(entry.target);
          // 高度变化 → 更新
          updateItemHeight(idx, entry.contentRect.height);
        });
      });
    }

    // 开始监听当前元素
    ro.current.observe(el);
  }, [isFixedHeight, updateItemHeight]);

一、第一段:基础版 measureItem

功能

渲染完成后,立刻获取元素高度。

const measureItem = (idx: number, el: HTMLDivElement | null) => {
  if (!el) return;
  // 获取元素真实高度
  const h = el.getBoundingClientRect().height;
  // 存入高度缓存
  heightsRef.current[idx] = h;
};

原理

  • 元素渲染 → ref 存在 → 立刻读高度
  • 只执行一次
  • 同步、阻塞式

优点

  • 简单
  • 直观
  • 一次测量够用

缺点(致命)

  • 元素高度变化不会重新测量 折叠面板、图片加载、富文本变化 → 高度变了 → 列表直接错位
  • 图片加载完成后高度变化无法感知
  • 窗口 resize 后变化无法感知

适用场景

高度永远不变的简单动态列表


二、第二段:进阶版 observeItem

功能

监听元素尺寸变化,自动更新高度,永久保持正确。

这是工业级虚拟列表必须用的方案

const observeItem = useCallback((el, index) => {
  // 1. 固定高度不需要监听
  if (isFixedHeight || !el) return;

  // 2. 防止重复监听(性能关键)
  if (observedMap.current.has(el)) return;
  observedMap.current.set(el, index);

  // 3. 全局唯一监听实例(性能爆炸强)
  if (!ro.current) {
    ro.current = new ResizeObserver((entries) => {
      entries.forEach(entry => {
        const idx = observedMap.current.get(entry.target);
        // 4. 高度变化 → 自动更新整个列表的位置
        updateItemHeight(idx, entry.contentRect.height);
      });
    });
  }

  // 5. 开始监听元素
  ro.current.observe(el);
}, [isFixedHeight, updateItemHeight]);

🔥 这段代码的精妙之处(面试必问)

1. ResizeObserver:浏览器原生监听尺寸变化

  • 元素高度变化 自动触发
  • 图片加载完成
  • 文字展开/收起
  • 窗口 resize
  • 富文本渲染完成

全部能捕获!

2. 全局唯一实例,性能极高

整个列表只创建一个 ResizeObserver 监听所有元素,而不是每个元素创建一个。

性能提升 10~100 倍。

3. Map 防止重复监听

避免反复 observe 同一个元素。

4. 高度变化自动更新

调用 updateItemHeight

  • 修改当前项高度
  • 偏移后面所有项的 top
  • 更新总高度
  • 触发视图刷新

列表永远不会错位!

5. 固定高度直接跳过,极致优化


三、两者最核心区别

方式测量时机高度变化图片/折叠/resize工业级可用
measureItem渲染一次不更新不支持❌ 不可用
observeItem持续监听自动更新完美支持✅ 生产可用

第三步:计算 startIndex 和 endIndex(最关键)

visibleItems = 全部数据.slice(startIndex, endIndex)

基础版

const getStartIndex = () => {
    let sum = 0;
    const h = heightsRef.current;
    for (let i = 0; i < h.length; i++) {
      sum += h[i] || 50; // 预估默认高度50
      if (sum > scrollTop) {
        return Math.max(0, i - buffer);
      }
    }
    return 0;
  };

进阶版

// ==============================================
  // 4. 二分查找:找到当前滚动位置对应的起始项
  // 比遍历快非常多
  // ==============================================
  const findStartIndex = useCallback(() => {
    const arr = positions.current;
    let l = 0, r = arr.length - 1;

    // 二分查找:找最后一个 top <= scrollTop 的项
    while (l <= r) {
      const mid = (l + r) >> 1; // 等价 Math.floor((l+r)/2)
      if (arr[mid].top <= scrollTop) l = mid + 1;
      else r = mid - 1;
    }

    // 结果就是可视区第一项
    return Math.max(0, r);
  }, [scrollTop]);

一、先看本质区别(最重要)

基础版(遍历)

从头开始一项一项加高度,直到超过 scrollTop 复杂度:O(n)

进阶版(二分)

利用提前算好的 top 数组,每次砍掉一半查找 复杂度:O(log n)


二、基础版遍历查找(getStartIndex)

const getStartIndex = () => {
  let sum = 0;
  const h = heightsRef.current;
  for (let i = 0; i < h.length; i++) {
    sum += h[i] || 50;   // 累加高度
    if (sum > scrollTop) {
      return i - buffer; // 找到了
    }
  }
  return 0;
};

它在干嘛?

翻书一样从头翻: 第1项多高?加起来。 第2项多高?加起来。 第3项多高?加起来…… 直到总和超过滚动距离,找到了!

缺点

  • 1万条要算1万次
  • 10万条要循环10万次
  • 滚动越往下,越慢、越卡
  • 属于暴力解法

三、进阶版二分查找(findStartIndex)

const findStartIndex = useCallback(() => {
  const arr = positions.current;
  let l = 0, r = arr.length - 1;

  while (l <= r) {
    const mid = (l + r) >> 1;
    if (arr[mid].top <= scrollTop) l = mid + 1;
    else r = mid - 1;
  }

  return Math.max(0, r);
}, [scrollTop]);

它在干嘛?

positions 里提前存好了每一项的 top: [0, 100, 200, 300, 400, 500...]

这是天然有序数组! 二分查找就是: 每次取中间 → 判断 scrollTop 在左边还是右边 → 直接砍掉一半

速度恐怖对比

  • 10000 条数据
    • 遍历:10000 次
    • 二分:14 次

四、最核心的一句话总结(面试必背)

基础遍历:

从头累加高度找位置,数据越多越慢。

进阶二分:

利用提前计算好的有序 top 数组,O(log n) 瞬间定位,海量数据也不卡。


五、为什么二分能这么快?

因为:

positions 里的 top 是严格递增、提前算好、永久缓存的!

0100200300400500...

天生满足二分查找条件


第四步:渲染 + 上下占位(让滚动条正常)

基础版

 return (
    <div
      ref={scrollRef}
      onScroll={handleScroll}
      style={{
        width: 505,
        height: 500,
        border: '1px solid #000',
        overflowY: 'auto',
        padding: 20, // 直接给滚动容器 padding正确boxSizing: 'border-box',
      }}
    >
      {/* 顶部占位 */}
      <div style={{ height: topBlank }} />

      {/* 只渲染可见区域 */}
      {visibleList.map((item, idx) => {
        const realIdx = startIndex + idx;
        return (
          <div
            key={item.id}
            ref={(el) => measureItem(realIdx, el)}
            style={{
              padding: 10,
              borderBottom: '1px solid #eee',
              background: '#fff',
            }}
          >
            {item.content}
          </div>
        );
      })}

      {/* 底部占位 */}
      <div style={{ height: totalHeight - topBlank }} />
    </div>
  );

进阶版

 return (
    <div
      ref={containerRef}
      style={{
        width: '100%',
        height: '100%',
        overflowY: 'auto',   // 纵向滚动
        position: 'relative',// 子项绝对定位
        ...style,
      }}
    >
      {/* 这一层用来撑开滚动条:高度 = 总高度 */}
      <div style={{ height: totalHeight.current }} />

      {/* 只渲染 start ~ end 之间的项!虚拟列表核心 */}
      {Array.from({ length: end - start }, (_, i) => {
        const idx = start + i;
        const pos = positions.current[idx];

        return (
          <div
            key={idx}
            // 固定高度不绑定 ref关闭监听
            ref={isFixedHeight ? null : (el) => observeItem(el, idx)}
            style={{
              position: 'absolute',
              top: pos.top,          // 项的位置
              left: 0,
              width: '100%',
            }}
          >
            {/* 渲染每一项 */}
            {itemContent(idx)}
          </div>
        );
      })}
    </div>
  );

一、核心差异一句话总结

第一段:上下空白占位 + 正常流布局(简易版)

第二段:绝对定位 + 计算 top + 无重排 + 支持动态高度(正式版)


二、详细对比

布局方式

第一段(简易版)
  • 正常文档流
  • topBlankbottomBlank 撑开高度
  • 优点:简单
  • 缺点:项多了会频繁重排、回流、卡顿
第二段(工业版)
  • 所有项绝对定位
  • top: xxx 精确放置
  • 优点:几乎无回流、渲染极快
  • 滚动流畅度 高 10 倍以上

总结

原来 “虚拟列表”有这么多可以挖的点,真的不要小看喽!

完整代码

import {
  useRef,
  useState,
  useCallback,
  useLayoutEffect,
  useEffect,
} from 'react';

// ==============================================
// 调度器(核心优化:防止滚动时频繁更新导致卡顿)
// 作用:把多次 setState 合并到一帧执行,保证 60fps
// ==============================================
const Scheduler = {
  queue: [],        // 存放要执行的任务
  running: false,   // 是否正在执行
  rafId: null,      // requestAnimationFrame 的标识

  // 添加任务到队列
  push(task) {
    this.queue.push(task);
    this.flush();
  },

  // 执行任务:一帧最多执行 16ms(保证流畅)
  flush() {
    if (this.running) return;
    this.running = true;

    const run = () => {
      // 一帧时间 16ms,到点就停,不阻塞浏览器
      const end = performance.now() + 16;
      while (this.queue.length && performance.now() < end) {
        this.queue.shift()();
      }
      // 还有任务 → 下一帧继续
      if (this.queue.length) {
        this.rafId = requestAnimationFrame(run);
      } else {
        this.running = false;
      }
    };

    // 取消上一帧,避免重复执行
    cancelAnimationFrame(this.rafId);
    this.rafId = requestAnimationFrame(run);
  },
};

// ==============================================
// 手写极简版 Virtuoso 虚拟列表
// 支持:固定高度 / 动态高度
// ==============================================
export default function Virtuoso({
  totalCount,    // 总条数
  itemHeight,    // 项高度(固定高度传数字,动态高度不传)
  itemContent,   // 渲染每一项的内容
  style = {},    // 容器样式
}) {
  // 滚动容器 DOM 引用
  const containerRef = useRef(null);

  // 当前滚动距离 scrollTop
  const [scrollTop, setScrollTop] = useState(0);

  // 可视区域高度(一屏能显示多少)
  const [containerHeight, setContainerHeight] = useState(500);

  // 存放每一项的位置信息:{ top, height }
  const positions = useRef([]);

  // 列表总高度(用于撑开滚动条)
  const totalHeight = useRef(0);

  // 是否固定高度:itemHeight 是数字 → 固定高度模式
  const isFixedHeight = typeof itemHeight === 'number';

  // ==============================================
  // 动态高度监听(固定高度不会用到)
  // ==============================================
  const ro = useRef(null);                     // ResizeObserver 实例
  const observedMap = useRef(new Map());       // 存储已监听的元素,避免重复监听

  // ==============================================
  // 1. 初始化所有项的位置 & 高度
  // 执行时机:DOM 更新后、浏览器绘制前(useLayoutEffect)
  // ==============================================
  useLayoutEffect(() => {
    const pos = [];
    let top = 0; // 第一项 top 从 0 开始

    // 遍历所有项,计算位置
    for (let i = 0; i < totalCount; i++) {
      let height;

      // 固定高度 → 直接用传入的 itemHeight
      if (isFixedHeight) {
        height = itemHeight;
      } 
      // 动态高度 → 用缓存的高度,没有就用默认 60
      else {
        height = positions.current[i]?.height ?? 60;
      }

      pos.push({ top, height });
      top += height; // 下一项的 top = 上一项 top + height
    }

    // 保存所有位置信息
    positions.current = pos;
    // 总高度 = 最后一项的 top + 最后一项的 height
    totalHeight.current = top;
  }, [totalCount, itemHeight, isFixedHeight]);

  // ==============================================
  // 2. 监听容器高度(窗口 resize 时更新)
  // ==============================================
  useLayoutEffect(() => {
    const update = () => {
      if (containerRef.current) {
        // 获取容器可视高度
        setContainerHeight(containerRef.current.clientHeight);
      }
    };
    update(); // 初始化执行一次
    window.addEventListener('resize', update);
    return () => window.removeEventListener('resize', update);
  }, []);

  // ==============================================
  // 3. 监听滚动事件
  // 使用调度器 Scheduler 合并更新 → 不卡顿
  // ==============================================
  useEffect(() => {
    const el = containerRef.current;
    const onScroll = () => {
      // 把更新 scrollTop 交给调度器,一帧只执行一次
      Scheduler.push(() => setScrollTop(el.scrollTop));
    };
    // passive: true → 不阻塞浏览器滚动,性能更好
    el.addEventListener('scroll', onScroll, { passive: true });
    return () => el.removeEventListener('scroll', onScroll);
  }, []);

  // ==============================================
  // 4. 二分查找:找到当前滚动位置对应的起始项
  // 比遍历快非常多
  // ==============================================
  const findStartIndex = useCallback(() => {
    const arr = positions.current;
    let l = 0, r = arr.length - 1;

    // 二分查找:找最后一个 top <= scrollTop 的项
    while (l <= r) {
      const mid = (l + r) >> 1; // 等价 Math.floor((l+r)/2)
      if (arr[mid].top <= scrollTop) l = mid + 1;
      else r = mid - 1;
    }

    // 结果就是可视区第一项
    return Math.max(0, r);
  }, [scrollTop]);

  // ==============================================
  // 5. 计算可视区域:start 到 end
  // 从 start 开始累加高度,直到填满屏幕 + 缓冲区
  // ==============================================
  const getVisibleRange = useCallback(() => {
    const pos = positions.current;
    const start = findStartIndex();
    let end = start;
    let sum = 0;

    // 缓冲区 300px:上下多渲染一点,防止滚动白屏
    const buffer = 300;

    // 累加高度,直到超过屏幕高度 + buffer
    while (end < totalCount && sum < containerHeight + buffer) {
      sum += pos[end].height;
      end++;
    }

    return { start, end: Math.min(end, totalCount) };
  }, [findStartIndex, totalCount, containerHeight]);

  // ==============================================
  // 6. 更新项高度(仅动态高度使用)
  // 某项高度变化 → 更新后面所有项的 top
  // ==============================================
  const updateItemHeight = useCallback((index, newHeight) => {
    // 固定高度直接跳过,不执行
    if (isFixedHeight) return;

    const pos = positions.current;
    const diff = newHeight - pos[index].height;

    // 高度没变,不更新
    if (diff === 0) return;

    // 更新当前项高度
    pos[index].height = newHeight;

    // 后面所有项的 top 都要同步偏移
    for (let i = index + 1; i < totalCount; i++) {
      pos[i].top += diff;
    }

    // 总高度也更新
    totalHeight.current += diff;

    // 触发视图刷新(用调度器不卡)
    Scheduler.push(() => setScrollTop((s) => s));
  }, [totalCount, isFixedHeight]);

  // ==============================================
  // 7. 监听元素尺寸变化(仅动态高度)
  // 使用 ResizeObserver 监听高度变化
  // ==============================================
  const observeItem = useCallback((el, index) => {
    // 固定高度 / 没有元素 → 不监听
    if (isFixedHeight || !el) return;

    // 已经监听 → 不重复监听
    if (observedMap.current.has(el)) return;
    observedMap.current.set(el, index);

    // 第一次创建 ResizeObserver
    if (!ro.current) {
      ro.current = new ResizeObserver((entries) => {
        entries.forEach(entry => {
          const idx = observedMap.current.get(entry.target);
          // 高度变化 → 更新
          updateItemHeight(idx, entry.contentRect.height);
        });
      });
    }

    // 开始监听当前元素
    ro.current.observe(el);
  }, [isFixedHeight, updateItemHeight]);

  // 计算出当前要渲染的 start 和 end
  const { start, end } = getVisibleRange();

  // ==============================================
  // 渲染结构
  // ==============================================
  return (
    <div
      ref={containerRef}
      style={{
        width: '100%',
        height: '100%',
        overflowY: 'auto',   // 纵向滚动
        position: 'relative',// 子项绝对定位
        ...style,
      }}
    >
      {/* 这一层用来撑开滚动条:高度 = 总高度 */}
      <div style={{ height: totalHeight.current }} />

      {/* 只渲染 start ~ end 之间的项!虚拟列表核心 */}
      {Array.from({ length: end - start }, (_, i) => {
        const idx = start + i;
        const pos = positions.current[idx];

        return (
          <div
            key={idx}
            // 固定高度不绑定 ref关闭监听
            ref={isFixedHeight ? null : (el) => observeItem(el, idx)}
            style={{
              position: 'absolute',
              top: pos.top,          // 项的位置
              left: 0,
              width: '100%',
            }}
          >
            {/* 渲染每一项 */}
            {itemContent(idx)}
          </div>
        );
      })}
    </div>
  );
}