preface

In business scenarios, we often encounter the need to render a list of data in a scrolling container. If the data is small and all the data is rendered to the list at once, there is no problem with the experience. However, for a long list, such as 1000 data query through the interface, if you want to render all 1000 data at the same time and generate the corresponding 1000 native DOM nodes, This can cause the browser to occupy the main thread too long in the rendering phase, causing the user to feel stutter and response delays when manipulating the page.

To address this problem, we can optimize data rendering: it doesn’t matter to the user that you render all the data into the list container at once, but the DOM data in the visible area of the container. So what we need to do is combine the scrollbar position with the visual area of the container and render the corresponding data into the scrollcontainer.

The react-tiny-virtual-list component library on Npm. js provides the function of virtual scroll list. This article will also use Hooks to write a simple virtual scroll component based on the internal implementation of react-tiny-virtual-list. This component will include some TS type definitions. The implementation code is about 140 lines.

The test case

import { VirtualList } from '.. /src/index';

const rowStyle = { padding: '0 10px'.borderBottom: '1px solid grey'.lineHeight: '50px' }

function App() {
    const renderItem = ({ style, index }: { style: any; index: number }) = > {
        return (
            <div style={{ . rowStyle.. style }} key={index}>
                Row #{index}
            </div>
        );
    };
      
    return (
        <VirtualList
            width="auto"
            height={400}
            itemCount={1000}
            renderItem={renderItem}
            itemSize={50}
            className="VirtualList"
        />)}/ / style
.VirtualList {
    margin: 20px;
    background: #FFF;
    border-radius: 2px;
    box-shadow:
      0 2px 2px 0 rgba(0.0.0.14.),
      0 3px 1px -2px rgba(0.0.0.2.),
      0 1px 5px 0 rgba(0.0.0.12.);
}
Copy the code

The DOM structure after rendering is as follows:

Code implementation

Props definition:

Before familiarizing yourself with a component, the first thing you should do is to understand what properties the component exposes to the user. Components developed based on TS are also easier to read.

export interface IProps {
    width: number | string; / / width of the list
    height: number | string; / / the list
    itemCount: number; / / item number
    itemSize: number; // item fixed height/width (depending on scrolling direction)
    renderItem(itemInfo: { index: number.style: ItemStyle }): React.ReactNode; // item renders the nodeclassName? :string; style? : React.CSSProperties; scrollDirection? : DIRECTION;// Should the list scroll vertically or horizontally? Either "vertical" (default) or "horizontal"overscanCount? :number; // Number of additional buffer items rendered above/belowscrollOffset? :number; // Can be used to set the initial roll offsetonScroll? (offset:number.event: Event): void;
}
Copy the code

From the Props definition, we can see that there are five required properties:

  • Width: scroll list width;
  • Height: The height of the scroll list, as in the test case above, is a scroll list with a height of 400px;
  • ItemCount: Indicates the number of items in a scroll list, for example, 1000 items.
  • ItemSize: The size of a single item. Default is vertical scrolling. ItemSize represents the height of each item to render.
  • RenderItem: DOM node for each piece of data to render.

Implementation idea:

The implementation of a virtual scroll list can be completed by following the required properties above.

  • We first need a div as the scroll container, in PropsWidth, height,Applies to the element and is defined on the containeroverflow: auto;
  • Second, we need a div as the child of the scroll container. This div will be sized according to the PropsItemCount, itemSizeMultiply it out to get a long list;
  • So given the long list, how do we position the item? throughposition: absolutecontroltop / leftTo determine the position of the item;
  • How about controlling the number of items displayed in the visual area? By dividing the height of the container by the itemSize, you can calculate how many items can be displayed in the visible area of the container.

The DOM structure:

const sizeProp = {
    [DIRECTION.VERTICAL]: 'height',
    [DIRECTION.HORIZONTAL]: 'width'};const positionProp = {
    [DIRECTION.VERTICAL]: 'top',
    [DIRECTION.HORIZONTAL]: 'left'};const STYLE_WRAPPER: React.CSSProperties = {
    overflow: 'auto'.willChange: 'transform'.WebkitOverflowScrolling: 'touch'};const STYLE_INNER: React.CSSProperties = {
    position: 'relative'.minWidth: '100%'.minHeight: '100%'};const VirtualList: React.FC<IProps> = (props) = > {
    const { 
        width, height, itemCount, itemSize, renderItem, style = {}, onScroll, scrollOffset,
        overscanCount = 3, scrollDirection = DIRECTION.VERTICAL, ... otherProps } = props;// ...

    constwrapperStyle = { ... STYLE_WRAPPER, ... style, height, width };constinnerStyle = { ... STYLE_INNER, [sizeProp[scrollDirection]]: itemCount * itemSize,// Calculate the overall height/width of the list (depending on vertical or horizontal)
    };
    
    return (
        <div ref={rootNode} {. otherProps} style={wrapperStyle}>
            <div style={innerStyle}>{items}</div>
        </div>)}Copy the code

As you can see from the code above, the DOM structure already satisfies the first two steps of the implementation idea.

Visual range:

Once we have a container, we can calculate the range of items that the container can hold in the visible area:

const getVisibleRange = () = > { // Get visual range
    const { clientHeight = 0, clientWidth = 0 } = rootNode.current as HTMLDivElement || {};
    const containerSize: number = scrollDirection === DIRECTION.VERTICAL ? clientHeight : clientWidth;
    let start = Math.floor(offset / itemSize - 1); // start --> integer (index = 0)
    let stop = Math.ceil((offset + containerSize) / itemSize - 1); // stop --> round up
    return {
        start: Math.max(0, start - overscanCount),
        stop: Math.min(stop + overscanCount, itemCount - 1),}}const { start, stop } = getVisibleRange();
Copy the code

Take vertical scrolling for example:

  • RootNode is a scrolling container DOM bound by ref, through which we can get the height of the container;
  • Offset is the internal state of the component that records the current scroll bar positionoffset / itemSizeYou can calculate the index of the first node in the container visible area in the data list under the current scrolling environment, i.estart;
  • offset + containerSizeThe index of the last node in the data list under the container’s viewable region can be calculated, i.estop;
  • OverscanCount is the number of additional buffer items rendered above/below the visible items. Helps reduce interruptions or flickers during browser scrolling.

The Hooks function component calls getVisibleRange when it executes, and the DOM has not yet been rendered to the page. If you access the container node via rootNode.current, you cannot get the container height.

We could have done it the other way, because the component Props passed height, which is the height of the container, and we didn’t need to get it.

But what if the user doesn’t pass height as a number? He just wants to pass a string, such as height=”80%”, so that height cannot be used to calculate the viewable range.

Since the function component can’t get the DOM information when executing, wait until it is mounted to trigger a component update to get the DOM information. This will be done in exchange for functionality through an update, but don’t worry, many scenarios require a negligible update in exchange for functionality.

After the above analysis, we can complete it in useEffect, of course, with the help of a state:

const VirtualList: React.FC<IProps> = (props) = > {
    const [isMount, setMount] = useState<boolean> (false);

    useEffect(() = > {
        if(! isMount) setMount(true); // Force an update for getVisibleRange to get containerSize after DOM mount} []);// ...
}
Copy the code

Render the item:

With visual range, we can calculate top and render items within that range into the scroll container:

const VirtualList: React.FC<IProps> = (props) = > {
    // ...
    
    const getStyle = (index: number) = > {
        const style = styleCache[index];
        if (style) return style;

        return (styleCache[index] = {
            position: 'absolute'.top: 0.left: 0.width: '100%',
            [sizeProp[scrollDirection]]: props.itemSize, // height / width
            [positionProp[scrollDirection]]: props.itemSize * index, // top / left
        });
    }
    
    // ...
    
    const items: React.ReactNode[] = [];
    const { start, stop } = getVisibleRange();
    for (let index = start; index <= stop; index ++) {
        items.push(
            renderItem({ index, style: getStyle(index) })
        );
    }
    
    return (
        <div ref={rootNode} {. otherProps} style={wrapperStyle}>
            <div style={innerStyle}>{items}</div>
        </div>)}Copy the code

Scroll to onScroll

As the last step, the container listens for the scroll event and updates the item node in the container’s visual area by getting the scroll distance offsetTop to offset (the state stored within the component’s scroll distance), which triggers the component to re-render.

const VirtualList: React.FC<IProps> = (props) = > {
    const { 
        width, height, itemCount, itemSize, renderItem, style = {}, onScroll, scrollOffset,
        overscanCount = 3, scrollDirection = DIRECTION.VERTICAL, ... otherProps } = props;const [offset, setOffset] = useState<number>(scrollOffset || 0);
    
    useEffect(() = > {
        if(! isMount) setMount(true); // Force an update for getVisibleRange to get containerSize after DOM mountrootNode.current? .addEventListener('scroll', handleScroll, {
            passive: true.// To optimize browser page scrolling performance
        });
        return () = >{ rootNode.current? .removeEventListener('scroll', handleScroll); }} []);const handleScroll = (event: Event) = > {
        const { scrollTop = 0, scrollLeft = 0 } = rootNode.current as HTMLDivElement;
        const newOffset = scrollDirection === DIRECTION.VERTICAL ? scrollTop : scrollLeft;
        if (newOffset < 0|| newOffset === offset || event.target ! == rootNode.current) {return;
        }
        setOffset(newOffset);
        if (typeof onScroll === 'function') { onScroll(offset, event); }};// ...
}
Copy the code

Complete code:

import React, { useRef, useState, useEffect } from 'react';

export enum DIRECTION {
    HORIZONTAL = 'horizontal',
    VERTICAL = 'vertical',}export interface ItemStyle {
    position: 'absolute'; top? :number;
    left: number;
    width: string | number; height? :number; marginTop? :number; marginLeft? :number; marginRight? :number; marginBottom? :number; zIndex? :number;
}

export interface IProps {
    width: number | string; / / width of the list
    height: number | string; / / the list
    itemCount: number; / / item number
    itemSize: number; // item fixed height/width (depending on scrolling direction)
    renderItem(itemInfo: { index: number.style: ItemStyle }): React.ReactNode; // item renders the nodeclassName? :string; style? : React.CSSProperties; scrollDirection? : DIRECTION;// Should the list scroll vertically or horizontally? Either "vertical" (default) or "horizontal"overscanCount? :number; // Number of additional buffer items rendered above/belowscrollOffset? :number; // Can be used to set the initial roll offsetonScroll? (offset:number.event: Event): void;
}

const STYLE_WRAPPER: React.CSSProperties = {
    overflow: 'auto'.willChange: 'transform'.WebkitOverflowScrolling: 'touch'};const STYLE_INNER: React.CSSProperties = {
    position: 'relative'.minWidth: '100%'.minHeight: '100%'};const sizeProp = {
    [DIRECTION.VERTICAL]: 'height',
    [DIRECTION.HORIZONTAL]: 'width'};const positionProp = {
    [DIRECTION.VERTICAL]: 'top',
    [DIRECTION.HORIZONTAL]: 'left'};const VirtualList: React.FC<IProps> = (props) = > {
    const { 
        width, height, itemCount, itemSize, renderItem, style = {}, onScroll, scrollOffset,
        overscanCount = 3, scrollDirection = DIRECTION.VERTICAL, ... otherProps } = props;const rootNode = useRef<HTMLDivElement | null> (null);
    const [offset, setOffset] = useState<number>(scrollOffset || 0);
    const [styleCache] = useState<{ [id: number]: ItemStyle }>({});
    const [isMount, setMount] = useState<boolean> (false);

    useEffect(() = > {
        if(! isMount) setMount(true); // Force an update for getVisibleRange to get containerSize after DOM mount
        if(scrollOffset) scrollTo(scrollOffset); rootNode.current? .addEventListener('scroll', handleScroll, {
            passive: true.// To optimize browser page scrolling performance
        });
        return () = >{ rootNode.current? .removeEventListener('scroll', handleScroll); }} []);const handleScroll = (event: Event) = > {
        const { scrollTop = 0, scrollLeft = 0 } = rootNode.current as HTMLDivElement;
        const newOffset = scrollDirection === DIRECTION.VERTICAL ? scrollTop : scrollLeft;
        if (newOffset < 0|| newOffset === offset || event.target ! == rootNode.current) {return;
        }
        setOffset(newOffset);
        if (typeof onScroll === 'function') { onScroll(offset, event); }};const scrollTo = (value: number) = > {
        if(scrollDirection === DIRECTION.VERTICAL) { rootNode.current! .scrollTop = value; }else {
            rootNode.current!.scrollLeft = value;
        }
    }

    const getVisibleRange = () = > { // Get visual range
        const { clientHeight = 0, clientWidth = 0 } = rootNode.current as HTMLDivElement || {};
        const containerSize: number = scrollDirection === DIRECTION.VERTICAL ? clientHeight : clientWidth;
        let start = Math.floor(offset / itemSize - 1); // start --> integer (index = 0)
        let stop = Math.ceil((offset + containerSize) / itemSize - 1); // stop --> round up
        return {
            start: Math.max(0, start - overscanCount),
            stop: Math.min(stop + overscanCount, itemCount - 1),}}const getStyle = (index: number) = > {
        const style = styleCache[index];
        if (style) return style;

        return (styleCache[index] = {
            position: 'absolute'.top: 0.left: 0.width: '100%',
            [sizeProp[scrollDirection]]: props.itemSize, // height / width
            [positionProp[scrollDirection]]: props.itemSize * index, // top / left
        });
    }

    constwrapperStyle = { ... STYLE_WRAPPER, ... style, height, width };constinnerStyle = { ... STYLE_INNER, [sizeProp[scrollDirection]]: itemCount * itemSize,// Calculate the overall height/width of the list (depending on vertical or horizontal)
    };
    const items: React.ReactNode[] = [];
    const { start, stop } = getVisibleRange();
    for (let index = start; index <= stop; index ++) {
        items.push(
            renderItem({ index, style: getStyle(index) })
        );
    }
    
    return (
        <div ref={rootNode} {. otherProps} style={wrapperStyle}>
            <div style={innerStyle}>{items}</div>
        </div>)}export default VirtualList;
Copy the code

The last

If there are inadequacies in the compilation of this article, 👏 welcomes readers to put forward valuable suggestions, the author to correct.

react-tiny-virtual-list