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