React实现虚拟列表

334 阅读11分钟

文章前言

永远把别人对你的批评记在心里,别人的表扬,就把它忘了。Hello 大家好~!我是南宫墨言QAQ

在传统的前端开发中,当需要展示大量数据时,例如一个包含数千或数万个元素的列表,将所有数据同时渲染到浏览器中可能会导致性能下降和用户体验不佳。这是因为在渲染过程中,浏览器需要处理大量的DOM元素,而且所有元素的事件处理、样式计算等操作都需要占用大量的计算资源和内存。

为了解决这个问题,虚拟列表技术应运而生。虚拟列表将只渲染当前可见区域的一部分数据,而不是全部渲染。当用户滚动列表时,虚拟列表会动态地加载和卸载需要显示的数据,从而保持DOM树中的元素数量相对较小。

虚拟列表的实现原理通常基于以下几个核心概念:

  1. 可视区域:虚拟列表只会渲染当前用户可见的部分,通常是列表容器可视区域的高度或宽度。
  2. 缓冲区:在可视区域之外的数据会被缓冲,当用户滚动列表时,新的数据会从缓冲区加载进来,同时不再可见的数据会从DOM中卸载以释放资源。
  3. 动态计算:根据滚动位置和列表项的高度(或宽度),虚拟列表会动态计算需要加载和卸载的数据,以及对应的滚动偏移量。

通过使用虚拟列表技术,可以显著提高大型列表的性能。它减少了DOM元素的数量,降低了浏览器的渲染成本,减少了内存消耗,并且能够更好地处理用户交互,提供更流畅的滚动和导航体验。

虚拟列表针对列表项高度可以分为固定高度和动态高度,其关键点是怎么确定渲染节点的索引范围startIndex和endIndex,最终渲染在可见部分的数据就是根据startIndex和endIndex来确定的,下面将对这两种情况进行分析说明

实现示意图:

image.png

查看在线demo

观看到文章最后的话,如果觉得不错,可以点个关注或者点个赞哦!感谢~❤️

文章主体

感谢各位观者的耐心观看,React实现虚拟列表正片即将开始,且听南宫墨言娓娓QAQ道来

image.png

固定高度

固定高度的情况实现虚拟列表相对简单些,可以根据scrollTop、height和itemSize计算出startIndex和endIndx,进而对这个范围内的组件进行实例化显示。

代码实现

1.编写useRangeForFixed用来获取startIndx和endIndx

import React, { useMemo, useState } from "react";
import { FixedSizeListProps } from "../types/fixed";

const useRangeForFixed = ({itemSize, height, itemCount, extraRenderNumber = 2}:FixedSizeListProps) => {
  /** 滚动位置 */
  const [scrollTop, setScrollTop] = useState(0); 

  /**
   * 计算需要渲染的item起始和终点的索引值
   * extraRenderNumber是为了解决滚动时来不及加载元素出现短暂空白区域现象,故在主轴前后额外对渲染几个item
   * 备注:注意处理数组越界的情况
   */
   
  const startIndex = useMemo(()=>{
    /** 滚动距离/itemSize可以得知经过了多少个item,向下取整并减extraRenderNumber获取最开始的index */
    const start = Math.floor(scrollTop / itemSize) - extraRenderNumber
    /** 处理越界情况 */
    return Math.max(start, 0)
  },[itemSize, scrollTop, extraRenderNumber])

  const endIndex = useMemo(()=>{
     /** (滚动距离+ 可视窗口大小)/itemSize可以得知最末尾需要经过了多少个item,向下取整并加extraRenderNumber获取最末尾的index */
    const end = Math.floor((height + scrollTop) / itemSize) + extraRenderNumber
    /** 处理越界情况 */
    return Math.min(end, itemCount - 1)
  },[height, itemCount, itemSize, scrollTop, extraRenderNumber])

  /** 返回渲染的前后index和更新scrollTop的函数 */
  return { startIndex, endIndex, setScrollTop }
}

export { useRangeForFixed }

2.获取到起始和终点后,可以可视范围内的组件进行实例化

/** 需要渲染的items */
const items = []

/** 获取起始和终点索引 */
const {startIndex, endIndex, setScrollTop } = useRangeForFixed(props)

/** 列表长度大于0,对每一项进行处理 */
if(itemCount > 0){
for(let i = startIndex; i< endIndex; i++){
  items.push(
    <Component 
      key={i} 
      index={i} 
      style={{
        left: 0,
        width: '100%',
        height: itemSize,
        top: i * itemSize,
        position: 'absolute',
      }}
    />)
}
}

3.组件的DOM结构

<div 
  style={{
    height,
    overflow:'auto',
    position:'relative',
  }}
  onScroll={(e)=>{
    // flushSync(()=>{
      // @ts-ignore
      setScrollTop(e.target.scrollTop)
    // })
  }}
>
  {/* contentHeight为内容高度 */}
  <div style={{ height: contentHeight }} >
    {items}
  </div>
</div>

总结

在最外层div中设置的高度为可视窗口的高度,内层div的高度为所有item组成的高度,目的是让最外层的div生成正确的滚动条

在最外层的div监听滚动事件,根据监听到的scrollTop值的变化更新startIndex和endIndex,从而去更新可视窗口中组件的渲染

这里使用的React的版本是18,setState是异步的,所以当快速滚动时会出现渲染不实时而导致短暂的空白现象,这里暂时使用ReactDom的flushSync方法,将状态更新变成同步的,解决短暂空白的问题。

注:滚动是一个高频触发的事件,当前方案在列表复杂的情况下,可能会出现性能问题。后续考虑使用函数节流+requestAnimationFrame来进行优化,从而减少空白现象

FixedSizeList组件代码:
import React from "react";
import { useRangeForFixed } from "../hooks/useRangeForFixed";
import { flushSync } from "react-dom";
import { FixedSizeListProps } from "../types/fixed";

export interface Props extends  FixedSizeListProps {
  children: any
}

const FixedSizeList:React.FC<Props> = ({children: Component,...props}) => {

  const {  itemCount, height, itemSize } = props


  /** 内容高度 */
  const contentHeight = itemSize * itemCount

  /** 需要渲染的items */
  const items = []

  /** 获取起始和终点索引 */
  const {startIndex, endIndex, setScrollTop } = useRangeForFixed(props)

  /** 列表长度大于0,对每一项进行处理 */
  if(itemCount > 0){
    for(let i = startIndex; i< endIndex; i++){
      items.push(
        <Component 
          key={i} 
          index={i} 
          style={{
            left: 0,
            width: '100%',
            height: itemSize,
            top: i * itemSize,
            position: 'absolute',
          }}
        />)
    }
  }
  return (
    <div 
      style={{
        height,
        overflow:'auto',
        position:'relative',
      }}
      onScroll={(e)=>{
         flushSync(()=>{
          // @ts-ignore
          setScrollTop(e.target.scrollTop)
         })
      }}
    >
      {/* contentHeight为内容高度 */}
      <div style={{ height: contentHeight }} >
        {items}
      </div>
    </div>
  )
}

export { FixedSizeList }
使用示例:
import React from "react";
import { FixedSizeList } from "../../components/FixedSizeList";
import '../../styles.css'
function Item({ style, index }:{style:React.CSSProperties, index:number}) {
  return (
    <div
      className="item"
      style={{
        ...style,
        backgroundColor: index % 2 === 0 ? '#282c34' : '#087ea4',
        color:'#FFF',
      }}
    >
      {index}
    </div>
  );
}


const Fixed = () => {
  const list = new Array(10000).fill(0).map((item, i) => i);

  return (
    <>
      <p style={{textAlign:'center'}}>列表项高度固定 - 虚拟列表实现</p>
      <FixedSizeList
        height={window.screen.availHeight}
        itemCount={list.length}
        itemSize={50}
      >
        {Item}
      </FixedSizeList>
    </>
  )
}

export { Fixed }

动态高度

动态高度的情况相对固定高度会复杂的多,实现思路和固定高度一样,获取到stratIndex和endIndex就好办了,这里我们通过累加列表项的高度来计算startIndex和endIndex,关键点是如何在渲染前知道所有列表项的高度,因为实际情况列表项的高度是根据内容自适应的,只有在渲染完成后才能知道其真正的高度,所以我们解决方式是提供一个列表项预估高度,当列表项渲染完成后,再去更新高度

代码实现

1.这里通过offsets数组来记录每个列表项到顶部的距离,offsets是height的累加缓存结果,简单的说,假设存在一个heights为[10, 10, 20, 30, 50, 80],那么offsets就是[10, 20, 40, 70, 120, 200], 推导出来的公式为offsets[i] = offsets[i-1] + heights[i],那么问题的关键点在于列表项的高度,这里和固定高度不一样的是不能直接传一个固定的itemHeight,所以这里传一个根据index动态获取列表高度的函数getItemHeight(index),下面是计算offsets的代码:

/** 获取offsets */
  const genOffsets = () => {
    const a = [];
    a[0] = getItemHeight(0);
    for (let i = 1; i < itemCount; i++) {
      a[i] = getItemHeight(i) + a[i - 1]
    }
    return a;
  };

  /** 存储offsets */
  const [offsets, setOffsets] = useState(() => genOffsets())

提供的getItemHeight,它在列表项渲染之前会提供一个预估高度estimatedItemHeight

/** 高度数组,在列表项渲染完成时更新 */
const heightsRef = useRef(new Array(100));

/** 预估高度 */
const estimatedItemHeight = 40;

/** 获取列表项的高度 */
const getHeight = (index:number) => {
    return heightsRef.current[index] ?? estimatedItemHeight;
};

2.编写useRangeForVariable用来获取startIndx和endIndx

import React, { useMemo, useState } from "react";
import { RangeForVariable } from "../types/variable";
import { findNearestItemBinarySearch } from "../utils/variable/findNearestItemBinarySearch";

const useRangeForVariable = ({ height, offsets,itemCount, extraRenderNumber = 2}: RangeForVariable) => {
  /** 滚动位置 */
  const [scrollTop, setScrollTop] = useState(0); 

  /**
   * 计算需要渲染的item起始和终点的索引值
   * extraRenderNumber是为了解决滚动时来不及加载元素出现短暂空白区域现象,故在主轴前后额外对渲染几个item
   * 备注:注意处理数组越界的情况
   */
  const startIndex = useMemo(()=>{
    /** 滚动距离/itemSize可以得知经过了多少个item,向下取整并减extraRenderNumber获取最开始的index */
    const start = findNearestItemBinarySearch(offsets, scrollTop)- extraRenderNumber
    /** 处理越界情况 */
    return Math.max(start, 0)
  },[extraRenderNumber, offsets, scrollTop])

  const endIndex = useMemo(()=>{
     /** (滚动距离+ 可视窗口大小)/itemSize可以得知最末尾需要经过了多少个item,向下取整并加extraRenderNumber获取最末尾的index */

     const end = findNearestItemBinarySearch(offsets, scrollTop + height) + extraRenderNumber
    /** 处理越界情况 */
    return Math.min(end, itemCount - 1)
  },[offsets, extraRenderNumber, itemCount, scrollTop, height])

  /** 返回渲染的前后index和更新scrollTop的函数 */
  return { startIndex, endIndex, setScrollTop }
}

export { useRangeForVariable }

查找startIndex和endIndex使用findNearestItemBinarySearch(二分查找)

export function findNearestItemBinarySearch (offsets:number[], offset: number){
  if(offsets.length < 1) return -1

  /** 低位下标、高位下标 */
  let low = 0, high = offsets.length - 1

  while(low <= high){
    /** 中间下标 */
    const middle = Math.floor((low + high) / 2)
    if(offset === offsets[middle]){
      return middle
    }else if(offset < offsets[middle]){
      high = middle - 1
    }else if(offset > offsets[middle]){
      low = middle + 1
    }
  }
  if(low > 0){
    return low -1
  }else {
    return 0
  }
}

3.获取到起始和终点后,可以可视范围内的组件进行实例化

/** 需要渲染的items */
const items = []

/** 列表长度大于0,对每一项进行处理 */
if(itemCount > 0) {
for (let i = startIndex; i <= endIndex; i++) {
  /** top为上一项offset的值 */
  const top = i === 0 ? 0 : offsets[i - 1]
  /** height为当前与上一项offset的差值 */
  const height = i === 0 ? offsets[0] : offsets[i] - offsets[i - 1]
  items.push(
    <Component
      key={i}
      index={i}
      style={{
        top,
        height,
        width: '100%',
        position: 'absolute',
      }}
    />
  );
}
}

4.组件的DOM结构

<div
  style={{
    height,
    overflow: 'auto',
    position: 'relative'
  }}
  onScroll={(e) => {
    flushSync(() => {
      // @ts-ignore
      setScrollTop(e.target.scrollTop);
    });
  }}
>
  <div style={{ height: contentHeight }}>{items}</div>
</div>

总结

由于高度是手动获取的,当容器宽度发生改变的时候,就会导致列表项的高度变化,此时就需要手动去触发虚拟列表缓存的高度数组,所以建议将宽度固定住

如果列表有图片,在图片加载渲染完前后会出现列表项的高度变化,那么在这前后就需要手动触发虚拟列表缓存的高度数组,所以建议给图片一个占位,在加载渲染前就占据好高度

由于预告高度并不准确,在列表项渲染的时候就会导致内容高度一致在变化,所以会出现拖动滚动条进行滚动时,滑块和光标的位置慢慢对不上

VariableSizeList组件代码:
import { forwardRef, useState } from "react"
import { flushSync } from "react-dom"
import { VariableSizeListProps } from "../types/variable"
import { useRangeForVariable } from "../hooks/useRangeForVariable"


interface Props extends VariableSizeListProps {
  children:any
}


const VariableSizeList = forwardRef((props: Props, ref)=> {

  const { height, getItemHeight, itemCount, children: Component } = props 
  //@ts-ignore
  ref.current = {
    resetHeight: () => {
      setOffsets(genOffsets())
    }
  }

  /** 获取offsets */
  const genOffsets = () => {
    const a = [];
    a[0] = getItemHeight(0);
    for (let i = 1; i < itemCount; i++) {
      a[i] = getItemHeight(i) + a[i - 1]
    }
    return a;
  };

  /** 存储offsets */
  const [offsets, setOffsets] = useState(() => genOffsets())

  /** 内容高度 */
  const contentHeight = offsets[offsets.length - 1]

  /** 获取起始和终点索引 */
  const { startIndex , endIndex, setScrollTop } = useRangeForVariable({...props, offsets})

  /** 需要渲染的items */
  const items = []

  /** 列表长度大于0,对每一项进行处理 */
  if(itemCount > 0) {
    for (let i = startIndex; i <= endIndex; i++) {
      /** top为上一项offset的值 */
      const top = i === 0 ? 0 : offsets[i - 1]
      /** height为当前与上一项offset的差值 */
      const height = i === 0 ? offsets[0] : offsets[i] - offsets[i - 1]
      items.push(
        <Component
          key={i}
          index={i}
          style={{
            top,
            height,
            width: '100%',
            position: 'absolute',
          }}
        />
      );
    }
  }


  return (
    <div
      style={{
        height,
        overflow: 'auto',
        position: 'relative'
      }}
      onScroll={(e) => {
        flushSync(() => {
          // @ts-ignore
          setScrollTop(e.target.scrollTop);
        });
      }}
    >
      <div style={{ height: contentHeight }}>{items}</div>
    </div>
  )

})

export { VariableSizeList }
使用示例:
import React, { useEffect, useRef } from 'react'
import { faker } from '@faker-js/faker';
import { VariableSizeList } from '../../components/VariableSizeList';


interface ItemProps {
  data:any
  index:number
}

interface Props  extends ItemProps{
  setHeight:(index:number, height:number)=>void
}

const Item:React.FC<Props> = ({index, data, setHeight })=>{
  const itemRef = useRef<HTMLInputElement>(null);
  useEffect(() => {
    //@ts-ignore
    setHeight(index, itemRef.current.getBoundingClientRect().height);
  }, [setHeight, index]);

  return (
    <div
      ref={itemRef}
      style={{
        backgroundColor: index % 2 === 0 ? '#282c34' : '#087ea4',
        color:'#FFF'
      }}
    >
      {data}
    </div>
  );
}

const Variable = () => {
  
  const list = new Array(1000).fill(0).map(() => faker.lorem.paragraph())
  
  const listRef = useRef();

  /** 高度数组,在列表项渲染完成时更新 */
  const heightsRef = useRef(new Array(100));

  /** 预估高度 */
  const estimatedItemHeight = 40;

  /** 获取列表项的高度 */
  const getHeight = (index:number) => {
    return heightsRef.current[index] ?? estimatedItemHeight;
  };

  const setHeight = (index:number, height:number) => {
    if (heightsRef.current[index] !== height) {
      heightsRef.current[index] = height;
      // 让 VariableSizeList 组件更新高度
      // @ts-ignore
      listRef.current.resetHeight();
    }
  };
  return (
    <>
      <p style={{textAlign:'center'}}>  列表项高度动态 - 虚拟列表实现</p>
      <VariableSizeList
        ref={listRef}
        itemCount={list.length}
        getItemHeight={getHeight}
        height={window.innerHeight}
      >
        {({ index, style } :{index:number, style:React.CSSProperties}) => {
          return (
            <div style={style}>
              <Item index={index} data={list[index]} setHeight={setHeight} />
            </div>
          );
        }}
      </VariableSizeList>
    </>
  )
}

export { Variable }

结尾

虚拟列表的核心点就是根据滚动距离计算在可视区域的列表项范围

在固定高度下,我们相对比较简单的就能计算出列表项范围

而在动态高度下,需要在列表项渲染完成后才能动态去获取内容高度,所以采取了预设高度,并在列表项渲染完成后去更新高度

列表项使用绝对定位是因为回流有关,并在向后滚动过去那些自最初渲染以来已更改大小的项目时保留平滑滚动的外观,(react-window作者讲解

本文实现的虚拟列表组件参考了react-window库,ant design官网中就是使用react-window去构造虚拟列表的

本文所有代码在GitHub项目链接地址

参考文章

南宫墨言QAQ在此非常感谢以下几位优秀的博主提供他们关于浏览器缓存的优秀文章,感谢~❤️

前端西瓜哥长列表优化

虚拟列表相关文章

react-window GitHub

mini react-window(一) 实现固定高度虚拟滚动

mini react-window(二) 实现可知变化高度虚拟列表

react-window 是如何实现虚拟列表的

使用 react-window 虚拟化大型列表

结尾营业

看官都看到这了,如果觉得不错,可不可以不吝啬你的小手手帮忙点个关注或者点个赞

711115f2517eae5e.gif