Writing

Efficiently Rendering Large Lists (Virtual Lists)

文章發表於

Have you ever encountered scenarios in your projects where you need to render large lists, such as big data tables or simply displaying multiple items in a list format? I remember facing a similar issue in my first job—I naively used map to render a list with over ten thousand items, only to find the page becoming extremely sluggish and even crashing. The main reason is that rendering thousands of items all at once into the DOM forces the browser to spend a significant amount of time rendering these elements. During interactions, this also leads to severe performance issues due to repaint/reflow.

This article will explain the principles behind virtual lists and how to implement them.

What is a Virtual List?

Whether you're viewing data on a computer or a mobile device, there's a maximum visible height, so there's no need to render all the data into the DOM at once. Virtual lists address this exact problem. The core idea is to render only the data currently in the viewport, meaning only a small number of elements are rendered at any given time. Simultaneously, we simulate the total height of the entire list to achieve an effect similar to infinite scrolling. As the user scrolls through the list, new items entering the visible range are loaded in real-time, while old items leaving the visible range are removed.

Through the interactive demo above, you can better understand the underlying principles. By setting the number of items and the height of each item, you can simulate the relationship between the viewport and the entire list by adjusting the Scroll Position. For example, if the viewport only has space for 10 items, even if there are 100 items in total, only 10 items need to be rendered. The remaining 90 items are simulated, achieving an effect similar to infinite scrolling.

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

Implementing a Virtual List

From the interactive UI above, you can see that the most important aspect of implementing a virtual list is determining the start and end of the visible area, represented by startIndex and endIndex. These values are calculated as follows:

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

Next, we need to calculate the actual position of each item to know where it should be placed in the list:

const itemPosition = itemIndex * itemHeight;

After calculating the start and end of the visible area and the position of each item, we then compute the total height of all the data:

const totalHeight = itemCount * itemHeight;

One final optimization technique is using overscan to improve list rendering. This involves rendering items just outside the visible area to prevent visual jumps when scrolling.

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

Finally, we just need to add an onScroll event to the parent container and update the scrollTop value within it to implement a basic virtual list.

Code is hidden. Click "Expand code" to view.
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">Virtual List</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>
  );
};

Conclusion

While the implementation above isn't suitable for production environments, it helps you understand the principles behind virtual lists. For production use, I recommend libraries like react-window or tanstack-virtual.

If you enjoyed this article, please click the buttons below to share it with more people. Your support means a lot to me as a writer.
Buy me a coffee