手把手带你掌握分片渲染和虚拟列表

390 阅读20分钟

对于一次性插入大量数据的情况,一般有两种做法:

  1. 时间分片
  2. 虚拟列表

分片渲染

一次性渲染一万条数据

如果咱们的服务器返回的数据很多很大,在不卡顿的情况下,把他们展示在页面上?这就需要分片渲染和虚拟列表。

使用它们的主要目的是为了解决大量数据的加载问题,比如页面上一次性渲染一万条数据:

基础代码:

image.png

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>一万数据渲染</title>
</head>

<body>
    <ul id="list-container"></ul>
  
    <script>
        console.time()
        // 记录任务开始时间
        let now = Date.now();
        // 插入十万条数据
        const total = 10000;
        // 获取容器
        let ul = document.getElementById('list-container');
        // 将数据插入容器中
        for (let i = 0; i < total; i++) {
            let li = document.createElement('li');
            li.innerText = `我是列表${i}`
            ul.appendChild(li);
        }
        console.timeEnd()

        console.log('JS运行时间:',Date.now() - now);
        console.time()
        let now1 = Date.now();
        setTimeout(()=>{
            console.timeEnd()
            console.log('渲染时间:',Date.now() - now1);
        },0)
    </script>
</body>
</html>

测试结果:

image.png

为什么下面这段代码测试出来的时间是渲染时间?

  setTimeout(()=>{
            console.timeEnd()
            console.log('渲染时间:',Date.now() - now1);
        },0)

解释说明:

因为在事件循环机制里面,js把任务划分成了宏任务和延迟任务,只有本轮所有的宏任务执行完以后,才能去执行延迟任务。

也就是说页面加载以后,html解析器会把html标签解析成dom,在解析的过程中,如果碰到css文件或者img,他会要其他线程去处理,并不会阻塞html的解析,但是当碰到script标签,html的解析就会暂停,等js全部执行完以后再继续解析html。但是在执行js之前,必须先保证css文件已经全部加载好了。因为js里面要处理css样式。

所以说:js会阻塞html的解析,css会阻塞js的执行,css不会阻塞html的解析,但是会阻塞页面的渲染。因为html解析完的第一个步骤就是计算css样式。如果css文件没有加载好,它就会等着。

这也是为什么有些页面出现元素以后,好一会才能渲染出样式的缘故。所以等js执行完,html解析完,css文件处理好以后,才开始执行渲染任务。渲染任务是一个同步的宏任务。

所以说页面js执行完以后,页面会立即渲染页面,页面渲染结束以后,本轮的宏任务算是执行结束了,进入延迟队列执行延迟任务,所以setTimeout能够拿到整个渲染时间。

文中用console.time()console.log(Date.now() - now)来获取执行时间发现,console.time()的计时更加准确,但是一般我们用Date.now() - now也行。

从时间上看js执行只运行了 6 ms,渲染却用了135ms,就说明js运行完以后,页面要去解析html,把html变成DOM,然后计算css,创建CSSOM,布局,分层,绘制,最后交给合成线程渲染页面,显示到浏览器上花费了135ms

说明:对浏览器而言,js的运行并不是最耗时的,最耗时的是浏览器的渲染流水线,这也从侧面说明,为什么优化页面性能的第一步就是想尽办法减少页面的重排和重绘。

性能指标:

image.png

就这点东西,全都报红色了,说明了什么?此时页面只是简单的一排排数据,而真实的页面比这个页面更加复杂多变,所以这样干肯定不行,所以就出现了分片渲染和虚拟列表。

使用 setTimeout 分片渲染

原理说明:

分片渲染:执行思想就是建立一个队列,通过定时器来进行渲染

不管是setTimeout/setIntervalXHR/fetch代码,在这些代码执行时, 本身是同步任务,而其中的回调函数才是异步任务。

当代码执行到setTimeout/setInterval时,实际上是JS引擎线程通知定时触发器线程,间隔一个时间后,会触发一个回调事件, 而定时触发器线程在接收到这个消息后,会在等待的时间后,将回调事件放入到由事件触发线程所管理的事件队列中再去执行。

所以说分片渲染就是延迟多次渲染,利用定时器和递归实现,浏览器先把这20条数据渲染完以后,再去渲染下一个20条。

开始写代码:有一万条数据,每次只渲染20条,每次渲染完以后,就去再拿20条来渲染,利用 setTimeOut 把这20条要渲染的数据加入到下一个事件循环里面去。 代码用 let pageCount = Math.min(total , once);解决了最后一次,万一不足20条的渲染问题。

基础代码:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>一万数据渲染</title>
</head>

<body>
    <ul id="list-container"></ul>
  
    <script>
        let ul = document.getElementById('list-container');
        let total = 10000;
        let once = 20;
        let page = 0;
        let index = 0;

        //循环加载数据
        function loop(total,index){
            if(total <= 0){
                return false;
            }
            page++ ;
            //每页多少条
            let pageCount = Math.min(total , once);
            setTimeout(()=>{
                for(let i = 0; i < pageCount; i++){
                    let li = document.createElement('li');
                    li.innerText = `我是第${page}页`
                    ul.appendChild(li);
                }
                //递归
                loop(total - pageCount, index + pageCount)
            },0)
        }

        loop(total,index);
    </script>
</body>
</html>


测试结果:

image.png

image.png

测试页面加载很快,但是当你滚动页面的时候,你会发现有卡顿,当数据更多的时候,连开发者工具都是卡死的,但是好的是,页面首页加载速度却很快。

闪屏原因

所以我们现在只需要解决这个闪屏的问题就离目标更加近了。为什么会闪屏呢?

显示器刷新原因

显示器的刷新频率是60HZ,相当于每16.6ms刷新一次页面,不同的刷新频率给人的视觉感受是不一样的:

  • 帧率在 50 ~ 60 FPS 时页面动画相当流畅,让人倍感舒适;
  • 帧率在 30 ~ 50 FPS 之间的动画,因各人敏感程度不同,舒适度因人而异;
  • 帧率在 30 FPS 以下的动画,让人感觉到明显的卡顿和不适感;
  • 帧率波动很大的动画,亦会使人感觉到卡顿。
定时器延迟时间不准确

就是因为setTimeout里面的回调函数才会加入到延迟任务里面去,你的延迟时间是0,但是执行它得前提是所有宏任务都执行完了,才能轮到它。所以它真正的等待时间绝对大于0。所以你理想的无缝衔接化的渲染过程变成了断断续续处理。

还有一个原因是定时刷新时间和显示器刷屏时间不同步。刷新频率受屏幕分辨率和屏幕尺寸的影响,因此不同设备的刷新频率可能会不同,而setTimeout只能设置一个固定时间间隔,这个时间不一定和屏幕的刷新时间相同。

使用 requestAnimationFrame 分片渲染

原理说明:

setTimeout相比,requestAnimationFrame最大的优势是由系统来决定回调函数的执行时机。

requestAnimationFrame的执行代码的时机是由显示器刷新频率决定的,如果显示器16.6ms刷新一次,那requestAnimationFrame就每16.6ms执行一次,如果显示器刷新延迟,那requestAnimationFrame就执行延迟。它正好解决了setTimeout和显示器刷新不同步的问题。

所以,requestAnimationFrame 能保证回调函数在屏幕每一次的刷新间隔中只被执行一次,这样就不会引起丢帧现象。

基础代码:

直接将上述代码中的定时器换成 window.requestAnimationFrame 就好了。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>一万数据渲染</title>
</head>

<body>
    <ul id="list-container"></ul>
  
    <script>
        let ul = document.getElementById('list-container');
        let total = 10000;
        let once = 20;
        let page = 0;
        let index = 0;

        //循环加载数据
        function loop(total,index){
            if(total <= 0){
                return false;
            }
            page++ ;
            //每页多少条
            let pageCount = Math.min(total , once);
            window.requestAnimationFrame(()=>{
                for(let i = 0; i < pageCount; i++){
                    let li = document.createElement('li');
                    li.innerText = `我是第${page}页`
                    ul.appendChild(li);
                }
                //递归
                loop(total - pageCount, index + pageCount)
            })
        }
        
        loop(total,index);
    </script>
</body>
</html>

测试结果

image.png

页面明显流畅很多,但是呢?感觉还是不带劲,有没有更好的办法呢?可以尝试 DocumentFragment

使用 DocumentFragment 优化加载元素

image.png

基础代码

image.png

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>一万数据渲染</title>
</head>

<body>
    <ul id="list-container"></ul>
  
    <script>
        let ul = document.getElementById('list-container');
        let total = 5000;
        let once = 20;
        let page = 0;
        let index = 0;

        //循环加载数据
        function loop(total,index){
            if(total <= 0){
                return false;
            }
            page++ ;
            //每页多少条
            let pageCount = Math.min(total , once);
            window.requestAnimationFrame(()=>{
                let fragment = document.createDocumentFragment()
                for(let i = 0; i < pageCount; i++){
                    let li = document.createElement('li');
                    li.innerText = `我是第${page}页`
                    fragment.appendChild(li);
                }
                ul.appendChild(fragment)
                //递归
                loop(total - pageCount, index + pageCount)
            })
        }
        
        loop(total,index);
    </script>
</body>
</html>

测试结果:

image.png

从性能来看没有太大的变化。

虚拟列表

在日常业务开发中,我们常常会遇到一些不能使用分页方式来加载列表数据的业务情况,我们称这种列表叫做长列表。分片渲染更适合dom比较简单的业务场景,如果是复杂的列表,我们则用虚拟列表来处理。

原理说明

虚拟列表就是按需显示,当一个页面很长的时候,屏幕就那么大,它只能显示页面的一部分。按需显示就是我就渲染屏幕显示的这部分,其他的地方等用户滚到的时候,再去选渲染。这样整个页面的性能就非常高了,避免了所有的无用功。

换言之,假设有1万条数据要渲染到页面上,但是我们屏幕高度为500px,页面总高度是9000px,这样的话,意味着用户需要拉动滚动条,才能全部看到。而我们在屏幕上最多只能看到10条完整数据,那么在首次渲染的时候,我们只需加载10条即可,其他等滚动的时候再说。

比如掘金首页

image.png

页面一进来它就只加载了 20 条数据,让你把滚动条拉到底的时候它又去重新请求数据。这也算作是一种虚拟列表的实现。

image.png

虚拟列表实际是在首屏加载的时候,只加载屏幕可视区域范围内需要的那几个列表项,当滚动发生的时候,页面通过js计算拿到现在应该显示在可视区域内的列表项,再把不应该出现在可视区域内列表项全部删除掉,就OK了。

image.png

上面这个图很好的说明了页面一进来和拉动滚动条时候的页面加载情况。页面一进来,起始位置从头开始,只有上面有缓冲区,等用户滚动滚动条以后,上下都会出现一个缓冲区。

image.png

这幅图就很形象的描述了虚拟列表的全貌,页面只展示四个item,上下各有2个缓冲item,只要不再这8个item内,其他item全部删除掉。原来页面有8item,从item1item8,用户滚动以后,页面依旧有8item,是item2item9

虚拟列表一共有3种类型:定高虚拟列表,不定高虚拟列表,动态高度的虚拟列表。

定高虚拟列表

基础代码:

推算公式如下:

  • 列表总高度 listHeight = itemCount * itemHeight -- 列表总高度 = 列表总数 * 列表高度`

  • 可显示的列表项数visibleCount = Math.ceil(height / itemHeight) --可显示的列表项数 = 屏幕高度 / 列表高度,只要超出就加1,所以用math.ceil()

  • 数据的起始索引startIndex = Math.floor(scrollTop / itemHeight)--起始索引 = 卷上去的高度 / 列表高度, 超出的小数不算,所以用 Math.floor()

  • 数据的结束索引endIndex = startIndex + visibleCount -- 结束索引 = 开始索引 + 可显示的列表数

  • 列表显示数据为visibleData = listData.slice(startIndex,endIndex) -- 列表显示数据=总数据开始索引到结束索引的截取部分

当滚动后,由于渲染区域相对于可视区域会发生一定的偏移,所以

  • 偏移量startOffset = scrollTop - (scrollTop % itemSize);

当然你也可以把整个偏移量写死,上下为2,这样做的目的是为了确保万一。

所以

上缓冲区起始索引: const finialStartIndex = Math.max(0, startIndex - 2);
下缓冲区结束索引: const endIndex = Math.min(itemCount, startIndex + numVisible + 2);

所以说精髓就是:

image.png

在react项目里面直接创建一个组件,复制以下代码:

import { useState } from 'react';

const FixedSizeList = (props: any) => {
  const { 
        height = 300, //外部盒子的高度height,外部盒子的宽度:width
        width=200, //外部盒子的宽度:width
        itemHeight=28, //列表中每个元素的高度:itemHeight
        itemCount=1000 //列表中元素的数量:itemCount
  } = props;
  // 记录滚动掉的高度
  const [scrollOffset, setScrollOffset] = useState(0);
  // 外部容器高度
  const containerStyle = {
    position: 'relative',
    width,
    height,
    overflow: 'auto',
  };
  // 1000个元素撑起盒子的实际高度
  const contentStyle = {
    height: itemHeight * itemCount,
    width: '100%',
  };
    
  const getCurrentChildren = () => {
    const startIndex = Math.floor(scrollOffset / itemHeight);     // 可视区起始索引
    const finialStartIndex = Math.max(0, startIndex - 2);     // 上缓冲区起始索引
    const numVisible = Math.ceil(height / itemHeight);    // 可视区能展示的元素的最大个数
    const endIndex = Math.min(itemCount, startIndex + numVisible + 2); // 下缓冲区结束索引

    const items = [];
    // 根据上面计算的索引值,不断添加元素给container
    for (let i = finialStartIndex; i < endIndex; i++) {
      const itemStyle = {
        position: 'absolute',
        height: itemHeight,
        width: '100%',
        // 计算每个元素在container中的top值
        top: itemHeight * i,
        border: '1px solid red'
      };
      items.push(
        <div key={i} index={i} style={itemStyle} >{i}</div>
      );
    }
    return items;
  }

  // 当触发滚动就重新计算
  const scrollHandle = (event: any) => {
    const { scrollTop } = event.currentTarget;
    setScrollOffset(scrollTop);
  }

  return (
    <div id=“container” style={containerStyle} onScroll={scrollHandle}>
       <div id="itemList" style={contentStyle}>
          {getCurrentChildren()}
       </div>
    </div>
  );
};

export default FixedSizeList;

测试结果:

image.png

逻辑总结

你只需要组件传入:外部盒子container的宽高,和内部元素item的高度和个数,就能渲染出一个虚拟列表出来。虚拟列表的精髓是:在元素滑动滚动条以后,页面的scrollTop会发生变化。scrollTop就能得到起始索引,然后根据可视区的高度计算出可视区应该显示几个元素,这样就能推算出结束索引。然后从开始索引,循环执行到结束索引,把元素加入div里面,定高的虚拟列表就成功了。

不定高虚拟列表

定高虚拟列表和不定高虚拟列表之间区别是: 内部元素item的高度各不相同,也就是说,每一行的高度确定,但是具体的数值不同。 image.png

两个难点:

  1. 我们没有办法利用item 的个数 * item高度 获得 itemList 的总高度。

  2. 我们没有办法利用 Math.floor(scrollOffset / itemHeight)获取可视区起始索引。

因为上面2个难点导致我们没有办法快速的计算出 startIndex 和 endIndex 。仔细想一下定高虚拟列表的实现过程,无非就是根据现有的条件计算出 startIndex 和 endIndex,然后用循环往数组里面添加元素,然后渲染到页面上么。所以说不管定高还是不定高他们的实现思路都是一样的:

  • 第一:计算出startIndex, endIndex ,然后加上缓冲区的大小,将此范围内的节点渲染到容器中。

  • 第二:我们无需精确计算全部数据容器到底有多高,只需简单给出一个粗略的高度,然后出现滚动条即可。

实现逻辑

首先设计一个对象来装已经渲染过的 item 的高度及其他的偏移量。

image.png

其次计算盒子的总高度,用来显示滚动条, const totalHeight = measuredHeight + unMeasuredItemsCount * defaultItemHeight;已经渲染的item总高度 + 没有渲染的item的个数 * 默认高度50px,就是估算高度。

默认高度就是预估高度,是一个平均值,如果你的预估高度太小,就会出现渲染数据太多的问题,如果预估高度太大,就会出现渲染数据太少,页面没有被填充完,下面会有空白。所以这个预估值还是很重要的,最好获取一个平均值,不能比最小行高低,也不能比最大行高高。

image.png

lastItemIndex是已经被记录的滚动到的最大索引

  • 如果小于lastItemIndex的都是被缓存过的项,直接获取
  • 如果大于lastItemIndex则是未被缓存过的项,从lastItemIndex开始,遍历到index,将遍历的所有项的height相加,即为当前项的index
  • getItemMetaData 是一个工具函数,通过他可以获取当前这个index对应的高度及其它的偏差。

image.png

获取开始index

image.png

获取结束index

image.png

渲染startIndex到endIndex之间的数据

image.png

基础的工具函数都已经准备好了 现在上组件

const DifheightVirtualList = (props: Record<string, any>) => {
  const { height, width, itemCount, itemEstimatedSize, children: Child } = props;
  const [scrollOffset, setScrollOffset] = useState(0);
  const containerStyle = {
    position: 'relative',
    width,
    height,
    overflow: 'auto',
    willChange: 'transform'
  };
  const contentStyle = {
    height: estimatedHeight(itemEstimatedSize, itemCount),
    width: '100%',
  };

  const getCurrentChildren = () => {
    const [startIndex, endIndex] = getRangeToRender(props, scrollOffset)
    const items = [];
    for (let i = startIndex; i <= endIndex; i++) {
      const item = getItemMetaData(props, i);
      const itemStyle = {
        position: 'absolute',
        height: item.size,
        width: '100%',
        top: item.offset,
      };
      items.push(
        <Child key={i} index={i} style={itemStyle} />
      );
    }
    return items;
  }

  const scrollHandle = (event:any) => {
    const { scrollTop } = event.currentTarget;
    setScrollOffset(scrollTop);
  }

  return (
    <div style={containerStyle} onScroll={scrollHandle}>
      <div style={contentStyle}>
        {getCurrentChildren()}
      </div>
    </div>
  );
};

export default DifheightVirtualList;

调用如下:

image.png

完整代码如下:

import { useState } from 'react';

// 元数据

interface IMeasuredData {
  renderedItem: Record<string, any>;
  lastItemIndex: number;
}

const measuredData: IMeasuredData = {
  renderedItem: {},
  lastItemIndex: -1,
};

const estimatedHeight = (defaultItemHeight = 50, itemCount: number) => {
  let measuredHeight = 0;
  const { renderedItem, lastItemIndex } = measuredData;
  // 计算已经获取过真实高度的项的高度之和
  if (lastItemIndex >= 0) {
    const lastItem = renderedItem[lastItemIndex];
    measuredHeight = lastItem.offset + lastItem.size;
  }
  // 未计算过真实高度的项数
  const unMeasuredItemsCount = itemCount - measuredData.lastItemIndex - 1;
  // 预测总高度
  const totalHeight = measuredHeight + unMeasuredItemsCount * defaultItemHeight;
  return totalHeight;
}

const getItemMetaData = (props: Record<string, any>, index: number) => {
  const { itemSize } = props;
  const { renderedItem, lastItemIndex } = measuredData;
  // 如果当前索引比已记录的索引要大,说明要计算当前索引的项的size和offset
  if (index > lastItemIndex) {
    let offset = 0;
    // 计算当前能计算出来的最大offset值
    if (lastItemIndex >= 0) {
      const lastItem = renderedItem[lastItemIndex];
      offset += lastItem.offset + lastItem.size;
    }
    // 计算直到index为止,所有未计算过的项
    for (let i = lastItemIndex + 1; i <= index; i++) {
      const currentItemSize = itemSize(i);
      renderedItem[i] = { size: currentItemSize, offset };
      offset += currentItemSize;
    }
    // 更新已计算的项的索引值
    measuredData.lastItemIndex = index;
  }
  return renderedItem[index];// 返回当前这个item的高度和偏差
};

const getStartIndex = (props: Record<string, any>, scrollOffset: number) => {
  const { itemCount } = props;
  let index = 0;
  while (true) {
    const currentOffset = getItemMetaData(props, index).offset;
    if (currentOffset >= scrollOffset) return index;
    if (index >= itemCount) return itemCount;
    index++
  }
}

const getEndIndex = (props: Record<string, any>, startIndex: number) => {
  const { height, itemCount } = props;
  // 获取可视区内开始的项
  const startItem = getItemMetaData(props, startIndex);
  // 可视区内最大的offset值
  const maxOffset = startItem.offset + height;
  // 开始项的下一项的offset,之后不断累加此offset,直到等于或超过最大offset,就是找到结束索引了
  let offset = startItem.offset + startItem.size;
  // 结束索引
  let endIndex = startIndex;
  // 累加offset
  while (offset <= maxOffset && endIndex < (itemCount - 1)) {
    endIndex++;
    const currentItem = getItemMetaData(props, endIndex);
    offset += currentItem.size;
  }
  return endIndex;
};

const getRangeToRender = (props: Record<string, any>, scrollOffset: number) => {
  const { itemCount } = props;
  const startIndex = getStartIndex(props, scrollOffset);
  const endIndex = getEndIndex(props, startIndex);
  return [
    Math.max(0, startIndex - 2),
    Math.min(itemCount - 1, endIndex + 2),
    startIndex,
    endIndex,
  ];
};

const DifheightVirtualList = (props: Record<string, any>) => {
  const { height, width, itemCount, itemEstimatedSize, children: Child } = props;
  const [scrollOffset, setScrollOffset] = useState(0);
  const containerStyle = {
    position: 'relative',
    width,
    height,
    overflow: 'auto',
    willChange: 'transform'
  };
  const contentStyle = {
    height: estimatedHeight(itemEstimatedSize, itemCount),
    width: '100%',
  };

  const getCurrentChildren = () => {
    const [startIndex, endIndex] = getRangeToRender(props, scrollOffset)
    const items = [];
    for (let i = startIndex; i <= endIndex; i++) {
      const item = getItemMetaData(props, i);
      const itemStyle = {
        position: 'absolute',
        height: item.size,
        width: '100%',
        top: item.offset,
      };
      items.push(
        <Child key={i} index={i} style={itemStyle} />
      );
    }
    return items;
  }

  const scrollHandle = (event:any) => {
    const { scrollTop } = event.currentTarget;
    setScrollOffset(scrollTop);
  }

  return (
    <div style={containerStyle} onScroll={scrollHandle}>
      <div style={contentStyle}>
        {getCurrentChildren()}
      </div>
    </div>
  );
};

export default DifheightVirtualList;


调用:

import React from 'react';
//import type { FormProps } from 'antd';
// import { Button, Checkbox, Input } from 'antd';
// import Form from './Form'
// import LongRander from './LongRander'
import FixedSizeList from './VirtualList';
import DifheightVirtualList from './DifHeightVirtualList'
import './App.css'

const rowSizes = new Array(1000).fill(true).map(() => 28 + Math.round(Math.random() * 55))
const getItemSize = (index: number) => rowSizes[index];

const Row = ({ index, style }: Record<string, any>) => {
  return (
    <div className={index % 2 ? 'list-item-odd' : 'list-item-even'} style={style} >
      Row {index}
    </div>
  )
}

const App: React.FC = () => (
<div>
  {/* <LongRander></LongRander> */}
  定高
  <FixedSizeList></FixedSizeList>
  不定高
  <DifheightVirtualList
      height={300}
      width={200}
      itemSize={getItemSize}
      itemCount={1000}
    >
      {Row}
    </DifheightVirtualList>
</div>
);

export default App;

动态高度虚拟列表:

不管定高还是不定高,最起码调用方会告诉组件具体item的高度,即使不定高的高度都不一样,但是他们的高度是明确的,可以通过getItemSize 方法拿到。但是动态高度虚拟列表就比较棘手了,它里面item的高度是不确定的。

image.png

原理

前提条件 1.传值的时候,多加一个参数,是动态高度标志, 2.传进来的元素,高度不一定,但是他们都是有明确高度的,只是带进了样式里面。

image.png

这就是为什么 react-window 里面只做了定高和不定高的虚拟列表,没有做动态高度的虚拟列表的原因。不管怎么样,即使是动态的,高度也是在渲染之前就已经知道了的,定好的,所以我们完全可以把他们的高度取出来,存储到数组里面,给组件丢给一个取值的方法,把他的高度和scrllTop都给计算出来。之后,动态高度走的路线,完全就和不定高一样了。

不管是定高,还是不定高,还是动态,都有一个永恒的定律: 上一个item 的 height + 他的 scrollTop = 下一个 item 的 scrollTop

image.png

我们根据调试来说一下代码,页面一进来,参数如下:最值得注意的是第一次渲染,页面的高度是: 50*1000,1000是item的个数,50是item默认的高度。

image.png

进入计算总高度的函数看看

image.png

进入计算开始高度

image.png

进入计算结束高度

image.png

第一次区间渲染值

image.png

渲染结果

image.png

完整代码:

import React, { useState } from 'react';

// 元数据
interface IMeasuredData {
  renderedItem: Record<string, any>;
  lastItemIndex: number;
}

const measuredData: IMeasuredData = {
  renderedItem: {},//renderedItem 里面装的数据: {0:{size: 37, offset: 0},1: {size: 45, offset: 37}}
  lastItemIndex: -1,//起初最后一个就是-1
};

const estimatedHeight = (defaultEstimatedItemSize = 50, itemCount: number) => {
  let measuredHeight = 0;
  const { renderedItem, lastItemIndex } = measuredData;
  // 计算已经获取过真实高度的项的高度之和
  if (lastItemIndex >= 0) {
    const lastMeasuredItem = renderedItem[lastItemIndex];
    measuredHeight = lastMeasuredItem.offset + lastMeasuredItem.size;
  }
  // 未计算过真实高度的项数
  const unMeasuredItemsCount = itemCount - lastItemIndex - 1;
  // 预测总高度
  const totalEstimatedHeight = measuredHeight + unMeasuredItemsCount * defaultEstimatedItemSize;
  return totalEstimatedHeight;
}

const getItemMetaData = (props: Record<string, any>, index: number) => {
  const { itemSize, itemEstimatedSize = 50 } = props;
  const { renderedItem, lastItemIndex } = measuredData;
  // 如果当前索引比已记录的索引要大,说明要计算当前索引的项的size和offset
  if (index > lastItemIndex) {
    let offset = 0;
    // 计算当前能计算出来的最大offset值
    if (lastItemIndex >= 0) {
      const lastMeasuredItem = renderedItem[lastItemIndex];
      offset += lastMeasuredItem.offset + lastMeasuredItem.size;
    }
    // 计算直到index为止,所有未计算过的项
    for (let i = lastItemIndex + 1; i <= index; i++) {
      const currentItemSize = itemSize ? itemSize(i) : itemEstimatedSize;
      renderedItem[i] = { size: currentItemSize, offset };
      offset += currentItemSize;
    }
    // 更新已计算的项的索引值
    measuredData.lastItemIndex = index;
  }
  return renderedItem[index];
};

const getStartIndex = (props: Record<string, any>, scrollOffset: number) => {
  const { itemCount } = props;
  let index = 0;
  while (true) {
    const currentOffset = getItemMetaData(props, index).offset;
    if (currentOffset >= scrollOffset) return index;
    if (index >= itemCount) return itemCount;
    index++
  }
}

const getEndIndex = (props: Record<string, any>, startIndex: number) => {
  const { height, itemCount } = props;
  // 获取可视区内开始的项
  const startItem = getItemMetaData(props, startIndex);
  // 可视区内最大的offset值
  const maxOffset = startItem.offset + height;
  // 开始项的下一项的offset,之后不断累加此offset,知道等于或超过最大offset,就是找到结束索引了
  let offset = startItem.offset + startItem.size;
  // 结束索引
  let endIndex = startIndex;
  // 累加offset
  while (offset <= maxOffset && endIndex < (itemCount - 1)) {
    endIndex++;
    const currentItem = getItemMetaData(props, endIndex);
    offset += currentItem.size;
  }
  return endIndex;
};

const getRangeToRender = (props: Record<string, any>, scrollOffset: number) => {
  const { itemCount } = props;
  const startIndex = getStartIndex(props, scrollOffset);
  const endIndex = getEndIndex(props, startIndex);
  return [
    Math.max(0, startIndex - 2),
    Math.min(itemCount - 1, endIndex + 2),
    startIndex,
    endIndex,
  ];
};

class ListItem extends React.Component {
  domRef: any;
  resizeObserver: any;
  constructor(props: Record<string, any>) {
    super(props);
    this.domRef = React.createRef();
    this.resizeObserver = null;
  }
  componentDidMount() {
    if (this.domRef.current) {
      const domNode = this.domRef.current.firstChild;
      const { index, onSizeChange } = this.props as any;
      this.resizeObserver = new ResizeObserver(() => {
        onSizeChange(index, domNode);
      });
      this.resizeObserver.observe(domNode);
    }
  }
  componentWillUnmount() {
    if (this.resizeObserver && this.domRef.current.firstChild) {
      this.resizeObserver.unobserve(this.domRef.current.firstChild);
    }
  }
  render() {
    const { index, style, ComponentType } = this.props  as any;
    return (
      <div style={style} ref={this.domRef}>
        <ComponentType index={index} />
      </div>
    )
  }
}

const VariableSizeList = (props: Record<string, any>) => {
  const { height, width, itemCount, itemEstimatedSize = 50, children: Child } = props;
  const [scrollOffset, setScrollOffset] = useState(0);
  const [, setState] = useState({});

  const containerStyle = {
    position: 'relative',
    width,
    height,
    overflow: 'auto',
    willChange: 'transform'
  };

  const contentStyle = {
    height: estimatedHeight(itemEstimatedSize, itemCount),
    width: '100%',
  };

  const sizeChangeHandle = (index: number, domNode: any) => {
    const height = domNode.offsetHeight;
    const { renderedItem, lastItemIndex } = measuredData;
    const itemMetaData = renderedItem[index];
    itemMetaData.size = height;
    let offset = 0;
    for (let i = 0; i <= lastItemIndex; i++) {
      const itemMetaData = renderedItem[i];
      itemMetaData.offset = offset;
      offset += itemMetaData.size;
    }
    setState({});
  }
    
  const getCurrentChildren = () => {
    const [startIndex, endIndex] = getRangeToRender(props, scrollOffset)
    const items = [];
    for (let i = startIndex; i <= endIndex; i++) {
      const item = getItemMetaData(props, i);
      const itemStyle = {
        position: 'absolute',
        height: item.size,
        width: '100%',
        top: item.offset,
      };
      items.push(
        <ListItem key={i} index={i} style={itemStyle} ComponentType={Child} onSizeChange={sizeChangeHandle} />
      );
    }
    return items;
  }

  const scrollHandle = (event: any) => {
    const { scrollTop } = event.currentTarget;
    setScrollOffset(scrollTop);
  }

  return (
    <div style={containerStyle} onScroll={scrollHandle}>
      <div style={contentStyle}>
        {getCurrentChildren()}
      </div>
    </div>
  );
};

export default VariableSizeList;


调用代码:

import VariableSizeList from './DynamicVirtualList'
import './App.css'
import { JSX } from 'react/jsx-runtime';

const items: JSX.Element[] = [];
const itemCount = 1000;
for (let i = 0; i < itemCount; i++) {
    const height = (30 + Math.floor(Math.random() * 30));
    const style = {
        height,
        width: '100%',
    }
    // 相比不定高,这里传入的是元素
    items.push(
        <div className={i % 2 ? 'list-item-odd' : 'list-item-even'} style={style}>Row {i}</div>
    )
}

const Row = (props: any) => items[props.index];

const App = () => {
    return (
        <VariableSizeList
          className="list"
          height={300}
          width={200}
          itemCount={itemCount}
          isDynamic //比定高多了一个参数是否是动态高度
        >
            {Row}
        </VariableSizeList>
    );
}

export default App;

总结:

分片渲染的原理 就是我将要渲染的数据分成很多份,然后利用定时器或者requestAnimationFrame的特性,将他们所做的事情放到不同的任务里面去,来减轻同步执行数据量大造成的卡顿。

分片渲染的核心requestAnimationFrame实现定时加载数据。requestAnimationFrame和定时器都是宏任务的一种,为了区分同步宏任务,我们可以将他们称之为延迟任务,在performasnce里面他们会单独使用一个task,排在同步任务的后面。setTimeout的执行时机是所有的同步任务处理结束以后,才会执行延迟宏任务。而且它延迟的时间是不准确的,如果我的延迟时间是50ms,但是50ms并没有将前面的同步任务处理完,那么他就会被继续延迟,直到所有的同步任务结束以后,才会轮到延迟任务。requestAnimationFrame是在浏览器重绘之前调用的,它的用法和setTimeout极为相似们只是不用设置延迟事件而已。他表示在浏览器重绘之前我调用回调函数即可。和浏览器的刷屏频率有关系,如果浏览器的刷屏频率是60HZ,那么就是13.3ms之前会去处理回调函数里面的事情。所以使用requestAnimationFrame会更为精准的执行代码,避免setTimeout出现的延迟闪屏问题。

定高虚拟列表的核心 1.每一行的高度都相同,总高等于条数乘以行高。2.startIndex 等于 scrollTop 除以 行高,3.endIndex 等于 startindex加上视口显示条数。为了避免页面滚动出现空白,需要上线缓冲2条数据。

知道了startIndex和endIndex我们就可以循环往视口里面添加数据了。

不定高虚拟列表的核心 不管是定高还是不定高,我们的目标都是要找到startIndexendIndex,然后循环将对应的数据渲染到视口就好了。但是不定高,就意味着每一条数据的高度各不相同。这样我们面临的问题是:1:无法快速的知道滚动容器的总高。2:无法确定startIndex。

为了解决上面2个问题,我们用预估法获取总高,获取总高是为了出现滚动条,所以完全可以使用行高的平均值乘以总条数就好了。知道了总高度,以后我们可以在滚动的时候将所有经过的行的数据存储到数组里面,这是一个对象类型的数组,每一行的行高和它到第一行顶部的高度是组成一个对象,存入数组。

在滚动的时候,我们能拿到scrollTop,我们让每一行到顶的高度和scrollTop比较,找到第一个大于scrolltop的那一行,这一行所对的index就是startTime

endIndex也是一样的,我们用scrollTop加上视口高度,然后和数组里面的顶高比较,那个刚好大于scrollTop加上视口高度的行就是结束行。

为了解决计算误差,以免滚动时候出现空白,我们需要在上下各加2条数据作为缓冲数据。

动态虚拟列表

相比于不定高而言,动态虚拟列表在渲染前并不知道每一行的高度,但是要想渲染就必须拿到他的startIndexendIndex,然后循环将对应的数据渲染到视口区。

现在面临的问题是:无法拿到总高,也无法拿到行高。

对于总高我们可以用预估法,将每一行的高度定为行高的平均值。渲染前行高是不知道的,所以用预估法把行高定为平均值,先按照平均值把视口内的数据渲染数来,同时要将渲染过的数据添加到数组里面保存好,具体类似不定高虚拟列表,数组里面放每一行的行高和顶高。

等到这一行渲染以后,通过监听sizechange事件,就能获得这一行的真实高度,然后我们去修改数组里面的数据,将他改成真实的行高和顶高。

等下次滚动的时候,我们能拿到scrollTop,然后和数组里面的真实顶高对比,找到顶高刚好大于scrollTop的那一项,他就是startIndex,如果数组里面没有,那就按照平均值,依次向后添加对象,找到顶高刚好大于scrolltop的那一项。这一项的index就是startIndex

知道了startIndex就有多种办法知道endindex,比如scrollTop加上视口高度,按照找startIndex的办法找endIndex,你还可以按照视口除以平均高度的办法算出视口能展示几条数据,然后用startIndex加上视口条数,就能得到endIndex

知道了startIndex 和 endIndex,你将这几条数据截取出来,然后用循环渲染到页面上,一旦渲染就会触发onSizechange事件,在这个事件里面去修改数组中行的行高和顶高,来矫正数据。