虚拟列表-前端

1,049 阅读6分钟

简介: 最近发现公司项目移动端列表页面展示特别卡,检查之后发现是因为数据量大,渲染很慢导致的。因为要定位某一个列表的原因,导致数据没办法分页。在这种情况就提出用虚拟列表的方式处理,但是之前一直没有用过这种方式,在查了很多相关的技术文章之后,结合项目的具体情况,实现了这个功能(通过记录的方式让自己更加了解实现的原理,因为很多时候只是CV工程师, 即使实现了功能也不知道具体做了些啥)。 注: Angular + ts,具体实现参照了这位老兄的代码codesandbox.io/s/virtualli…

相关概念

虚拟列表:当数据很多时,只渲染可视区域内需要展示的那些数据。

具体实现步骤简化了一些, 不是只渲染可视区域内需要展示的数据,而是渲染固定长度10条数据(10条数据渲染后一定大于可视区域height, 别问为啥是10条, 没有理由)。

需要了解的三个概念:

  • 滚动容器元素scrollDom, 即真实可视区域(宽高固定);
  • 列表容器元素realDom,即实际渲染数据的区域;
  • 虚拟容器元素virtualDom,即假设所有列表数据渲染后应撑开的高度。

步骤:

  • 计算当前可视区域起始数据的startIndex,由于固定渲染10条数据 endIndex = startIndex + 10;
  • 计算当前可视区域的数据;
  • 根据startIndex计算列表的偏移位置(这里的偏移量取的上一条数据的bottom),设置到realDom上,这样才可以撑开滚动条;
  • 设置虚拟容器元素的高度。
  • 记录假设所有列表数据渲染后的position(记录每一条数据的height/top/bottom等)。项目中大部分数据渲染后的高度400+px,所以初始化每条数据的高度500px;每次更新当前可视区域的数据后要更新position。

具体步骤实现

一、获取到列表数据后,初始化position

 initPositions(data) {
    const positions = data.map((d, index) => ({
      index,
      height: 500,
      top: index * 500,
      bottom: (index + 1) * 500
    }));
    return positions;
  }
this.positions = this.initPositions(this.scrollData);
// 获取到数据后也需要更新一下positions, 后面会提到这个方法
this.updateItemsSize('scroll-item');

二、监听scrollDom的滚动事件,根据滚动scrollTop计算出应该渲染的startIndex,并更新真实渲染数据

  // 滚动修改当前加载的数据(默认展示10条数据)
  getShowScrollData(scrollTop: number) {
    let startIndex = this.getStartIndex(scrollTop);
    // 当获取到startIndex大于或等于列表数据的最后10条的index,不再改变应该渲染的数据
    if (startIndex == null || startIndex + 10 > this.scrollData.length) {
      startIndex = this.scrollData.length - 10;
    }
    this.realdata = this.scrollData.slice(startIndex, startIndex + 10);
    return startIndex;
  }
  
  // 获取列表起始索引
  getStartIndex(scrollTop: number = 0) {
    // 二分法查找
    return this.binarySearch(this.positions, scrollTop);
  }

  // 二分法查找, 实际就是每次取数据中间值的bottom与滚动高度scrollTop进行对比,
  binarySearch(list: any[] , value: number) {
    let start = 0;
    let end = list.length - 1;
    let tempIndex = null;
    while (start <= end) {
      const midIndex = parseInt((`${(start + end) / 2}`), 10);
      const midValue = list[midIndex].bottom;
      if (midValue === value) { // 相等时结束
        return midIndex + 1;
      } else if (midValue < value) { // 小于时,startIndex增加
        start = midIndex + 1;
      } else if (midValue > value) { // 大于时, endIndex减小
        if (tempIndex === null || tempIndex > midIndex) {
          tempIndex = midIndex;
        }
        end = end - 1;
      }
    }
    return tempIndex;
  }

三、数据更新后,更新列表的position

  // 获取列表项的当前尺寸
  updateItemsSize(scrollItemId: string) {
  	// 获取当前渲染的数据的node节点
    let nodes: any = document.querySelectorAll(`.real-item`);
    nodes = Array.prototype.slice.call(nodes); // 类数组转数组
    nodes.forEach(node => {
      // 根据getBoundingClientRect这个API获取到每个节点实际的数据
      const rect = node.getBoundingClientRect();
      const height = rect.height;
      const index = +node.id; // 每个node的id是数据在scrollData的下标
      const oldHeight = this.positions[index].height;
      const dValue = oldHeight - height;
      // 存在差值
      if (dValue) {
        this.positions[index].bottom = this.positions[index].bottom - dValue;
        this.positions[index].height = height;
        for (let k = index + 1; k < this.positions.length; k++) {
          this.positions[k].top = this.positions[k - 1].bottom;
          this.positions[k].bottom = this.positions[k].bottom - dValue;
        }
      }
    });
  }

四、更新虚拟容器元素virtualDom的高度

  // 虚拟Dom添加高度
  setVirtualDom(virtualId: string, height?: string) {
    const virtualDom = document.getElementById(`${virtualId}`);
    // 初始化virtualDom的高度为0时, 也用这个方法
    height = height ? height : `${this.positions[this.positions.length - 1].bottom}`;
    if (virtualDom) {
      virtualDom.style.height = height + 'px';
    }
  }

五、设置realDom的偏移量,修改滚动条的位置(如果不设置偏移量, 滚动条的位置是不对的)

  // 设置realDom的偏移量
  setStartOffset(start: number, scrollId: string) {
    const startOffset = start >= 1 ? this.positions[start - 1].bottom : 0;
    const dom = document.getElementById(`${scrollId}`);
    dom.style.transform = `translate3d(0,${startOffset}px,0)`;
  }

这样写下来看,好像实现步骤很简单一样,但我在参考别人代码的时候是不理解的,是通过一步步断点,看每一数据的变化才能根据具体项目做一些改变的。最后也理解了其实虚拟列表中的重点就是要找到列表渲染startIndex以及列表的positions。

其他的问题

实际项目中还有一个功能,通过页面右侧的列表目录树进行列表定位,但是最后的效果还是不好,总是会出现偏差,定不准。

主要的原因: 还是虚拟列表这个方法,最终渲染到页面上的数据是通过滚动才获取到的。

换句话说,就是必须知道滚动高度scrollTop才能找到startIndex,但是要定位的话,是不可能一开始就知道用户要定位到数据的实际positions,positions是每一次滚动数据更新之后再根据每一条数据页面实际渲染的height再更新为真实数据(一开始都是给的默认500px的高度,这个与实际高度肯定是有出入的,也是因为这个原因导致定位一直不准的)。

这个列表数据为什么一开始就不能用分页呢,就是因为要提前知道每一个列表所处的位置,才能进行要定位。那这样即使用了虚拟列表问题依旧存在。但是不能还没有尝试过就直接放弃对吧,其实后面大部分的时间都是为了来处理这个定位的问题,最后还是放弃了用户虚拟列表+定位去完成这个功能。由于花了大量的时间去完成这个功能还是想记录一下这个坎坷的过程。

主要想了三种方法:

    1. 手动滚动变成自动滚动,每次都让scrollTop + 100 知道找到要定位的那条数据并渲染出来,这样的定位效果还挺好的,但是问题在于点击右侧列表目录树的之后,滚动条是100的增加的,如果数据量很大并且定位的是最后一条数据,那样就需要滚动很久,很明显会觉得慢,所以放弃了这个做法。这个方法在实现的过程中也遇到了很多问题,首先是ionic-content的滚动事件是异步的,一直以来我在处理异步变同步的时候就很迷茫,所以最后还是请教了同事;其次是什么时候停止滚动,尝试了很多次,才找到停止的准确条件(当前显示的列表realData的第一条数据等于当前定位的数据,但是这样有可能定位的数据是最后10条中的某一条,那根本无法停止滚动,所以停止条件中一定要(this.realData[0] === this.scrollData[this.scrollData.length - 10]);
    1. 通过某种算法提前计算所有列表数据渲染之后的高度,这个能力有限搞不定
    1. 知道要定位的数据的下标index,也知道每一个最小的渲染高度minHeight,先滚动scrollTop = index * minHeight;再100的增加滚动距离知道找到要定位的数据。但这个方法还是有可能遇到和第一个方法一样的问题,没有采取 。