前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >关于虚拟列表,看这一篇就够了

关于虚拟列表,看这一篇就够了

作者头像
Jou
发布2023-04-23 16:20:28
2.9K0
发布2023-04-23 16:20:28
举报
文章被收录于专栏:前端技术归纳前端技术归纳
theme: channing-cyan

前言

长列表渲染一直以来都是前端比较头疼的一个问题,如果想要在网页中放大量的列表项,纯渲染的话,对于浏览器性能将会是个极大的挑战,会造成滚动卡顿,整体体验非常不好,主要有以下问题:

  • 页面等待时间极长,白屏时间久,用户体验差
  • CPU计算能力不够,滑动会卡顿
  • GPU渲染能力不够,页面会跳屏
  • RAM内存容量不够,浏览器崩溃

1. 传统做法

对于长列表渲染,传统的方法是使用懒加载的方式,下拉到底部获取新的内容加载进来,其实就相当于是在垂直方向上的分页叠加功能,**但随着加载数据越来越多,浏览器的回流和重绘的开销将会越来越大**

2.虚拟列表

其核心思想就是在处理用户滚动时,只改变列表在可视区域的渲染部分,然后使用padding或者translate来让渲染的列表偏移到可视区域中,给用户平滑滚动的感觉。

虚拟列表原理

虚拟列表的核心步骤可以总结成五步:

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

固定高度

列表项高度固定的话,就无需每次都计算当前应该渲染多少条数据,视口的数据量始终是固定的,只需要通过用户滚动的距离,来计算列表的开始结束索引即可。

preview3.gif
preview3.gif

核心步骤

1.根据容器的高度,计算出所需要渲染的列表项数,以及初始化列表高度 计算条数时,注意要使用Math.ceil(),而不是floor()

代码语言:javascript
复制
  // 可视区域最多显示的条数
  const limit = useMemo(
    function () {
      return Math.ceil(containerHeight / itemHeight);
    },
    [startIndex],
  );
  // 用于撑开Container的盒子,计算其高度
  const wraperHeight = useMemo(
    function () {
      return list.length * itemHeight;
    },
    [list, itemHeight],
  );

2.初始化开始和结束索引,更新渲染方法,设置缓冲区域

代码语言:javascript
复制
  // 初始化开始索引
  const [startIndex, setStartIndex] = useState(0);

  // 列表的结束索引
  const endIndex = useMemo(
    function () {
      return Math.min(startIndex + limit, list.length - 1);
    },
    [startIndex, limit],
  );
  
  // 根据索引渲染列表
  const renderList = useCallback(
    function () {
      const rows = [];
      // 多展示渲染1个,减少滑动过快的白屏
      for (let i = startIndex; i <= endIndex + 1; i++) {
        // 渲染每个列表项
        rows.push(
          <ItemBox
            data={i}
            key={i}
            style={{
              width: '100%',
              height: itemHeight - 11 + 'px',
              marginTop: '10px',
              borderBottom: '1px solid #aaa',
              position: 'absolute',
              top: i * itemHeight + 'px',
              left: 0,
              right: 0,
              backgroundColor: 'orange',
            }}
          />,
        );
      }
      return rows;
    },
    [startIndex, endIndex, ItemBox],
  );

3.监听滚动事件,根据滚动后的scrollTop计算出新的开始和结束索引

代码语言:javascript
复制
// 监听滚动
  const handleSrcoll = useCallback(
    function (e: any) {
      // 过滤页面其他滚动
      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],
  );

4.对滚动事件做节流优化

代码语言:javascript
复制
// 利用请求动画帧做了一个节流优化
  let then = useRef(0);
  const boxScroll = (e:any) => {
    const now = Date.now();
    /**
     * 这里的等待时间不宜设置过长,不然会出现滑动到空白占位区域的情况
     * 因为间隔时间过长的话,太久没有触发滚动更新事件,下滑就会到padding-bottom的空白区域
     * 电脑屏幕的刷新频率一般是60HZ,渲染的间隔时间为16.6ms,我们的时间间隔最好小于两次渲染间隔16.6*2=33.2ms,一般情况下30ms左右,
     */
    if (now - then.current > 30) {
      then.current = now;
      // 重复调用scrollHandle函数,让浏览器在下一次重绘之前执行函数,可以确保不会出现丢帧现象
      window.requestAnimationFrame(() => handleSrcoll(e));
    }

  };
存在的问题

这里滑动过快还是会存在一个白屏的现象,目前想到的办法有两个

  • 是加一个过渡的loading,
  • 隐藏滚动条,让用户只能滚轮滚动

不定高度

当列表项的高度不固定的时候,我们就需要一个策略来得到需要渲染的列表项,就是先给没有渲染出来的列表项设置一个预估高度,等到这些数据渲染成真实dom元素了之后,再获取到他们的真实高度去更新原来设置的预估高度,然后来获取列表项的开始索引。

preview2.gif
preview2.gif

核心步骤

1.初始化列表项数,开始结束索引,以及列表项缓存数组

首先我们需要给定一个初始的列表项高度,并初始化一个用于列表项高度以及位置信息的数组,这里存储位置信息的目的是可以直接通过比较scrollTop值和列表项的top来得出列表的开始索引。

代码语言:javascript
复制
// 初始化开始索引
const [startIndex, setStartIndex] = useState(0);

// 初始化缓存数组
// 先给没有渲染出来的列表项设置一个预估高度,等到这些数据渲染成真实dom元素了之后,再获取到他们的真实高度去更新原来设置的预估高度
// 高度尽量往小范围设置,避免出现空白
  const [positionCache, setPositionCache] = useState(function () {
    const positList: any = [];
    list.forEach((_: any, i: number) => {
      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],
  );

2.更新当前列表项的高度和位置

当用户滚动时,我们需要一直更新这个缓存数组中的列表项信息,目的是下次计算就能使用列表项的真实高度和位置,从而准确渲染出列表项。并且需要注意的是,不只是需要更新视图中的列表项,还需要更新之后的所有列表项

代码语言:javascript
复制
// 每次滚动,都去更新缓存数组中dom的高度和位置
  useEffect(
    function () {
      // 获取当前视口中的列表节点
      const nodeList = WraperRef.current.childNodes;
      const positList = [...positionCache];
      let needUpdate = false;
      nodeList.forEach((node: any) => {
        let newHeight = node.clientHeight;
        // 获取节点id,映射缓存数组中的位置
        const nodeID = Number(node.id.split('-')[1]);
        const oldHeight = positionCache[nodeID]['height'];
        // 高度发生变化,更新缓存数组
        const dValue = oldHeight - newHeight;
        if (dValue) {
          needUpdate = true;
          positList[nodeID].height = node.clientHeight;
          // 当前节点与底部的距离 = 上一个节点与底部的距离 + 当前节点的高度
          positList[nodeID].bottom = nodeID > 0 ? psitList[nodeID - 1].bottom + positList[nodeID].height : positList[nodeID].height;
          // 当前节点与顶部的距离 = 上一个节点与底部的距离
          positList[nodeID].top = nodeID > 0 ? positList[nodeID - 1].bottom : 0;
          // 更改一个节点就需要更改之后所有的值,不然会造成空白
          for (let j = nodeID + 1, len = positList.length; j < len; j++) {
            positList[j].top = positList[j - 1].bottom;
            positList[j].bottom += dValue;
          }
        }
      });
      // 相同节点不更新数组
      if (needUpdate) {
        setPositionCache(positList);
      }
    },
    [scrollTop],
  );

3.监听用户滚动,更新列表开始索引

这里我们需要在列表项里面去重新寻找开始索引,因为存了列表项的top值,所以这里我们比较其scrollTop的大小即可,并且数组中的列表项遵循从上往下排列,所以其top和bottom值必定也是线性变化的,所以这里我们可以使用二分查找来进行性能优化。

代码语言:javascript
复制
// 滚动事件监听
  const handleSrcoll = useCallback(
    function (e: any) {
      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],
  );

二分查找核心代码

代码语言:javascript
复制
// 如果滚轮从下往上滚动,我们就可以通过二分查找快速找到最上方的节点
  const getStartIndex = function (scrollTop: any) {
    let idx =
      binarySearch(positionCache, scrollTop, (currentValue: any, targetValue: any) => {
        // 传入的比较方法,通过比较顶部距离与最上方节点的bottom值来决定列表的第一个元素
        const currentCompareValue = currentValue.bottom;
        if (currentCompareValue === targetValue) {
          return CompareResult.eq;
        }
        if (currentCompareValue < targetValue) {
          return CompareResult.lt;
        }
        return CompareResult.gt;
      }) || 0;
    const targetItem = positionCache[idx];
    if (targetItem.bottom < scrollTop) {
      idx += 1;
    }
    return idx;
  };  

  // 二分查找核心算法
  const binarySearch = function (list: any, value: any, compareFunc: any) {
    let start = 0;
    let end = list.length - 1;
    let tempIndex = null;
    while (start <= end) {
      tempIndex = Math.floor((start + end) / 2);
      const midValue = list[tempIndex];
      const compareRes = compareFunc(midValue, value);
      // 一般情况是找不到完全相等的值,只能找到最接近的值
      if (compareRes === CompareResult.eq) {
        return tempIndex;
      }
      if (compareRes === CompareResult.lt) {
        start = tempIndex + 1;
      } else if (compareRes === CompareResult.gt) {
        end = tempIndex - 1;
      }
    }
    return tempIndex;
  };
  1. 设置列表项偏移,使其展示在容器视口中

这里有两种方式,可以通过translate,也可以通过paddingTop paddingBottom来实现

代码语言:javascript
复制
  // 使用translate来校正滚动条位置
  // 也可以使用paddingTop来实现,目的是将子节点准确放入视口中
  const getTransform = useCallback(
    function () {
      // return `translate3d(0,${startIndex >= 1 ? positionCache[startIndex - 1].bottom : 0}px,0)`;
      return {
        // 改变空白填充区域的样式,起始元素的top值就代表起始元素距顶部的距离,可以用来充当paddingTop值
        paddingTop: `${positionCache[startIndex].top}px`,
        // 缓存中最后一个元素的bottom值与endIndex对应元素的bottom值的差值可以用来充当paddingBottom的值
        paddingBottom: `${positionCache[positionCache.length - 1].bottom - positionCache[endIndex].bottom}px`,
      };
    },
    [positionCache, startIndex],
  );

最后

感谢你能看到这里,文中实现了两种情况的虚拟列表,当然,所有的列表项数据还是都需要接口来进行请求的,所以在滚动的时候,我们还需要加上监听滚动条位置并且从接口拉取数据的逻辑,所以需要优化的地方还很多。

如果可以的话,不妨给笔者留个赞再走呢

demo地址

https://github.com/AdolescentJou/react-virtual-scroll

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2023-03-27,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
    • 1. 传统做法
      • 2.虚拟列表
      • 虚拟列表原理
      • 固定高度
        • 核心步骤
          • 存在的问题
      • 不定高度
        • 核心步骤
          • demo地址
      • 最后
      相关产品与服务
      容器服务
      腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
      领券
      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档