Writing

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

文章發表於

大家是否有在项目中遇到需要大型渲染列表的场景,例如大型数据表格或是简单但多笔数据需要条列式的呈现。笔者记得自己的第一份工作就有遇到过类似的问题,依稀记得自己是直接使用 map 直接渲染破万比数据的列表,结果发现页面变得非常卡,甚至直接当掉。而主因如果将破万笔的数据一次性的渲染到 DOM 中,这会造成浏览器需要花费大量的时间来渲染这些元素,而在进行交互的时候,也会因为 repaint/reflow 的关系,导致页面变得非常卡顿。

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

什么是虚拟列表 (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-window 或是 tanstack-virtual 等套件。

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