TanStack Virtual 源码解析:定高/不定高虚拟列表实现原理以及框架无关设计

深入 TanStack Virtual 源码,理解虚拟列表的核心原理与框架无关设计。本文将从固定高度和动态高度两种场景分析虚拟列表的实现思路,并解析 TanStack Virtual 如何通过框架无关的设计支持 React、Vue、Angular 等多种框架。

注意 :为了让大家更易理解虚拟列表的核心原理,本文的代码示例剔除了源码中一些非核心的细节,例如列表的 gap(间距)、padding(内边距)、horizontal(水平滚动)等配置项的处理逻辑。

什么是虚拟列表

当列表项达到数千甚至数万条时,全部渲染会导致页面卡顿、内存占用过高。

为什么会卡顿? 每个 DOM 节点都需要占用内存,浏览器需要计算每个节点的布局(Layout)和绑定事件。当 DOM 节点数量过多时:

  • 内存占用激增:1万个节点可能占用数百MB内存
  • 布局计算耗时:浏览器需要计算所有节点的位置和尺寸
  • 重绘重排开销大:滚动时触发大量节点的重绘

虚拟列表 的核心思想是:只渲染可视区域及附近的元素,未显示的部分不创建真实 DOM,从而大幅减少 DOM 节点数量。

假设有1万条数据,可视区域高度500px,每项高度50px,那么可视区域最多显示10项。虚拟列表只渲染这10条(加上缓冲区约20条),而不是全部1万条。

对比项 传统渲染 虚拟列表
DOM节点数 全部渲染(10000+) 只渲染可见部分(10-20个)
首屏渲染时间 数秒甚至更长 几乎瞬间完成
内存占用 随数据量线性增长 保持恒定
滚动性能 卡顿明显 流畅丝滑

实现思路

虚拟列表的核心是动态计算可视区域内应该显示哪些列表项

  1. 计算可视区域的起始索引startIndex
  2. 计算可视区域的结束索引endIndex
  3. 截取对应的数据进行渲染
  4. 计算偏移量startOffset)让渲染内容对齐可视区域

固定高度虚拟列表:最简单的场景

固定高度是虚拟列表最简单的场景,因为每个元素的位置可以通过数学公式直接计算,无需实际测量 DOM。

HTML 结构

虚拟列表需要三层结构:

tsx 复制代码
<div className="container" style={{ height: 500, overflow: "auto" }}>
  {/* 占位层:撑开滚动条 */}
  <div style={{ height: totalHeight }} />

  {/* 渲染层:实际渲染的列表项 */}
  <div style={{ transform: `translateY(${startOffset}px)` }}>
    {visibleData.map((item) => (
      <div key={item.id}>{item.content}</div>
    ))}
  </div>
</div>
  • 占位层:高度等于所有列表项的总高度,用于形成正常的滚动条
  • 渲染层 :通过 transform 偏移到正确位置

核心公式

typescript 复制代码
// screenHeight: 可视区域高度,即容器的 clientHeight
// itemSize: 每个列表项的固定高度
// scrollTop: 当前滚动距离,即容器的 scrollTop
// listData: 完整的列表数据数组

// 列表总高度 = 数据总数 × 每项高度
const listHeight = listData.length * itemSize;

// 可视区域能显示的项数 = 可视区域高度 ÷ 每项高度(向上取整,确保填满)
const visibleCount = Math.ceil(screenHeight / itemSize);

// 起始索引 = 滚动距离 ÷ 每项高度(向下取整)
// 向下取整确保不遗漏部分可见的顶部项
const startIndex = Math.floor(scrollTop / itemSize);

// 结束索引 = 起始索引 + 可见数量
const endIndex = startIndex + visibleCount;

// 偏移量 = 起始项的顶部位置 = startIndex × itemSize
// 也可以写成:scrollTop - (scrollTop % itemSize)
const startOffset = startIndex * itemSize;

startIndex 的计算为什么用 Math.floor? 例如当 scrollTop = 120pxitemSize = 50px 时:

  • Math.floor(120/50) = 2:第2项部分可见,需要渲染
  • Math.ceil(120/50) = 3:会遗漏第2项,导致顶部出现空白

简易版完整实现

tsx 复制代码
import React, { useState } from "react";

const VirtualList = ({ listData, itemSize, containerHeight }) => {
  // scrollTop: 当前滚动距离
  const [scrollTop, setScrollTop] = useState(0);

  // 可视区域能显示的项数
  const visibleCount = Math.ceil(containerHeight / itemSize);
  // 列表总高度,用于撑开滚动条
  const listHeight = listData.length * itemSize;
  // 可视区域起始索引
  const startIndex = Math.floor(scrollTop / itemSize);
  // 可视区域结束索引(不超过数据总长度)
  const endIndex = Math.min(startIndex + visibleCount, listData.length);
  // 截取可视区域要渲染的数据
  const visibleData = listData.slice(startIndex, endIndex);
  // 渲染层的偏移量,让第一项对齐到正确位置
  const startOffset = startIndex * itemSize;

  return (
    <div
      // 容器:固定高度,开启滚动
      style={{
        height: containerHeight,
        overflow: "auto",
        position: "relative",
      }}
      // 监听滚动事件,更新 scrollTop
      onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
    >
      {/* 占位层:撑开滚动条高度 */}
      <div style={{ height: listHeight }} />
      {/* 渲染层:通过 transform 偏移到正确位置 */}
      <div
        style={{
          position: "absolute",
          top: 0,
          transform: `translateY(${startOffset}px)`,
        }}
      >
        {visibleData.map((item, i) => (
          // 每个列表项固定高度
          <div key={startIndex + i} style={{ height: itemSize }}>
            {item.content}
          </div>
        ))}
      </div>
    </div>
  );
};

动态高度虚拟列表:核心挑战与解决方案

在实际应用中,列表项的高度往往是由内容动态撑开的(如长短不一的文本、图片等)。这时固定高度的计算公式不再适用。

动态高度的三大难点

  1. 渲染前不知道实际高度:元素未渲染到 DOM 前,无法获取其真实尺寸,但我们需要知道总高度来显示滚动条
  2. 无法用除法计算索引 :固定高度时 startIndex = scrollTop / itemSize,但动态高度每项不同,无法直接计算
  3. 尺寸变化导致内容跳动:上方元素高度变化时,下方内容位置会改变,用户会感觉页面"跳"了一下

TanStack Virtual 的解决方案

TanStack Virtual 采用估算 + 测量 + 缓存 + 补偿的策略:

  1. 初始估算 :用预估高度(estimateSize)初始化所有元素的位置,让页面快速显示
  2. 实际测量 :元素渲染后,通过 ResizeObserver 获取真实高度
  3. 缓存位置 :将测量结果缓存到 measurements 数组中,后续直接查询
  4. 滚动补偿:上方元素高度变化时,自动调整滚动位置,防止内容跳动

下面我们按照这四个阶段详细讲解。

measurements 数组:位置缓存的核心

在 TanStack Virtual 源码中,使用 measurements 数组来缓存每一项的位置信息:

typescript 复制代码
// TanStack Virtual 中每个元素的位置信息
interface VirtualItem {
  index: number; // 元素索引
  start: number; // 元素顶部距离列表顶部的距离
  end: number; // 元素底部距离列表顶部的距离
  size: number; // 元素高度
  // ... 其他属性如 key、lane 等
}

// measurements 数组示例
const measurements: VirtualItem[] = [
  { index: 0, size: 80, start: 0, end: 80 }, // 第0项:高80px,位于 0-80px
  { index: 1, size: 120, start: 80, end: 200 }, // 第1项:高120px,位于 80-200px
  { index: 2, size: 90, start: 200, end: 290 }, // 第2项:高90px,位于 200-290px
  // ...
];

有了这个缓存,我们就可以:

  • 快速查询任意元素的位置
  • 计算列表总高度(最后一项的 end 值)
  • 通过二分查找快速定位可视区域

阶段一:初始估算------让页面快速显示

为什么需要估算?

在元素渲染到 DOM 之前,我们无法知道它的真实高度。但浏览器需要知道列表的总高度才能显示正确的滚动条。所以我们先用一个预估值来初始化。

typescript 复制代码
// estimateSize: 用户提供的预估高度函数,如 () => 80
// 用预估高度初始化所有元素的位置信息
const measurements = listData.map((_, index) => ({
  index,
  size: estimateSize(index), // 预估高度,如 80px
  start: index * estimateSize(index), // 顶部位置 = 索引 × 预估高度
  end: (index + 1) * estimateSize(index), // 底部位置 = (索引+1) × 预估高度
}));

// 基于预估值计算列表总高度
// 例如 1000 项,每项预估 80px,总高度 = 80,000px
const totalHeight = measurements[measurements.length - 1].end;

虽然预估不准确,但至少让页面能够快速显示出来,滚动条也能正常工作。

估算值越准确,后续调整越少,性能越好。 建议基于实际数据统计平均高度作为预估值。

阶段二:实际测量------获取真实高度

元素渲染到 DOM 后,我们需要获取它的真实高度来修正 measurements 缓存。

测量时机 :在 React 中,我们通过 ref 回调在元素挂载时进行测量:

tsx 复制代码
// JSX 中为每个元素绑定 ref
<div
  data-index={index} // 通过 data 属性标记索引
  ref={(node) => measureElement(node, index)} // ref 回调进行测量
>
  {item.content}
</div>

使用 ResizeObserver 持续监听

TanStack Virtual 使用 ResizeObserver 而不是 getBoundingClientRect,因为:

  • ResizeObserver 可以持续监听尺寸变化(如图片加载、内容更新)
  • ResizeObserver异步回调,不会阻塞主线程
  • getBoundingClientRect 只能测量一次,且可能触发强制重排
typescript 复制代码
// resizeItem: 更新指定索引元素的高度(下一节会详细讲解)
const resizeObserver = new ResizeObserver((entries) => {
  entries.forEach((entry) => {
    // 从 data-index 属性获取元素索引
    const index = Number(entry.target.getAttribute("data-index"));
    // 获取元素的真实高度(优先使用 borderBoxSize)
    const measuredHeight =
      entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height;
    // 更新 measurements 缓存
    resizeItem(index, measuredHeight);
  });
});

// measureElement: 在元素挂载时调用
const measureElement = (node: HTMLElement | null, index: number) => {
  if (!node) return;
  // 开始观察该元素的尺寸变化
  resizeObserver.observe(node);
};

阶段三:增量更新与滚动位置补偿------防止内容跳动

当测量到的真实高度与预估值不同时,需要更新 measurements 缓存。这里有两个关键问题要处理:

问题一:如何高效更新缓存?

当第 N 项的高度变化时,第 N+1 项及之后所有项的 startend 都需要重新计算。TanStack Virtual 采用增量更新策略:

typescript 复制代码
// 假设索引 5000 的元素高度变化
// 只重新计算 5000 之后的项,前面 5000 项的缓存直接复用
for (let i = changedIndex + 1; i < measurements.length; i++) {
  measurements[i].start = measurements[i - 1].end;
  measurements[i].end = measurements[i].start + measurements[i].size;
}

变化位置越靠后,需要重算的项越少,性能越好!

问题二:如何防止内容跳动?

这是动态高度最精妙的设计。考虑以下场景:

  1. 用户正在看索引 100-110 的内容,scrollTop = 5000px
  2. 索引 50 的图片加载完成,高度从 100px 变为 200px(增加了 100px)
  3. 索引 51-110 的位置全部下移 100px
  4. 用户看到的内容突然"跳"了一下!

解决方案:滚动位置补偿

如果变化的元素在当前滚动位置上方 ,我们同时调整 scrollTop,让用户看到的内容保持不变:

typescript 复制代码
const resizeItem = (index: number, newSize: number) => {
  const item = measurements[index];
  const oldSize = item.size;
  const delta = newSize - oldSize; // 高度变化量

  if (delta === 0) return; // 高度没变,无需处理

  // 关键:判断变化的元素是否在当前滚动位置上方
  // item.start < scrollTop 说明该元素已经滚出可视区域上方
  if (item.start < scrollTop) {
    // 同步调整滚动位置,用户感知不到变化!
    containerRef.current.scrollTop += delta;
  }

  // 更新当前项的尺寸
  item.size = newSize;
  item.end = item.start + newSize;

  // 增量更新后续项的位置
  for (let k = index + 1; k < measurements.length; k++) {
    measurements[k].start = measurements[k - 1].end;
    measurements[k].end = measurements[k].start + measurements[k].size;
  }
};

效果: 索引 50 高度 +100px → 后续位置全部 +100px → 滚动位置也 +100px → 用户看到的内容相对位置不变!

这就像电梯上升时地板数字在变化,但你站在电梯里感觉不到移动一样。

阶段四:二分查找------快速定位可视区域

固定高度时,我们可以用除法直接计算 startIndex = scrollTop / itemSize。但动态高度每项不同,无法直接计算。

解决方案 :从 measurements 数组中查找第一个 end > scrollTop 的项。

为什么是 end > scrollTop

  • end 是元素底部的位置
  • 如果 end > scrollTop,说明这个元素的底部还没有滚出可视区域,即该元素至少部分可见
  • 这就是可视区域的第一项

举例:当 scrollTop = 210px 时:

typescript 复制代码
measurements = [
  { index: 0, start: 0, end: 80 }, // end=80 < 210,已完全滚出
  { index: 1, start: 80, end: 200 }, // end=200 < 210,已完全滚出
  { index: 2, start: 200, end: 290 }, // end=290 > 210 ✅ 这是第一个可见项!
  { index: 3, start: 290, end: 440 },
];
// startIndex = 2

二分查找实现(时间复杂度 O(log n)):

typescript 复制代码
const binarySearch = (scrollTop: number): number => {
  let start = 0;
  let end = measurements.length - 1;
  let result = 0;

  while (start <= end) {
    const mid = Math.floor((start + end) / 2);
    // 如果中间项的底部超过 scrollTop,它可能是第一个可见项
    if (measurements[mid].end > scrollTop) {
      result = mid; // 记录当前结果
      end = mid - 1; // 继续向左查找,看有没有更靠前的
    } else {
      start = mid + 1; // 中间项已完全滚出,向右查找
    }
  }
  return result;
};

缓冲区(overscan)------解决快速滚动白屏

快速滚动时,渲染速度可能跟不上滚动速度,导致短暂白屏。解决方案是在可视区域上下额外渲染几项作为缓冲:

scss 复制代码
┌─────────────────────────┐
│     overscan (5 项)     │  ← 缓冲区上方(已渲染但不可见)
├─────────────────────────┤
│   可视区域 (10 项)      │  ← 用户实际看到的部分
├─────────────────────────┤
│     overscan (5 项)     │  ← 缓冲区下方(已渲染但不可见)
└─────────────────────────┘
typescript 复制代码
// overscan: 缓冲区大小,TanStack Virtual 默认值为 1
const actualStartIndex = Math.max(0, startIndex - overscan);
const actualEndIndex = Math.min(listData.length, endIndex + overscan);

overscan 建议值:3-5,平衡性能和体验。值太大会增加渲染负担,太小可能出现白屏。

完整实现

tsx 复制代码
import React, { useState, useEffect, useRef, useCallback } from "react";

interface VirtualItem {
  index: number;
  size: number;
  start: number;
  end: number;
}

interface Props {
  listData: any[];
  estimateSize: number; // 预估高度
  containerHeight: number;
  overscan?: number; // 缓冲区大小
}

const DynamicVirtualList: React.FC<Props> = ({
  listData,
  estimateSize,
  containerHeight,
  overscan = 5,
}) => {
  const [scrollTop, setScrollTop] = useState(0);
  const [listHeight, setListHeight] = useState(0);
  const [, forceUpdate] = useState({});

  const containerRef = useRef<HTMLDivElement>(null);
  const measurementsRef = useRef<VirtualItem[]>([]);
  const scrollTopRef = useRef(0);
  const resizeObserverRef = useRef<ResizeObserver | null>(null);

  // 【阶段一】初始化 measurements,用预估高度填充
  useEffect(() => {
    measurementsRef.current = listData.map((_, i) => ({
      index: i,
      size: estimateSize,
      start: i * estimateSize,
      end: (i + 1) * estimateSize,
    }));
    setListHeight(listData.length * estimateSize);
  }, [listData, estimateSize]);

  // 【阶段三】更新高度(带滚动位置补偿)
  const resizeItem = useCallback((index: number, newSize: number) => {
    const measurements = measurementsRef.current;
    if (!measurements[index]) return;

    const delta = newSize - measurements[index].size;
    if (Math.abs(delta) < 0.5) return; // 忽略微小变化

    // 滚动位置补偿:如果变化的元素在滚动位置上方
    if (
      measurements[index].start < scrollTopRef.current &&
      containerRef.current
    ) {
      containerRef.current.scrollTop += delta;
    }

    // 更新当前项
    measurements[index].size = newSize;
    measurements[index].end = measurements[index].start + newSize;

    // 增量更新后续项
    for (let k = index + 1; k < measurements.length; k++) {
      measurements[k].start = measurements[k - 1].end;
      measurements[k].end = measurements[k].start + measurements[k].size;
    }

    setListHeight(measurements[measurements.length - 1]?.end || 0);
    forceUpdate({});
  }, []);

  // 【阶段二】初始化 ResizeObserver
  useEffect(() => {
    resizeObserverRef.current = new ResizeObserver((entries) => {
      entries.forEach((entry) => {
        const index = Number(entry.target.getAttribute("data-index"));
        if (!isNaN(index)) {
          const height =
            entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height;
          resizeItem(index, height);
        }
      });
    });
    return () => resizeObserverRef.current?.disconnect();
  }, [resizeItem]);

  // 测量元素:在 ref 回调中调用
  const measureElement = useCallback((node: HTMLDivElement | null) => {
    if (node) {
      resizeObserverRef.current?.observe(node);
    }
  }, []);

  // 【阶段四】二分查找起始索引
  const binarySearch = (scrollTop: number): number => {
    const measurements = measurementsRef.current;
    let start = 0,
      end = measurements.length - 1,
      result = 0;
    while (start <= end) {
      const mid = Math.floor((start + end) / 2);
      if (measurements[mid].end > scrollTop) {
        result = mid;
        end = mid - 1;
      } else {
        start = mid + 1;
      }
    }
    return result;
  };

  // 计算可视区域
  const visibleCount = Math.ceil(containerHeight / estimateSize) + 1;
  const startIndex = binarySearch(scrollTop);
  const actualStartIndex = Math.max(0, startIndex - overscan);
  const endIndex = Math.min(
    startIndex + visibleCount + overscan,
    listData.length
  );
  const visibleData = listData.slice(actualStartIndex, endIndex);
  const startOffset = measurementsRef.current[actualStartIndex]?.start ?? 0;

  return (
    <div
      ref={containerRef}
      style={{
        height: containerHeight,
        overflow: "auto",
        position: "relative",
      }}
      onScroll={(e) => {
        scrollTopRef.current = e.currentTarget.scrollTop;
        setScrollTop(e.currentTarget.scrollTop);
      }}
    >
      {/* 占位层 */}
      <div style={{ height: listHeight }} />
      {/* 渲染层 */}
      <div
        style={{
          position: "absolute",
          top: 0,
          transform: `translateY(${startOffset}px)`,
        }}
      >
        {visibleData.map((item, idx) => {
          const actualIndex = actualStartIndex + idx;
          return (
            <div
              key={item.id ?? actualIndex}
              data-index={actualIndex}
              ref={measureElement}
            >
              {item.content}
            </div>
          );
        })}
      </div>
    </div>
  );
};

框架无关的设计理念

TanStack Virtual 还有一个强大的特性就是框架无关:一套核心代码,支持 React、Vue、Angular、Solid 等所有主流框架。

核心思想:计算与渲染分离

回顾我们前面实现的虚拟列表,核心逻辑其实与 React 无关:

typescript 复制代码
// 这些计算逻辑是纯 JavaScript,与框架无关!
const startIndex = binarySearch(scrollTop);
const endIndex = startIndex + visibleCount;
const visibleData = listData.slice(startIndex, endIndex);
const startOffset = measurements[startIndex].start;

虚拟列表的核心逻辑只依赖:

  • 浏览器原生 API(ResizeObserver、scroll 事件、offsetHeight)
  • 纯 JavaScript 数据结构(数组、Map)
  • 数学计算(二分查找、位置计算)

这些与你用 React 还是 Vue 毫无关系!

那么,框架在虚拟列表中扮演什么角色?只有一个:当数据变化时,触发 UI 重新渲染。

工作原理:onChange 回调

理解框架无关设计的关键是这张数据流图:

核心层完全不知道自己运行在哪个框架中,它只负责:

  1. 监听滚动事件
  2. 计算可见区域
  3. 当需要更新 UI 时,调用 onChange 回调

框架适配层的唯一职责 :实现 onChange 回调,用框架特定的方式触发重渲染。

核心层 virtual-corepackage.jsondependencies 为空,不依赖任何框架!

React 适配实现

React 适配层只需要做一件事:当核心层调用 onChange 时,触发组件重渲染

typescript 复制代码
function useVirtualizerBase(options) {
  // 技巧:useReducer 返回的 dispatch 每次调用都会触发重渲染
  // () => ({}) 每次返回新对象,确保状态变化
  const rerender = React.useReducer(() => ({}), {})[1];

  const resolvedOptions = {
    ...options,
    // 关键:实现 onChange 回调
    onChange: (instance, sync) => {
      if (sync) {
        // 滚动时需要同步更新,避免闪烁
        flushSync(rerender);
      } else {
        // 尺寸变化等可以异步更新
        rerender();
      }
    },
  };

  // 创建核心层实例(只创建一次)
  const [instance] = React.useState(() => new Virtualizer(resolvedOptions));

  // 每次渲染都更新 options(支持 props 变化)
  instance.setOptions(resolvedOptions);

  // 生命周期:挂载时初始化,卸载时清理
  useIsomorphicLayoutEffect(() => {
    return instance._didMount();
  }, []);

  return instance;
}

就这么简单! React 适配层的核心就是:用 useReducer 创建一个强制重渲染的函数,在 onChange 中调用它。

Vue 适配实现

Vue 适配层的思路完全一样,只是用 Vue 的方式触发更新:

typescript 复制代码
function useVirtualizerBase(options) {
  const virtualizer = new Virtualizer(unref(options));
  // shallowRef:浅响应式,性能更好
  const state = shallowRef(virtualizer);

  watch(
    () => unref(options),
    (options) => {
      virtualizer.setOptions({
        ...options,
        // 关键:实现 onChange 回调
        onChange: () => {
          // triggerRef:手动触发响应式更新
          triggerRef(state);
        },
      });
    },
    { immediate: true }
  );

  onScopeDispose(() => virtualizer._didMount()());
  return state;
}

Vue 适配层的核心 :用 triggerRef 手动触发 shallowRef 的更新。

为什么这样设计

框架无关的好处:

  • 维护成本低:1500 行核心逻辑只维护一份,Bug 修复一次所有框架受益
  • 扩展性强:适配新框架(如 Solid、Qwik)只需写 ~50 行代码
  • 可测试性好:核心层可以脱离框架独立测试

总结

核心要点

  1. 固定高度 :数学公式直接计算,Math.floor 确保不遗漏部分可见项
  2. 动态高度:估算 → 测量 → 增量更新 → 滚动补偿
  3. 缓冲区(overscan):上下额外渲染几项,解决快速滚动白屏
  4. 框架无关:核心逻辑与 UI 框架解耦,适配层只负责触发重渲染

参考资源

相关推荐
猪猪拆迁队28 分钟前
高性能 Package构建系统设计与实现
前端·后端·node.js
用户144361834009730 分钟前
你不知道的JS-上(五)
javascript·程序员
UIUV31 分钟前
JavaScript中instanceof运算符的原理与实现
前端·javascript·代码规范
前端fighter33 分钟前
全栈项目:闲置二手交易系统(一)
前端·vue.js·后端
飞行增长手记38 分钟前
IP协议从跨境到物联网的场景化应用
服务器·前端·网络·安全
我叫张小白。40 分钟前
Vue3 插槽:组件内容分发的灵活机制
前端·javascript·vue.js·前端框架·vue3
Lovely_Ruby1 小时前
前端er Go-Frame 的学习笔记:实现 to-do 功能(一)
前端·后端
脾气有点小暴1 小时前
uniapp通用递进式步骤组件
前端·javascript·vue.js·uni-app·uniapp
问道飞鱼1 小时前
【前端知识】从前端请求到后端返回:Gzip压缩全链路配置指南
前端·状态模式·gzip·请求头