Writing

Efficient Rendering of Large Lists (Virtual Lists)

文章發表於

Have you ever encountered scenarios in your projects where you need to render large lists, such as large data tables or simple but numerous data items that need to be listed? The author recalls facing a similar issue in their first job, where they directly used map to render a list of over ten thousand data items, only to find the page became extremely sluggish or even crashed. The main reason was that rendering over ten thousand data items into the DOM at once would cause the browser to spend a significant amount of time rendering these elements, and during interaction, the page would become very sluggish due to repaint/reflow.

This article will introduce the principle of virtual lists and how to implement them.

What is a Virtual List

Whether browsing data on a computer or a mobile phone, there is a maximum height, and it's unnecessary to render all data into the DOM at once. Virtual lists primarily solve the above problem, with the core concept being to only render the data currently needed in the viewport. Thus, only a small number of elements are rendered each time, while also simulating the total height of the list's data. This allows for rendering only the data in the current viewport while achieving an effect similar to infinite scrolling. As users scroll the list, new items entering the visible range are loaded in real-time, and old items leaving the visible range are removed.

Through the interactive effect above, you can better understand the principle behind it. By setting the number of data items and the height of each item, you can simulate the relationship between the viewable area and the entire list by sliding the Scroll Position. Assuming the viewable area only has space for 10 items, even if there are 100 items in total, only 10 items need to be rendered, and the remaining 90 items are presented through simulation, 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, it's clear that the most important aspect of implementing a virtual list is knowing the start and end points of the viewable area, which are startIndex and endIndex, respectively. Their calculation methods are as follows:

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

Next, it's necessary to calculate the actual position of each item to know its position in the list:

const itemPosition = itemIndex * itemHeight;

After obtaining the start and end points of the viewable area and the actual position of each item from the above calculations, the next step is to calculate the total height of all data:

const totalHeight = itemCount * itemHeight;

A final small trick is to use overscan to optimize the rendering of the list, which involves rendering areas outside the viewable area as well. This avoids the list jumping when scrolling due to areas outside the viewable area not being rendered.

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 layer and update scrollTop in the event to implement a very 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

Although the above implementation cannot be used in a production environment, it provides a basic understanding of the principle behind virtual lists. For using virtual lists in a production environment, it is recommended to use libraries such as 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