唔~,好久没有更新博文了,一年前的这个时候用 Vue 实现过一版虚拟列表,不过现在已经忘光光了,故写一篇博文帮忙回忆与巩固一下虚拟列表的知识。

前言

本文对虚拟列表出现的原因与解决的问题不再介绍,着重讲解虚拟列表实现的思路以及原理。

技术栈

  • React
  • Vite
  • TS

预览链接

问题分析

基本思路

  1. 虚拟列表的基本结构:

    • 容器层(container):整个虚拟列表的包裹层,滚动事件是在这个包裹层出发的。
    • 幻影层(phantom):容器层的子元素,用于撑开整个容器,获得原生的滚动条展示,不渲染内容。
    • 内容层(content):容器层的子元素,用于渲染列表内容,列表元素动态变化。
  2. 虚拟列表的滚动效果实现:
    首先我们知道,虚拟列表的视口的渲染元素是动态增减的,那么其平滑的滚动效果是如何出现的?这里有一个误区需要注意,虚拟列表的滚动效果就是原生的滚动效果,虚拟列表并不参与scrollTop属性的设置,而是通过CSStransform属性来实现的。
    这里有两个基本点需要了解

    1. 虚拟列表对小于等于元素高度的滚动是直接体现的原生的滚动效果。
    2. 虚拟列表对大于元素高度的滚动是通过CSStransform属性来实现的,具体体现为元素消失在视口的时候,会对内容层的transform属性增减一个元素的高度,实现内容与滚动的一致性。

细节分析

  1. 容器高度分析
    1. 容器层的高度:容器层的高度由幻影层的高度撑开,幻影层的高度依赖于需要渲染数据的总量与高度。
    2. 渲染区域的高度:即视口高度,由元素的clientHeight获取。
  2. 渲染的数据的数量
    1. 渲染的数据的数量:渲染的数据的数量由渲染区域的高度与元素的高度计算得出。
    2. 总数据的数量:即数据的长度。
  3. 可视数据索引的计算方式
    1. 开始索引:开始索引的计算方式为Math.floor(scrollTop / itemHeight)
    2. 结束索引:开始索引加上可视渲染数据的个数。

what can i say, 多说无益,下面直接上代码。

代码实现

虚拟列表的基本结构

  1. 首先搭建虚拟列表的基本结构

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    const VirtualList1: React.FC<VirtualList1Props> = (props) => {
      <div
        ref={list}
        className="infinite-list-container"
        onScroll={handleScroll}
      >
        {/* 最外层容器,获取 scrollTop 属性,并监听滚动事件 */}
        <div
          className="infinite-list-phantom"
          style={{ height: `${listHeight}px` }}
        ></div>
        {/* 用于将最外层容器的高度撑开,获取滚动效果 */}
        <div
          className="infinite-list"
          style={{ transform: `translateY(${startOffset}px` }}
        >
          {/* 实际的渲染层,动态增减元素渲染,减少过多 dom 元素渲染带来的性能损耗 */}
          {visibleData.map((item) => (
            <div
              className="infinite-list-item"
              style={{ height: itemSize, lineHeight: itemSize + 'px' }}
              key={item.id}
            >
              {item.value}
            </div>
          ))}
        </div>
      </div>;
    };
  2. 声名需要用到的变量

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    // 原数据与列表项的高度
    const { listData = [], itemSize = 200 } = props;
    // 视口的高度
    const [screenHeight, setSceenHeight] = useState(0);
    // 渲染数据起始索引
    const [startIndex, setStartIndex] = useState(0);
    // 实际渲染数据的 2D 位移偏移量
    const [startOffset, setStartOffset] = useState(0);
    
    // 最外层容器,获取 scrollTop 属性,并监听滚动事件
    const list = useRef<HTMLDivElement>(null);
    
    // 内层撑开外层容器的高度,由数据量和元素高度决定
    const listHeight = useMemo(
      () => listData.length * itemSize,
      [listData.length, itemSize]
    );
    // 可视区域渲染元素数据的数量,由视口高度与元素高度决定
    const visibleCount = useMemo(
      () => Math.ceil(screenHeight / itemSize),
      [screenHeight, itemSize]
    );
    // 结束索引,由开始索引和可视数据的数量决定
    const endIndex = useMemo(
      () => startIndex + visibleCount,
      [startIndex, visibleCount]
    );
    // 实际渲染数据的列表,由总数据与起始索引与结束索引决定
    const visibleData = useMemo(
      () => listData.slice(startIndex, Math.min(listData.length, endIndex)),
      [startIndex, endIndex, listData]
    );
  3. 处理元素渲染的副作用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    // 保证 endIndex 与 startIndex的值同步
    useEffect(() => {
      setEndIndex(startIndex + visibleCount);
    }, [visibleCount, startIndex]);
    // 在视口发生 Resize 事件的时候需要重新设置高度,出发 visibleCount 数量的变化
    useEffect(() => {
      const element = list.current;
      setSceenHeight(element?.clientHeight ?? 0);
      const resizeObserver = new ResizeObserver((entries) => {
        for (const entry of entries) {
          setSceenHeight(entry.contentRect.height);
        }
      });
    
      if (element) {
        resizeObserver.observe(element);
      }
    
      return () => {
        if (element) {
          resizeObserver.unobserve(element);
        }
      };
    }, []);
  4. 监听滚动事件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    const handleScroll = () => {
      // 获取外部元素的 scrollTop 属性
      const scrollTop = list.current?.scrollTop ?? 0;
      // 计算出当前可视数据的起始 index ,注意需要向下取整,由于元素的不完全展示
      const startIndexValue = Math.floor(scrollTop / itemSize);
      setStartIndex(startIndexValue);
      // 小于等于元素高度的滚动位移的体现由原生的 scrollTop 属性体现,反之由内容元素的 2D 位移与 scrollTop属性共同实现
      setStartOffset(scrollTop - (scrollTop % itemSize));
    };
  5. 完整代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    import React, { useEffect, useMemo, useRef, useState } from 'react';
    import './index.css';
    
    export interface ItemType {
      id: number;
      value: number;
    }
    interface VirtualList1Props {
      listData: ItemType[];
      itemSize: number;
    }
    
    const VirtualList1: React.FC<VirtualList1Props> = (props) => {
      // 原数据与列表项的高度
      const { listData = [], itemSize = 200 } = props;
      // 视口的高度
      const [screenHeight, setSceenHeight] = useState(0);
      // 渲染数据起始索引
      const [startIndex, setStartIndex] = useState(0);
      // 实际渲染数据的 2D 位移偏移量
      const [startOffset, setStartOffset] = useState(0);
    
      // 最外层容器,获取 scrollTop 属性,并监听滚动事件
      const list = useRef<HTMLDivElement>(null);
    
      // 内层撑开外层容器的高度,由数据量和元素高度决定
      const listHeight = useMemo(
        () => listData.length * itemSize,
        [listData.length, itemSize]
      );
      // 可视区域渲染元素数据的数量,由视口高度与元素高度决定
      const visibleCount = useMemo(
        () => Math.ceil(screenHeight / itemSize),
        [screenHeight, itemSize]
      );
      // 结束索引,由开始索引和可视数据的数量决定
      const endIndex = useMemo(
        () => startIndex + visibleCount,
        [startIndex, visibleCount]
      );
      // 实际渲染数据的列表,由总数据与起始索引与结束索引决定
      const visibleData = useMemo(
        () => listData.slice(startIndex, Math.min(listData.length, endIndex)),
        [startIndex, endIndex, listData]
      );
    
      // 在视口发生 Resize 事件的时候需要重新设置高度,触发 visibleCount 数量的变化
      useEffect(() => {
        const element = list.current;
        setSceenHeight(element?.clientHeight ?? 0);
        const resizeObserver = new ResizeObserver((entries) => {
          for (const entry of entries) {
            setSceenHeight(entry.contentRect.height);
          }
        });
    
        if (element) {
          resizeObserver.observe(element);
        }
    
        return () => {
          if (element) {
            resizeObserver.unobserve(element);
          }
        };
      }, []);
    
      const handleScroll = () => {
        // 获取外部元素的 scrollTop 属性
        const scrollTop = list.current?.scrollTop ?? 0;
        // 计算出当前可视数据的起始 index ,注意需要向下取整,由于元素的不完全展示
        const startIndexValue = Math.floor(scrollTop / itemSize);
        setStartIndex(startIndexValue);
        // 小于等于元素高度的滚动位移的体现由原生的 scrollTop 属性体现,反之由内容元素的 2D 位移与 scrollTop属性共同实现
        setStartOffset(scrollTop - (scrollTop % itemSize));
      };
    
      return (
        <div
          ref={list}
          className="infinite-list-container"
          onScroll={handleScroll}
        >
          {/* 最外层容器,获取 scrollTop 属性,并监听滚动事件 */}
          <div
            className="infinite-list-phantom"
            style={{ height: `${listHeight}px` }}
          ></div>
          {/* 用于将最外层容器的高度撑开,获取滚动效果 */}
          <div
            className="infinite-list"
            style={{ transform: `translateY(${startOffset}px` }}
          >
            {/* 实际的渲染层,动态增减元素渲染,减少过多 dom 元素渲染带来的性能损耗 */}
            {visibleData.map((item) => (
              <div
                className="infinite-list-item"
                style={{ height: itemSize, lineHeight: itemSize + 'px' }}
                key={item.id}
              >
                {item.value}
              </div>
            ))}
          </div>
        </div>
      );
    };
    
    export default VirtualList1;

对不定高元素的渲染的处理

在上面的虚拟列表的实现中我们假定了列表项的高度是固定的,但在很多场景中,由于元素的内容高度与宽度不定,可能会造成列表元素的高度的不一致性,这需要一种能支持动态高度的虚拟列表。

实现动态虚拟列表的难点,在于如何正确的计算起始索引与结束索引并正确的展示元素。

这里我们为了实现一个动态的虚拟列表,采用现行用预估高度渲染,然后渲染后动态更新高度的手段。
这里核心的一点在于,在滚动下滚的时候动态更新高度数组,上滚的时候就可以正确的计算出起始索引与结束索引,从而正常展示元素。下面上代码

  1. 获取实际渲染元素列表的 DOM 实例,用于动态更新元素的高度

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    const items = useRef<HTMLDivElement>(null);
    
    <div
      ref={items}
      className="infinite-list"
      style={{ transform: `translateY(${startOffset}px)` }}
    >
      {visibleData.map((item) => (
        <div className="infinite-list-item" id={item.id} key={item.id}>
          {item.value}
        </div>
      ))}
    </div>;
  2. 维护positions数组,并初始化。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    const [positions, setPositions] = useState<PositionType[]>([]);
    
    useEffect(
      () =>
        setPositions(
          listData.map((_, index) => ({
            index,
            height: estimatedItemSize,
            top: index * estimatedItemSize,
            bottom: (index + 1) * estimatedItemSize,
          }))
        ),
      [estimatedItemSize, listData]
    );
  3. 每次组件更新的时候都需要更新整个位置数组的对象

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    useEffect(() => {
      updateItemSize(startIndex);
    });
    
    const updateItemSize = (startIndexValue: number) => {
      const itemlist = items.current?.children;
      if (itemlist) {
        const elementList = Array.from(itemlist);
        const newPositions = [...positions];
        for (const item of elementList) {
          const rect = item.getBoundingClientRect();
          const height = rect.height;
          const index = Number(item.id.slice(1));
          const oldHeight = positions[index].height;
          const diffValue = oldHeight - height;
          if (diffValue) {
            newPositions[index].bottom -= diffValue;
            newPositions[index].height = height;
            for (let i = index + 1; i < newPositions.length; i++) {
              newPositions[i].top = newPositions[i - 1].bottom;
              newPositions[i].bottom -= diffValue;
            }
            setPositions(newPositions);
          }
        }
      }
      setStartOffset(
        !startIndexValue ? 0 : positions[startIndexValue - 1]?.bottom
      );
    };
  4. 对滚动事件的处理

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    const handleScroll = () => {
      const scrollTop = list.current?.scrollTop ?? 0;
      const startIndexValue = getStartIndex(scrollTop);
      setStartIndex(startIndexValue);
    };
    
    // 二分法查找起始索引
    const getStartIndex = (value: number) => {
      let start = 0;
      let end = positions.length - 1;
      let tempIndex = null;
      while (start <= end) {
        const midIndex = Math.floor((start + end) / 2);
        const midValue = positions[midIndex].bottom;
        if (midValue === value) {
          return midIndex + 1;
        } else if (midValue < value) {
          start = midIndex + 1;
        } else if (midValue > value) {
          if (tempIndex === null || tempIndex > midIndex) {
            tempIndex = midIndex;
          }
          end = end - 1;
        }
      }
      return tempIndex ?? 0;
    };
  5. 完整代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    import React, { useEffect, useMemo, useRef, useState } from 'react';
    import './index.css';
    
    export interface ItemType {
      id: string;
      value: string;
    }
    export interface PositionType {
      index: number;
      height: number;
      top: number;
      bottom: number;
    }
    interface VirtualList2Props {
      listData: ItemType[];
      estimatedItemSize: number;
    }
    
    const VirtualList2: React.FC<VirtualList2Props> = (props) => {
      const { listData = [], estimatedItemSize } = props;
      const [screenHeight, setSceenHeight] = useState(0);
      const [startOffset, setStartOffset] = useState(0);
      const [startIndex, setStartIndex] = useState(0);
      const [positions, setPositions] = useState<PositionType[]>([]);
    
      const list = useRef<HTMLDivElement>(null);
      const items = useRef<HTMLDivElement>(null);
    
      const listHeight = useMemo(
        () => positions[positions.length - 1]?.bottom ?? 0,
        [positions]
      );
      // 根据预估高度计算渲染数量,这里应该使用数量偏小的数量防止展示不全
      const visibleCount = useMemo(
        () => Math.ceil(screenHeight / estimatedItemSize),
        [screenHeight, estimatedItemSize]
      );
      const endIndex = useMemo(
        () => startIndex + visibleCount,
        [startIndex, visibleCount]
      );
      const visibleData = useMemo(
        () => listData.slice(startIndex, Math.min(listData.length, endIndex)),
        [startIndex, endIndex, listData]
      );
    
      useEffect(() => {
        const element = list.current;
        setSceenHeight(element?.clientHeight ?? 0);
        const resizeObserver = new ResizeObserver((entries) => {
          for (const entry of entries) {
            setSceenHeight(entry.contentRect.height);
          }
        });
        if (element) {
          resizeObserver.observe(element);
        }
        return () => {
          if (element) {
            resizeObserver.unobserve(element);
          }
        };
      }, []);
      // 初始化位置数组
      useEffect(
        () =>
          setPositions(
            listData.map((_, index) => ({
              index,
              height: estimatedItemSize,
              top: index * estimatedItemSize,
              bottom: (index + 1) * estimatedItemSize,
            }))
          ),
        [estimatedItemSize, listData]
      );
      // 时刻更新位置数组,防止渲染不全
      useEffect(() => {
        updateItemSize(startIndex);
      });
      // 处理滚动事件,二分查找
      const handleScroll = () => {
        const scrollTop = list.current?.scrollTop ?? 0;
        const startIndexValue = getStartIndex(scrollTop);
        setStartIndex(startIndexValue);
      };
      // 更新位置数组
      const updateItemSize = (startIndexValue: number) => {
        const itemlist = items.current?.children;
        if (itemlist) {
          const elementList = Array.from(itemlist);
          const newPositions = [...positions];
          for (const item of elementList) {
            const rect = item.getBoundingClientRect();
            const height = rect.height;
            const index = Number(item.id.slice(1));
            const oldHeight = positions[index].height;
            const diffValue = oldHeight - height;
            if (diffValue) {
              newPositions[index].bottom -= diffValue;
              newPositions[index].height = height;
              for (let i = index + 1; i < newPositions.length; i++) {
                newPositions[i].top = newPositions[i - 1].bottom;
                newPositions[i].bottom -= diffValue;
              }
              setPositions(newPositions);
            }
          }
          setStartOffset(
            !startIndexValue ? 0 : newPositions[startIndexValue - 1]?.bottom
          );
        }
      };
      // 二分查找获取索引
      const getStartIndex = (value: number) => {
        let start = 0;
        let end = positions.length - 1;
        let tempIndex = null;
        while (start <= end) {
          const midIndex = Math.floor((start + end) / 2);
          const midValue = positions[midIndex].bottom;
          if (midValue === value) {
            return midIndex + 1;
          } else if (midValue < value) {
            start = midIndex + 1;
          } else if (midValue > value) {
            if (tempIndex === null || tempIndex > midIndex) {
              tempIndex = midIndex;
            }
            end = end - 1;
          }
        }
        return tempIndex ?? 0;
      };
    
      return (
        <div
          ref={list}
          className="infinite-list-container"
          onScroll={handleScroll}
        >
          <div
            className="infinite-list-phantom"
            style={{ height: `${listHeight}px` }}
          ></div>
          <div
            ref={items}
            className="infinite-list"
            style={{ transform: `translateY(${startOffset}px)` }}
          >
            {visibleData.map((item) => (
              <div className="infinite-list-item" id={item.id} key={item.id}>
                {item.value}
              </div>
            ))}
          </div>
        </div>
      );
    };
    
    export default VirtualList2;

对滚动过快白屏现象的处理

解决方法为上下都渲染一个缓冲区,防止滚动过快白屏。

  1. 定义上下缓冲区的大小。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    const {
      listData = [],
      itemSize = 200,
      estimatedItemSize,
      bufferScale = 1,
    } = props;
    
    const belowCount = useMemo(
      () => Math.min(listData.length - endIndex, bufferScale * visibleCount),
      [endIndex, bufferScale, visibleCount, listData.length]
    );
    const visibleData = useMemo(() => {
      return listData.slice(startIndex - aboveCount, endIndex + belowCount);
    }, [listData, startIndex, aboveCount, endIndex, belowCount]);
  2. 由于顶部多渲染了一些元素,所以整体 2D 位移的值需要减小

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    const updateItemSize = (startIndexValue: number) => {
      const itemlist = items.current?.children;
      if (itemlist) {
        const elementList = Array.from(itemlist);
        const newPositions = [...positions];
        for (const item of elementList) {
          const rect = item.getBoundingClientRect();
          const height = rect.height;
          const index = Number(item.id.slice(1));
          const oldHeight = positions[index].height;
          const diffValue = oldHeight - height;
          if (diffValue) {
            newPositions[index].bottom -= diffValue;
            newPositions[index].height = height;
            for (let i = index + 1; i < newPositions.length; i++) {
              newPositions[i].top = newPositions[i - 1].bottom;
              newPositions[i].bottom -= diffValue;
            }
            setPositions(newPositions);
          }
        }
        // 这里需要减去顶部元素把视口元素挤下去的距离
        if (startIndexValue) {
          const size =
            positions[startIndexValue]?.top -
              positions[startIndexValue - aboveCount]?.top ?? 0;
          setStartOffset(positions[startIndexValue - 1]?.bottom - size);
        } else {
          setStartOffset(0);
        }
      }
    };

总结

至此,我们完成了一个较为完备的虚拟列表的实现。支持动态高度并能解决滚动过快的白屏问题。
但是对于图片类渲染后决定高度的情况我们没有优化,可以考虑后续来继续解决,这里有两种解决方案:

  1. 在图片初始化的时候就固定图片的信息,固定宽高渲染,然后渲染。
  2. 根据ResizeObserver对象或者img元素的onload事件来监听图片的加载,然后更新高度数据。

另外频繁触发滚动事件可能对性能有些损耗,我们可以考虑采用节流函数来控制scroll事件的执行次数,详情查看 Demo4

1
2
3
4
5
6
7
8
9
10
// 167 ms 适配刷新率为 60hz 的屏幕
const handleScroll = throttle(
  () => {
    const scrollTop = list.current?.scrollTop ?? 0;
    const startIndexValue = getStartIndex(scrollTop);
    setStartIndex(startIndexValue);
  },
  16.7,
  { leading: false, trailing: true }
);