React长列表优化?虚拟列表!

「这是我参与2022首次更文挑战的第8天,活动详情查看:2022首次更文挑战」。

为什么要使用虚拟列表

场景

我们先来看个,这个列表项共有6000条数据,而且该页面有四个这样的列表,页面加载和滚动列表,肉眼可见的卡顿。(tip:下图帧率低,看的不明显)

动画.gif

什么原因呢?

这是因为页面中dom元素过多,导致页面初始化和滚动列表的时候,浏览器渲染的速度慢。

虚拟列表是啥

本来需要渲染6000条数据,但是容器盒子可视范围内只能显示7条。虚拟列表就是在6000条数据中,截取可视区域中最多容纳的条数7条,即页面中只存在7个真实的dom列表元素。然后监听容器的滚动,实时去更新该7条数据。

版本一:列表项高度固定

效果图

列表滚动的时候,如德芙般纵享丝滑,但是观察dom树结构发现,只有10条数据。这就是根据可视区域高度和每项高度计算得知的。

动画2.gif

dom树结构

  • 一个container盒子,包含一个ListBox盒子,在ListBox盒子里面渲染每一个列表项。

  • listbox高度 = 每一项的高度 * 列表项的总条数。使得撑开container盒子,产生滚动条

  • 初始化的时候计算可视区需要显示的条数,以及开始索引,结束索引等。

    • 计算条数时,注意要使用Math.ceil(),而不是floor()
  • 监听Container盒子的滚动事件,滚动时计算开始索引和结束索引。

  • 这就实现了列表的无缝衔接

上代码

    import React, { memo, useState, useMemo, useCallback, useRef } from "react";
    import styled from "styled-components";

    const Container = styled.div`
      overflow-y: auto;
      overflow-x: hidden;
      height: ${({ height }) => height};
    `
    const ListBox = styled.div`
      background-color: pink;
      position: relative;
    `
    const VirList3 = memo(function ({ list = [], containerHeight = 800, ItemBox = <></>, itemHeight = 50, ...props }) {
      const ContainerRef = useRef();
      const [startIndex, setStartIndex] = useState(0);
      // 用于撑开Container的盒子,计算其高度
      const wraperHeight = useMemo(function () {
        return list.length * itemHeight;
      }, [list, itemHeight])
      // 可视区域最多显示的条数
      const limit = useMemo(function () {
        return Math.ceil(containerHeight / itemHeight);
      }, [startIndex]);
      // 当前可视区域显示的列表的结束索引
      const endIndex = useMemo(function () {
        return Math.min(startIndex + limit, list.length - 1);
      }, [startIndex, limit]);

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

      const renderList = useCallback(function () {
        const rows = [];
        for (let i = startIndex; i <= endIndex; i++) {
          // 渲染每个列表项
          rows.push(<ItemBox
            data={i}
            key={i}
            style={{
              width: "100%",
              height: itemHeight - 1 + "px",
              borderBottom: "1px solid #aaa",
              position: "absolute",
              top: i * itemHeight + "px",
              left: 0,
              right: 0,
            }} />)
        }
        return rows;
      }, [startIndex, endIndex, ItemBox])

      return (<Container
        height={containerHeight + "px"}
        ref={ContainerRef}
        onScroll={handleSrcoll}>
        <ListBox
          style={{ height: wraperHeight + "px" }}>
          {renderList()}
        </ListBox>
      </Container>)
    })
    export default VirList3;
复制代码

使用组件

QQ截图20220228233257.png

版本二:列表项高度不固定

问题

列表项高度不固定的话,那如何计算当前可视区域应该显示的条数呢,如何在滚动的时候,修改首位索引,达到无缝衔接呢?

dom结构

增加一层div包裹列表项:该项目中指的是Wraper盒子

整体思路

  • 由于列表项高度不固定,导致显示的条数limit等变量无法计算。所以我们预先定义一个默认列表项高度(该高度需要根据自己的项目确定合适的高度)。

  • 使用一个缓存数组存储各个列表项的位置,每个对象包含:索引,每一项的顶部距离ListBox容器的距离,每一项底部距离ListBox容器的距离,每一项的高度。使用useState将该缓存数组进行初始化。

  • 计算limit:因为每一项的高度不固定,所以需要根据容器滚动实时去计算。使用useMemo当作计算属性,依赖缓存数组进行实时更新。

  • ListBox的高度默认为列表项数乘以默认列表项高度,当缓存数组更新的时候会触发ListBox高度重新计算。具体代码在wraperHeight位置。

  • getTransform值:当滚动的时候需要调整Wraper盒子的高度,以实现页面滚动时,无缝衔接效果。

  • 在滚动时,重新计算起始索引(使用二分查找),结束索引,limit。

  • 当页面滚动时,缓存数组获取列表项中的自定义属性data-id获取到当前项索引,然后通过计算得到当前项真实的位置。踩坑提示:注意这里要用data-id,不要用当前循环的那个索引。

二分查找

22228235357.png

上代码

import React, { memo, useState, useMemo, useCallback, useRef, useEffect } from "react";
import styled from "styled-components";

const Container = styled.div`
  overflow-y: auto;
  height: ${({ height }) => height};
`
const ListBox = styled.div`
  background-color: pink;
  position: relative;
`
const Wraper = styled.div`

`
const VirList4 = memo(function ({
  list = [],
  containerHeight = 800,
  ItemBox = <></>,
  estimatedItemHeight = 90,
  ...props }) {
  const ContainerRef = useRef();
  const WraperRef = useRef();
  const [startIndex, setStartIndex] = useState(0);
  const [scrollTop, setScrollTop] = useState(0);

  const [positionCache, setPositionCache] = useState(function () {
    const positList = [];
    list.forEach((_, i) => {
      positList[i] = {
        index: i,
        height: estimatedItemHeight,
        top: i * estimatedItemHeight,
        bottom: (i + 1) * estimatedItemHeight,
      }
    })
    return positList;
  })

  const limit = useMemo(function () {
    let sum = 0
    let i = 0
    for (; i < positionCache.length; i++) {
      sum += positionCache[i].height;
      if (sum >= containerHeight) {
        break
      }
    }
    return i;
  }, [positionCache]);

  const endIndex = useMemo(function () {
    return Math.min(startIndex + limit, list.length - 1);
  }, [startIndex, limit]);

  const wraperHeight = useMemo(function () {
    let len = positionCache.length;
    if (len !== 0) {
      return positionCache[len - 1].bottom
    }
    return list.length * estimatedItemHeight;
  }, [list, positionCache, estimatedItemHeight])

  useEffect(function () {
    const nodeList = WraperRef.current.childNodes;
    const positList = [...positionCache]
    let needUpdate = false;
    nodeList.forEach((node, i) => {
      let newHeight = node.getBoundingClientRect().height;
      const nodeID = Number(node.id.split("-")[1]);
      const oldHeight = positionCache[nodeID]["height"];
      const dValue = oldHeight - newHeight;
      if (dValue) {
        needUpdate = true;
        positList[nodeID].height = node.getBoundingClientRect().height;
        positList[nodeID].bottom = nodeID > 0 ? (positList[nodeID - 1].bottom + positList[nodeID].height) : positList[nodeID].height;
        positList[nodeID].top = nodeID > 0 ? positList[nodeID - 1].bottom : 0;
      }
    })
    if (needUpdate) {
      setPositionCache(positList)
    }
  }, [scrollTop])

  const getTransform = useCallback(function () {
    return `translate3d(0,${startIndex >= 1 ? positionCache[startIndex - 1].bottom : 0}px,0)`
  }, [positionCache, startIndex]);

  const handleSrcoll = useCallback(function (e) {
    if (e.target !== ContainerRef.current) return;
    const scrollTop = e.target.scrollTop;
    setScrollTop(scrollTop)
    const currentStartIndex = getStartIndex(scrollTop);
    console.log(currentStartIndex);
    if (currentStartIndex !== startIndex) {
      setStartIndex(currentStartIndex);
      console.log(startIndex + "====--" + limit + "--====" + endIndex)
    }

  }, [ContainerRef, estimatedItemHeight, startIndex])

  const renderList = useCallback(function () {
    const rows = [];
    for (let i = startIndex; i <= endIndex; i++) {
      rows.push(<ItemBox
        data={list[i]}
        index={i}
        key={i}
        style={{
          width: "100%",
          borderBottom: "1px solid #aaa",
        }} />)
    }
    return rows;
  }, [startIndex, endIndex, ItemBox])

  return (<Container
    height={containerHeight + "px"}
    ref={ContainerRef}
    onScroll={handleSrcoll}>
    <ListBox
      style={{ height: wraperHeight + "px" }}>
      <Wraper
        style={{
          transform: getTransform()
        }}
        ref={WraperRef}
      >
        {renderList()}
      </Wraper>
    </ListBox>
  </Container>)
})

export default VirList4;

复制代码

使用

33335638.png

效果图

动画3.gif

Guess you like

Origin juejin.im/post/7069790402475196453