Writing

高效渲染大型列表(Virtual Lists)

文章發表於

大家是否在项目中遇到过需要渲染大型列表的场景,比如大型数据表格或者需要展示大量条目的简单列表。笔者记得自己的第一份工作就遇到过类似问题,当时直接用 map 渲染了上万条数据的列表,结果页面变得非常卡顿,甚至直接崩溃。主要原因在于一次性将上万条数据渲染到 DOM 中,浏览器需要花费大量时间来渲染这些元素,而在进行交互时,由于重绘和重排的关系,也会导致页面变得异常卡顿。

本文将介绍虚拟列表的原理以及如何实现虚拟列表。

什么是虚拟列表(Virtual Lists)

无论是通过电脑还是手机浏览数据,都会有最大高度限制,不需要一次性将所有数据都渲染到 DOM 中。虚拟列表主要解决的就是上述问题,其核心概念是只渲染当前视窗所需的数据,因此每次只渲染少量元素,同时需要模拟整个列表的高度,这样就能只渲染当前视窗的数据,同时达到类似无限滚动的效果。当用户滑动列表时,会实时加载进入可视范围的新项目,并移除离开可视范围的旧项目。

通过上面的交互效果可以更好地理解其背后的原理,设置好数据数量和每条数据的高度后,就可以通过滑动 Scroll Position 来模拟可视区域与整个列表的关系。假设可视区只有 10 个项目的空间,即使总共有 100 个项目,也只需要渲染 10 个项目,而剩下的 90 个项目则通过模拟方式呈现,这样就能达到类似无限滚动的效果。

<div style="height: 500px; overflow: auto;">
<div style="height: 500000px;"></div>
</div>

实现虚拟列表

通过上面的交互 UI 可以知道,在实现虚拟列表时,最重要的是知道可视区域的起点和终点,这两个值分别是 startIndexendIndex,其计算方式如下:

const startIndex = Math.floor(scrollTop / itemHeight);
const visibleItemCount = Math.ceil(containerHeight / itemHeight);
const endIndex = startIndex + visibleItemCount;

接下来需要计算每个项目的实际位置,这样就能知道每个项目在列表中的位置:

const itemPosition = itemIndex * itemHeight;

通过上述计算得到可视区的起点和终点以及每个项目的实际位置后,接下来需要计算出所有数据的总高度:

const totalHeight = itemCount * itemHeight;

最后一个技巧是使用 overscan 来优化列表的渲染,这样可以在可视区域之外的区域也进行渲染,避免在滑动列表时因为可视区域之外的区域没有渲染而导致用户看到列表跳动。

const overscanCount = 3;
const startWithOverscan = Math.max(0, startIndex - overscanCount);
const endWithOverscan = Math.min(itemCount - 1, endIndex + overscanCount);

最后我们只需要在父层加上 onScroll 事件,并在事件中更新 scrollTop 就可以实现一个很基础的虚拟列表。

请点击『展开代码』以查看代码。
import React, { useState, useMemo, useRef, useCallback } from 'react';
import { preinit } from 'react-dom';

const ITEM_HEIGHT = 40; // The height of each item in pixels
const VIEWPORT_HEIGHT = 200; // The height of the visible area
const TOTAL_ITEMS = 1000; // Total number of items in the list
const TOTAL_HEIGHT = TOTAL_ITEMS * ITEM_HEIGHT; // Total scrollable height

const Item = ({ data }) => (
  <div
    className="h-full w-full rounded-md flex items-center justify-between px-4 font-bold text-sm text-white box-border bg-gradient-to-br from-green-500 to-green-600 dark:from-green-600 dark:to-green-700 border-2 border-green-700 dark:border-green-800"
  >
    <span>{data.name}</span>
    <span className="text-xs opacity-80 font-normal">
      {data.description}
    </span>
  </div>
);

const useVirtualList = ({
  itemCount,
  itemHeight,
  containerHeight,
  overscan = 3
}) => {
  const [scrollTop, setScrollTop] = useState(0);
  const [isScrolling, setIsScrolling] = useState(false);
  const scrollElementRef = useRef(null);

  // Memoized calculations for performance
  const { startIndex, endIndex, totalHeight, visibleItems } = useMemo(() => {
    if (itemCount === 0) {
      return { startIndex: 0, endIndex: 0, totalHeight: 0, visibleItems: [] };
    }

    // Core algorithm
    const startIndex = Math.floor(scrollTop / itemHeight);
    const visibleCount = Math.ceil(containerHeight / itemHeight);
    const endIndex = Math.min(startIndex + visibleCount - 1, itemCount - 1);

    // Add overscan buffer
    const startWithOverscan = Math.max(0, startIndex - overscan);
    const endWithOverscan = Math.min(itemCount - 1, endIndex + overscan);

    // Generate item descriptors with absolute positioning
    const visibleItems = [];
    for (let index = startWithOverscan; index <= endWithOverscan; index++) {
      visibleItems.push({
        index,
        style: {
          position: 'absolute',
          top: index * itemHeight,
          left: 0,
          right: 0,
          height: itemHeight,
        }
      });
    }

    return {
      startIndex: startWithOverscan,
      endIndex: endWithOverscan,
      totalHeight: itemCount * itemHeight,
      visibleItems
    };
  }, [scrollTop, itemHeight, containerHeight, itemCount, overscan]);

  // Optimized scroll handler with debouncing
  const handleScroll = useCallback((event) => {
    const newScrollTop = event.currentTarget.scrollTop;
    setScrollTop(newScrollTop);
    setIsScrolling(true);

    // Debounce scroll end detection
    const timeoutId = setTimeout(() => setIsScrolling(false), 150);
    return () => clearTimeout(timeoutId);
  }, []);

  return {
    // Props for the scrollable container
    containerProps: {
      ref: scrollElementRef,
      onScroll: handleScroll,
      style: {
        height: containerHeight,
        overflow: 'auto',
        position: 'relative',
      }
    },
    // Props for the inner content container
    innerProps: {
      style: {
        height: totalHeight, // The "illusion" height
        position: 'relative',
      }
    },
    // Data for rendering
    visibleItems,
    isScrolling,
    startIndex,
    endIndex,
  };
};

export default function App({ itemCount = 1000 }) {
  preinit("https://cdn.tailwindcss.com", {as: "script"});
  const items = useMemo(() =>
    Array.from({ length: itemCount }, (_, i) => ({
      id: i,
      name: `Item ${i + 1}`, // Display as 1-based index
      description: `This is item number ${i + 1}`,
    }))
  , [itemCount]);

  const {
    containerProps,
    innerProps,
    visibleItems,
  } = useVirtualList({
    itemCount: items.length,
    itemHeight: 60,
    containerHeight: 400,
    overscan: 3,
  });

  return (
    <div className="min-h-screen p-4 sm:p-8 flex items-center justify-center bg-bg-primary">
      <div className="w-full max-w-4xl mx-auto">
        <div className="rounded-xl shadow-lg dark:shadow-slate-900/50 p-6 sm:p-8 border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900">
          <div className="space-y-4">
            <h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">虚拟列表</h1>
            <div className="flex justify-center">
              <div className="w-full border-2 border-blue-500 dark:border-blue-400 rounded-lg overflow-hidden shadow-inner bg-slate-50 dark:bg-slate-800">
                <div {...containerProps} className="border-0 bg-transparent">
                  <div {...innerProps}>
                    {visibleItems.map(({ index, style }) => (
                      <div key={index} style={{...style, padding: '2px 8px'}}>
                        <Item data={items[index]} />
                      </div>
                    ))}
                  </div>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
};

结论

虽然上述实现不能直接用于生产环境,但可以大致了解虚拟列表背后的原理。如果要在生产环境中使用虚拟列表,推荐使用 react-windowtanstack-virtual 等库。

如果您喜欢这篇文章,请点击下方按钮分享给更多人,这将是对笔者创作的最大支持和鼓励。
Buy me a coffee