虚拟列表

430 阅读5分钟

虚拟列表使用场景

前端的业务开发中会遇到一些数据量大且无法使用分页方式来加载的列表,一般把这种列表叫做长列表。完整渲染的情况下,对于浏览器性能将会是个极大的挑战,会造成滚动卡顿。非完整渲染的长列表一般有两种方式:

  • 懒加载:即无限滚动,每次只渲染一部分,等剩余部分滚动到课件区域,就在渲染另一部分。(加载数据越来越多时,浏览器的回流和重绘的开销将会越来越大,整个滑动也会造成卡顿
  • 可视区域渲染:只渲染可见部分,不可见部分不渲染或者部分渲染。

虚拟列表就是采用的可视区域渲染。

虚拟列表一般分为

  • 定高虚拟列表
  • 不定高虚拟列表

定高虚拟列表

例如:

假设有1万条记录需要同时渲染,屏幕的可见区域的高度为500px,而列表每项的高度是50px,则此时屏幕中最多只能看到10个列表项,那么首次渲染时只需要加载10条。

当滚动发生时,通过计算当前滚动值得知此时在屏幕可见区域应该显示的列表项。(例如当滚动发生时,滚动条距顶部的位置为150px,则可见区域的列表项为第4项至第13项)

ee.jpeg

从antd4.0开始select组件已使用虚拟滚动技术(比3.0性能更好),如下《魔神运营系统》的一个日志模块的道具下拉数据:

jhbpd-11rsz.gif

可以看出每次页面并没有全部渲染数据,每次只渲染10条数据,滚动开始后此10条数据不断更换。

实现过程

虚拟列表的实现,就是在首屏加载的时候,只加载在可视区域内需要的列表项,当滚动发生时,动态通过计算获得可视区域内的列表项,并将非可视区域内存在的列表项删除。

55.png 数据定义:

虚拟列表实现的具体步骤:

  • 不把长列表数据一次性全部直接渲染在页面上
  • 截取长列表一部分数据用来填充可视区域
  • 长列表数据不可视部分使用空白占位填充(图中的startOffset区域)
  • 监听滚动事件根据滚动位置动态改变可视列表
  • 监听滚动事件根据滚动位置动态改变空白填充

简易实现一个虚拟列表:

数据推算:

  • 列表总高度wraperHeight= allDataList.length * itemHeight
  • 可视区域列表项数 visibleCount = Math.ceil(screenHeight / itemHeight)
  • 数据的起始索引startIndex = Math.floor(scrollTop / itemHeight)
  • 数据的结束索引endIndex = startIndex + visibleCount
  • 列表显示数据为showDataList = allDataList.slice(startIndex,endIndex)
  • 上滚动区域内容startOffset = itemHeight * startIndex

代码呈上:

import { useRef, useState, useMemo, useCallback } from "react";
import "./styles.css";

const screenHeight = 800;
const itemHeight = 50;
const allDataList = [];
for (let i = 0; i < 1000; i++) {
    allDataList.push({ id: i, value: i });
}

export default function App() {

    const containerRef = useRef();
    const [startIndex, setStartIndex] = useState(0);

    // 真实全部数据展示的高度,用于撑开container的盒子
    const wraperHeight = useMemo(() => {
        return allDataList.length * itemHeight;
    }, []);

    // 可视区域列表项数
    const visibleCount = useMemo(() => {
        return Math.ceil(screenHeight / itemHeight);
    }, []);

    // 可视区域结束索引
    const endIndex = useMemo(() => {
        return Math.min(startIndex + visibleCount, allDataList.length);
    }, [startIndex, visibleCount]);

    // 获取可视区域渲染数据
    const showDataList = useMemo(() => {
        return allDataList.slice(startIndex, endIndex);
    }, [startIndex, endIndex]);

    // 上滚动区域内容
    const startOffset = useMemo(() => {
        return startIndex * itemHeight;
    }, [startIndex]);

    const handleScroll = useCallback(
        (e) => {
            if (e.target !== containerRef.current) return;
            const scrollTop = e.target.scrollTop;
            let currentIndex = Math.floor(scrollTop / itemHeight);
            if (currentIndex !== startIndex) {
                setStartIndex(currentIndex);
            }
    },[startIndex]);

    return (

        <div
        ref={containerRef}
        className="container"
        style={{ height: `${screenHeight}px` }}
        onScroll={handleScroll}
        >

            <div
                className="content"
                style={{
                    height: `${wraperHeight}px`,
                    transform: `translate3d(0,${startOffset}px,0)`
                }}
            >
                {showDataList.map((item, index) => (
                    <div
                    className="item"
                    key={index}
                    style={{ height: `${itemHeight}px` }}
                    >
                    {item.value}
                    </div>
                ))}
            </div>
        </div>
);
}

虚拟列表demo

优化

在上面的列子中,发现快速滑动时会出现来不及渲染边缘处空白的情况。如果实际渲染时,上下多渲染一些元素用来过渡快速滑动时来不及渲染的问题。

用bufferSize来表示上下多渲染的元素,则优化后的handleScroll函数及其他部分代码为:

// 上下多渲染3个元素
const bufferSize = 3;
...
const [originStartIndex, setOriginStartIndex] = useState(0);
// 可视区域结束索引
const endIndex = useMemo(() => {
return Math.min(originStartIndex + visibleCount + bufferSize,allDataList.length);
}, [startIndex, visibleCount]);
const handleScroll = useCallback(
        (e) => {
            if (e.target !== containerRef.current) return;
            const scrollTop = e.target.scrollTop;
            let currentIndex = Math.floor(scrollTop / itemHeight);
            if (currentIndex !== originStartIndex) {
                setStartIndex(Math.max(currentIndex - bufferSize, 0));
                setOriginStartIndex(currentIndex);
            }
    },[startIndex]);

虚拟列表定高优化后的demo

在上面《魔神运营系统》的实例中可以看到,页面下拉框只显示了8个道具,dom元素中确渲染了10个,就是上下多渲染了一个元素。

不定高虚拟列表

上面介绍了定高虚拟列表的基本实现原理,在实际应用场景中,列表项的高度会因为很多因素并不相同。

对虚拟列表动态高度的解决方案一般有三种:

  • 传入每个元素对应的行高。(需要事先知道每一个元素的高度(不切实际))
  • 将列表项渲染后屏幕外,对其高度进行测量并缓存,然后再将其渲染至可视区域内(导致渲染成本增加一倍)
  • 先定预估高度进行渲染,等到这些数据渲染成真实dom元素了之后,再获取到他们的真实高度去更新原来设置的预估高度

第三种方式能避免前两种的不足。

实现过程:

html部分,其中phantom容器用来撑开容器,content展示渲染元素

<div className="container" ref={containerRef}>
  <div className="phantom" ref={phantomRef}/>
  <div className="content" ref={contentRef}>
    ...
    <!-- item-1 -->
    <!-- item-2 -->
    <!-- item-3 -->
    ....
  </div>\
</div>

1.初始化计算每个元素的位置

定义组件属性estimatedItemSize做为预估高度。通过预估高度先初始化计算每个元素的位置

当预估高度比实际高度大很多的时候,很容易出现可视区域数据量太少而引起的可视区域出现部分空白。所以我们的预估高度应该设置为列表项产生的最小值,这样尽管可能会多渲染出几条数据,但能保证首次呈现给用户的画面中没有空白

// 根据estimatedItemSize对positions进行初始化
const initCachedPositions = () => {
    const data = [];
    for (let i = 0; i < allDataList.length; ++i) {
        data[i] = {
            index: i,
            height: estimatedRowHeight, // 当前元素的高度
            top: i * estimatedRowHeight, // 
            bottom: (i + 1) * estimatedRowHeight,
            dValue: 0
        };
    }
return data;
};
// 记录所有元素的位置,高度
const [cachedPositions, setCachedPositions] = useState(initCachedPositions());

cachedPositions里面记录了每个元素的top和bottom,所以最后一个元素的bottom就是容器phantom的高度。

2.更新真实元素的位置

// 根据元素渲染真实高度,重新计算各个元素的位置
const updateCachedPositions = () => {
    const nodes = contentRef.current.childNodes;
    const start = nodes[0];
    nodes.forEach((node) => {
        if (!node) return;
        const rect = node.getBoundingClientRect();
        const index = Number(node.id.split("-")[1]);
        const oldHeight = cachedPositions[index].height;
        const dValue = oldHeight - rect.height;
        if (dValue) {
            cachedPositions[index].bottom -= dValue;
            cachedPositions[index].height = rect.height;
            cachedPositions[index].dValue = dValue;
        }
        });
    let startIdx = 0;
    if (start) {
        startIdx = Number(start.id.split("-")[1]);
    }
    const cachedPositionsLen = cachedPositions.length;
    let cumulativeDiffHeight = cachedPositions[startIdx].dValue;
    cachedPositions[startIdx].dValue = 0;

    for (let i = startIdx + 1; i < cachedPositionsLen; ++i) {
        const item = cachedPositions[i];
        cachedPositions[i].top = cachedPositions[i - 1].bottom;
        cachedPositions[i].bottom =
        cachedPositions[i].bottom - cumulativeDiffHeight;

        if (item.dValue !== 0) {
            cumulativeDiffHeight += item.dValue;
            item.dValue = 0;
        }
    }

    setCachedPositions(cachedPositions);
    const height = cachedPositions[cachedPositionsLen - 1].bottom;
    setPhantomHeight(height);
};

3.滚动后更新可视区域开始索引

列表项cachedPositions的bottom属性代表的就是该元素尾部到容器顶部的距离,所以可视区第一个元素的bottom是第一个大于滚动高度的。

因为缓存数据cachedPositions是一个有序数组,所以可以使用二分查找来降低时间复杂性

// 获取开始元素索引

const getStartIndex = (list, scrollTop) => {
    // 二分查找
    let idx = binarySearch(list, scrollTop);
    const targetItem = cachedPositions[idx];
    if (targetItem?.bottom < scrollTop) {
        idx += 1;
    }
    return idx;
};


// 监听滚动事件
const handelScroll = useCallback(
(e) => {
    if (e.target === containerRef.current) {
        const { scrollTop } = e.target;
        const currentIndex = getStartIndex(cachedPositions, scrollTop);
        if (currentIndex !== originStartIndex) {
        setStartIndex(Math.max(currentIndex - bufferSize, 0));
        setOriginStartIndex(currentIndex);
        }
    }
},

[startIndex, originStartIndex, cachedPositions]

);

其中二分查找实现:

const binarySearch = (list, value) => {
    let start = 0;
    let end = list.length - 1;
    let tempIndex = null;

    while (start <= end) {
        tempIndex = Math.floor((start + end) / 2);
        const midValue = list[tempIndex];
        if (midValue.bottom === value) {
            return tempIndex;
        }
        if (midValue.bottom < value) {
        start = tempIndex + 1;
        } else {
            end = tempIndex - 1;
        }
    }
    return tempIndex;
};

更新开始索引后,获取结束索引和渲染数据跟固定高度时计算方式一样。

4.计算偏移量

const transform = useMemo(() => {
    return `translate3d(0,${
    startIndex >= 1 ? cachedPositions[startIndex - 1].bottom : 0
    }px,0)`;
}, [startIndex, cachedPositions]);

不定高虚拟列表完整demo

react虚拟列表推荐库react-virtualized