likes
comments
collection
share

在业务中,我是如何实现虚拟滚动的(源码和解决方案) 中

作者站长头像
站长
· 阅读数 55

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第5篇文章,点击查看活动详情

承接上一章节在业务中,我是如何实现虚拟滚动的(源码和解决方案) 上,我们来详细介绍rc-virtual-list五层结构。 时刻铭记我们提出的几个问题

1. 第三层是哪里来的呢?仅仅查看list代码是无法体现出来的?
2. 如何渲染出当前可视窗口的dom元素,依据是什么?
3. 如何计算滚动条的高度?
4. 如何滚动?谁在滚动?

详细介绍

在这里,提供一份带注释的源码

我们根据组件的dom结构,一层一层的看:

第一层

这一层,如前面所说,就是组件的一个大盒子,设置了positon:relative;box-sizing: border-box属性,用来盛放组件。

第二层

在业务中,我是如何实现虚拟滚动的(源码和解决方案) 中 第二层是组件的主体组件,内容和滚动条组件都在这一层。

内容

我们可以看到rc-virtual-list-holder这一层的样式,有我们设置的height = 500,加上溢出省略属性发,这也就是代码中Component的功能 在业务中,我是如何实现虚拟滚动的(源码和解决方案) 中

滚动条

在业务中,我是如何实现虚拟滚动的(源码和解决方案) 中 我们根据上面这张图,可以看到滚动条的效果,这里,rc-virtual-list-scrollbar rc-virtual-list-scrollbar-show这个div仅仅是滚动条的轨道,实际的滚动条还在这一层的下边儿

在业务中,我是如何实现虚拟滚动的(源码和解决方案) 中 我们想不管滚动条在做什么,继续回头看内容区更深层次的组件。

第三层

前面提到过,第三层是所有内容高度总和的div,并且直接查看list的return的代码是没有体现出来,所以可以肯定,其一定是某一个组件提供的,我们来找找看。 最终发现在src/Filler.tsx

高度总和是如何计算的呢,我们查看Filter组件的height属性,对接外面的scrollHeight。这里我们先不探究,如何获取height,在下一层,一起探究!!!

在业务中,我是如何实现虚拟滚动的(源码和解决方案) 中

第四层

第四层是这个组件的可视区域

在业务中,我是如何实现虚拟滚动的(源码和解决方案) 中 在我们进行滚动时,发现 在业务中,我是如何实现虚拟滚动的(源码和解决方案) 中在实时变更,每滚动一个itemtranslateY会增加或者减少该item的高度。我们滚动前和滚动后对比,发现

滚动前:可视区域在所有内容的最上面

在业务中,我是如何实现虚拟滚动的(源码和解决方案) 中 滚动后:可视区域在所有内容的最下边

在业务中,我是如何实现虚拟滚动的(源码和解决方案) 中 那么是如何实现的呢? 我们一起查看Filter组件的offset属性以及第三层需要的scrollHeight属性。

因为这一段代码实在是太长了,这里一些简单的逻辑我直接就省略掉了。

这一段代码,详细介绍了如何计算应该显示到可视区域的元素范围,其大概使用,是主要通过监听scrollTop也就是列表内容滚动高度,来进行计算。

计算规则如下:

// currentItemBottom = 当前item和之前的所有item的高度,
// 如你在第二个item,则currentItemBottom就是前两个item的高度和

// 开始下标计算:如果currentItemBottom >=组件滚动的高度且startIndex = undefined
// 则渲染的内容的开始下标是当前item的下标,偏移值是当前item的所有item的高度和
 if (currentItemBottom >= scrollTop && startIndex === undefined) {
    startIndex = i;
    startOffset = itemTop;
  }
  
  // 结束下标计算: 如果如果currentItemBottom > 组件滚动的高度和可视区域之和且endIndex = undefined
  // 则渲染的内容的结束下标是当前item的下标
   if (currentItemBottom > scrollTop + height && endIndex === undefined) {
    endIndex = i;
  }
  const { scrollHeight, start, end, offset } = React.useMemo(() => {
    // ...
    let itemTop = 0;
    //将要在可视区域渲染的内容数组的下标头
    let startIndex: number;
    let startOffset: number;
    //将要在可视区域渲染的内容数组的下标尾
    let endIndex: number;
    //所有数据的长度
    const dataLen = mergedData.length;
    // 通过该for循环,来判断可视化组件要展示的内容的起止下标
    for (let i = 0; i < dataLen; i += 1) {
      // 每一个item
      const item = mergedData[i];
      // 每一个item的key
      const key = getKey(item);
      // heights的获取是通过useHeights的hook获取的
      //TODO:jiangniao 由collectHeight赋值 cacheHeight是用来存放各个item的实际高度,如果存在,使用这个高度,如果不存在,使用传进来的item的高度
      const cacheHeight = heights.get(key);
      // 当前item的底部 
      const currentItemBottom = itemTop + (cacheHeight === undefined ? itemHeight : cacheHeight);

      // Check item top in the range
      // 如果当前的item底部 >= 列表内容滚动高度并且startIndex === undefined
      // 设置当前可视组件要渲染的开始内容下标
      if (currentItemBottom >= scrollTop && startIndex === undefined) {
        startIndex = i;
        startOffset = itemTop;
      }

      // Check item bottom in the range. We will render additional one item for motion usage
      // 设置当前可是组建要渲染的内容结束的下标
      if (currentItemBottom > scrollTop + height && endIndex === undefined) {
         // 第i个元素(含)之前所有元素的高度 超过了 滚动高度+可视区域的高度,结束索引设为i
        endIndex = i;
      }

      itemTop = currentItemBottom;
    }

    // ...
    return {
      // 这里的itemTop是for循环每一个item的高度之和
      scrollHeight: itemTop,
      start: startIndex,
      end: endIndex,
      // 偏移的高度,是当前渲染组件内容的起始item的前面的item的高度之和,比如当前从第三个内容开始展示,那么该值就是前两个item的高度合
      offset: startOffset,
    };
    // 每次scrollTop变化时,重新计算
  }, [inVirtual, useVirtual, scrollTop, mergedData, heightUpdatedMark, height]);

终于和Filter组件相关的关键属性结束了,我们到现在也知道了如何计算应该显示到可视区域的元素范围。

第五层

视区域渲染dom元素的方法,由一个名为useChildren的自定义hooks提供。

  /**
   * mergedData item所有数据
   * start 可视区域item开始下标
   * end 可视区域item末尾下标
   * setInstanceRef 由useHeights提供
   * children  子组件结构,item的壳
   * sharedConfig item key的对象
   */
const listChildren = useChildren(mergedData, start, end, setInstanceRef, children, sharedConfig);

我们先不管setInstanceRef是做什么的,我们先来看看useChildren是如何实现的。

看了类型定义后,才知道为什么sharedConfig传递参数这么奇怪。因为该部分比较简单,就是通过起始和末尾的下标,来进行数据截取,将截取下来的数据进行通过传递过来的children的值进行渲染。

/**
 * 
 * @param list item所有数据
 * @param startIndex 可视区域item开始下标
 * @param endIndex 可视区域item末尾下标
 * @param setNodeRef 
 * @param renderFunc 子组件结构,item的壳
 * @param param5 item key的对象
 * @returns 
 */
export default function useChildren<T>(
  list: T[],
  startIndex: number,
  endIndex: number,
  setNodeRef: (item: T, element: HTMLElement) => void,
  renderFunc: RenderFunc<T>,
  { getKey }: SharedConfig<T>,
) {
  return list.slice(startIndex, endIndex + 1).map((item, index) => {
    const eleIndex = startIndex + index;
    const node = renderFunc(item, eleIndex, {
      // style: status === 'MEASURE_START' ? { visibility: 'hidden' } : {},
    }) as React.ReactElement;

    const key = getKey(item);
    return (
      <Item key={key} setRef={ele => setNodeRef(item, ele)}>
        {node}
      </Item>
    );
  });
}

但是,这里我们还是要注意一下Item组件,我们需要了解setRef是做什么用的,毕竟我们作为入参传入的setInstanceRef不知道是做什么的!

我们发现Item组件clone返回了包裹了外部传入的列表元素的JSXElement并给其加上了ref属性,这样,当通过ref获取子节点时将会调用refFunc -> setRef -> setInstanceRef。这也是为什么当元素高度可变时需要用React.forwardRef进行列表元素的包裹

export function Item({ children, setRef }: ItemProps) {
  const refFunc = React.useCallback(node => {
    setRef(node);
  }, []);

  return React.cloneElement(children, {
    ref: refFunc,
  });
}

接下来,我们再回过头看我们欠过的债,setInstanceRefcollectHeightheightsheightUpdatedMark。让我们来看看useHeights

我们前面知道了,setInstanceRef会在useChildren渲染时触发,这里,collectHeight在每次滚动时触发,用来计算每一个item的真实高度;heights作为一个map对象,存放了当前渲染的domitem们,其以itemkeykey,以item的高为valuesetInstanceRef用来维护一个以item的keykey,以item渲染出的domvaluemap对象,其根据判断在useChildren中,某一个element实例是否还存在,进行对map对象的新增或者删除。

export default function useHeights<T>(
  getKey: GetKey<T>,
// ...
): [(item: T, instance: HTMLElement) => void, () => void, CacheMap, number] {
  //是否更新
  const [updatedMark, setUpdatedMark] = React.useState(0);
  // 一个存储了当前所有可视区域的dom的map,
  const instanceRef = useRef(new Map<React.Key, HTMLElement>());
  // value是每一个dom的高,key是每一个dom的key
  const heightsRef = useRef(new CacheMap());
  const collectRafRef = useRef<number>();
 // ...
  // TODO: 滚动条滚动时,该方法触发
  function collectHeight() {
    cancelRaf();
    collectRafRef.current = raf(() => {
      instanceRef.current.forEach((element, key) => {
        if (element && element.offsetParent) {
          const htmlElement = findDOMNode<HTMLElement>(element);
          const { offsetHeight } = htmlElement;
          // 计算每一个item的实际高度,如果不等于当前dom的高度,则直接赋值
          if (heightsRef.current.get(key) !== offsetHeight) {
            heightsRef.current.set(key, htmlElement.offsetHeight);
          }
        }
      });

      // Always trigger update mark to tell parent that should re-calculate heights when resized
      setUpdatedMark((c) => c + 1);
    });
  }
  /**
   * 
   * @param item 当前item的值
   * @param instance 当前渲染的dom元素
   * @return 通过该方法进行删除和添加,干掉后,会不准,原因未知!
   */
  // TODO:通过key来判断当前可视区域dom的增或删,如果instance为null,则说明该dom在useChildren中没有了,删除;
  // 如果instance不为null,索命在useChildren中该dom被渲染了,需要添加。
  function setInstanceRef(item: T, instance: HTMLElement) {
    const key = getKey(item);

    if (instance) {
      // 需要添加的dom元素
      instanceRef.current.set(key, instance);
      collectHeight();
    } else {
      //不需要渲染的dom元素删除
      instanceRef.current.delete(key);
    }

    //...
    }
  }

// ...
  return [setInstanceRef, collectHeight, heightsRef.current, updatedMark];
}

总结

本章根据源码详细介绍了rc-virtual-list的五层结构,每一个组件的功能,对于滚动,因为篇幅问题,放到了最后一部分。

在业务中,我是如何实现虚拟滚动的(源码和解决方案) 下

资源引用

github.com/react-compo…

github.com/Nuibia/virt…

转载自:https://juejin.cn/post/7146068387200925727
评论
请登录