大数据列表虚拟滚动实现

852 阅读3分钟

产生原因

在平时开发项目的时候有的时候遇到大数据量的列表展示,如果单纯使用懒加载展示的话,页面有上千条数据,而后页面就会逐渐变成卡顿,这个时候就需要使用虚拟滚动技术,只不过因为需要计算滚动条所以需要知道数据源与每行高度,高度需要统一

什么是虚拟滚动?简单的说就是根据用户进行滚动的来加载更新DOM,这里我才用的是利用滚动事件动态更新DOM数据并动态设置padding-top与padding-bottom。

ymxhm-0qhgd.gif

初始化

  1. 首先组件第一步进行初始化操作因为涉及到很多计算,可以考虑封装成一个单独class,名称就叫Virtual 用户通过new 方式初始化。

image.png

```
constructor(param, cb) {
    this.init(param, cb);
  }
  //初始化
  init(param, cb) {
    this.param = param;
    this.cb = cb;

    this.offset = 0; // 初始化滚动条的offsetTop值
    this.direction = null
    this.range = Object.create(null);
    if (param) {
      this.checkRange(0, param.keeps - 1);
    }
  }
```
这里 便是初始化相关参数和值 

2. 下一步便是设置要渲染的下标和计算padding-top与padding-bottom的值

  //设置start与end的逻辑
  checkRange(start, end) {
    let { keeps, uniqueIds } = this.param;
    const total = uniqueIds.length;
    if (total < keeps) {
      start = 0;
      end = this.getLastIndex();
    } else if (end - start < keeps - 1) {
      start = end - keeps + 1;
    }
    if (this.range.start !== start) {
      this.updateRange(start, end);
    }
  }

关于优化点后面会详细介绍,上面的if else判断是为了防止计算的下标越界

  1. 最后将计算边距修改range对象最后调用传递进来的callback。
 /**
   * @param {number} start
   * @param {number} end
   * 修改range对象值并计算padFront与padBehind对应上padding与下padding
   */
  updateRange(start, end) {
    this.range = {
      start,
      end,
      padFront: this.getPadFront(start), //上边距
      padBehind: this.getPadBehind(end), //下边距
    };
    this.cb(this.getVirtualInfo());
  }

滚动原理计算

向下滚动

1.首先滚动发生后会触发组件的scroll组件然后调用Virtual.handleScroll

handleScroll(offset) {
    this.direction = offset < this.offset ? DIRECTION_TYPE.FRONT : DIRECTION_TYPE.BEHIND
    this.offset = offset

    if (this.direction === DIRECTION_TYPE.FRONT) {
      this.handleFront()
    } else if (this.direction === DIRECTION_TYPE.BEHIND) {
      this.handleBehind()
    }
  }

先利用offset判断是向上滚动还是向下滚动

2.向下滚动调用handleBehind

    handleBehind() {
    const overs = this.getScrollOvers();
    const { start } = this.range;
    const { buffer } = this.param;
    if (overs < start + buffer) {
      //滚动的如果小于start+buffer代表不需要改变padding,自身的内容已经够展示,不需要做其他渲染操作
      return;
    }
    this.checkRange(overs, this.getEndByStart(overs));
  }

那么接下来看一个核心方法那就是利用offset计算出start的下标

    getScrollOvers() {
        let { offset } = this;
        if (offset <= 0) {
          return 0
        }
        let low = 0
        let middle = 0
        let middleOffset = 0
        let high = this.param.uniqueIds.length
        while (low <= high) {
          middle = low + Math.floor((high - low) / 2)
          middleOffset = this.getIndexOffset(middle)

          if (middleOffset === offset) {
            return middle
          } else if (middleOffset < offset) {
            low = middle + 1
          } else if (middleOffset > offset) {
            high = middle - 1
          }
        }
        return low > 0 ? --low : 0

这里才用二分查找法通过每次不断变换计算出偏移量然后对offset值比较最后找到对应的下标或者最接近的最终返回。

3.和初始化时候一样进行相应计算

向上滚动

1.调用handleFront函数

handleFront() {
    const overs = this.getScrollOvers();
    let { start } = this.range;
    const { buffer } = this.param;
    if (overs > start) {
      return
    }
    const starts = Math.max(overs - buffer, 0);
    this.checkRange(starts, this.getEndByStart(starts));
  }

对start进行优化处理而后计算end值

getEndByStart(start) {
    let { keeps } = this.param;
    let end = start + keeps - 1;
    end = Math.min(end, this.getLastIndex());
    return end;
  }

这么做是为了不让end值越界

渲染处理

image.png

在这里重点关注vnode处理也就是setChildNode函数

setChildNode(h, slot, tag) {
      const childNode = [];
      const { dataKey, dataSource } = this;
      let { start, end } = this.virtualInfo;
      while (start <= end) {
        let dataSources = dataSource[start];
        if (dataSources) {
          if (Object.prototype.hasOwnProperty.call(dataSources, dataKey)) {
            childNode.push(
              h(
                Slot,
                {
                  props: {
                    index: start,
                    source: dataSources,
                    uniqueKey: dataSource[dataKey],
                    renderTag: tag,
                  },
                },
                slot({
                  source: dataSources,
                  index: start,
                })
              )
            );
          } else {
            console.warn(
              `Cannot get the data-key '${dataKey}' from data-sources.`
            );
          }
        } else {
          console.warn(`Cannot get the index '${start}' from data-sources.`);
        }
        start++;
      }
      return childNode;
    }

在这里将通过while循环截取范围内的数据利用createElement创建Vnode元素最终返回。

    return h(
      rootTag,
      {
        ref: refName,
        on: {
          "&scroll": this.handlerScrolls,
        },
        class: "sh-virtual",
      },
      [
        h(
          wrapTag,
          {
            style: wrapStyle,
            class: className,
          },
          children
        ),
      ]
    );

最后创建根节点与列表容器节点。

该组件优化点

其实在进行测试时候会发生很多问题,在已知问题上我做了很多优化,例如

  1. 查询时候使用二分查找算法

image.png

因为考虑到这是大数据量的虚拟列表,使用二分查找时间复杂度是O(1) 而直接使用for循环**O(n)**级别。

2.滚动小于缓存值大小 image.png

满足这个条件就会终止后面计算

3.利用buffer做渲染优化

image.png

在这里 Math.max(overs - buffer, 0) 这里意思是向上滚动overs-buffer 这个范围不用触发重新渲染

其他

其实刚开始做虚拟滚动当初想到的是用的IntersectionObserver实现但是,用这个东西会有很多严重的bug,后来才学习才用传统的scroll事件。

260541431c5543632715d2dfac6b5d2.png

如果用户滑动过快还没加载起来可能会一直有白屏。

结语

无论怎么说这是自己模仿文档、代码写的第一个轮子当然还没写完还要提供其他插槽比如说底部或者顶部这些。加油!!!