Bootstrap

虚拟列表原理剖析

前言

本文主要介绍长列表的一种优化方案:虚拟列表。社区中虚拟列表的实现方案已经有很多了,所以本文主要是对社区中一些技术大佬的实现方案进行更加详尽的刨析,以便我们能够更加深入理解虚拟列表的原理。

虚拟列表的作用

前言中我们其实已经说明了虚拟列表的主要作用,即他是长列表的一种优化方案。传统的长列表,主要用于处理一些数据展示且不进行分页的业务场景,对于这种长列表来说,如果加载的数据量很庞大的话,浏览器回流和重绘的开销也会十分庞大,所以有的时候会造成页面卡顿,导致用户体验较差。而虚拟列表可以在一定程度上解决这个问题。

虚拟列表的核心

虚拟列表其实也是一种按需加载,那么有些人可能会问,那不是和懒加载差不多吗?这里我们要简单说明一下懒加载懒加载其实就是延迟加载,当页面中的数据很多时,我们优先加载视口区域中的数据,其余数据等滚动条滚到相应位置时再进行加载。所以懒加载确实也是按需加载,但是区别在于,当你的滚动条滚动到靠下的位置,懒加载会加载你当前位置以及上方滚动过区域的全部数据,而虚拟列表只加载你当前可见区域中的数据。所以如果数据量很大的话,你滚动的位置越靠下,那么懒加载渲染的成本也就越高,但虚拟列表的渲染成本固定,他只对可见区域进行渲染,对非可见区域中的数据不渲染或部分渲染,因此性能要比懒加载高很多。

虚拟列表的实现

在实现虚拟列表之前我们首先需要明确几个比较关键的变量

  • 列表项高度itemHeight
  • 列表总长度listHeight
  • 渲染区域高度screenHeight
  • 可以显示列表项的数量visibleCount
  • 显示列表项的初始索引startIndex
  • 显示列表项的结束索引endIndex
  • 显示列表项数据visibleData
  • 渲染区域偏移量startOffset
原型图

在这里插入图片描述

模板

虚拟列表的模板由三部分组成

  • 列表容器 list-container:给固定高度,用于形成滚动条并监听滚动事件
  • 滚动区域 scroll-area:高度为列表总长度listHeight ,用于撑开列表容器形成滚动条
  • 内容区域 view-area:用来展示列表数据,为了让内容区域在滚动后持续显示在视口区域中,所以我们需要为内容区域设置偏移量startOffset

注意:滚动区域和内容区域需要开启绝对定位

<template>
  <div ref="list" class="list-container" @scroll="scrollEvent($event)">
    <div class="scroll-area" :style="{ height: listHeight + 'px' }"></div>
    <div class="view-area" :style="{ transform: getTransform }">
      <div ref="items"
        class="list-item" 
        v-for="item in visibleData" 
        :key="item.id"
        :style="{ height: itemHeight + 'px',lineHeight: itemHeight + 'px' }"
      >{{ item.value }}</div>
    </div>
  </div>
</template>

<style scoped>
.list-container {
  height: 100%;
  overflow: auto;
  position: relative;
}

.scroll-area {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
}

.view-area {
  left: 0;
  right: 0;
  top: 0;
  position: absolute;
  text-align: center;
}

.list-item {
  padding: 10px;
  color: red
}
</style>
逻辑

这部分逻辑其实也很简单,将我们上面罗列出来的关键变量一一表达出来就可以了,其次我们还需要为滚动事件绑定回调函数

export default {
  name:'VirtualList',
  props: {
    // 列表数据
    listData:{
      type:Array,
      default:()=>[]
    },
    // 列表项高度
    itemHeight: {
      type: Number,
      default:200
    }
  },
  computed:{
    // 列表总长度(列表项高度 * 列表项总数)
    listHeight(){
      return this.listData.length * this.itemHeight;
    },
    // 可显示的列表项数(视口区域宽度 / 列表项高度)注意向上取整
    visibleCount(){
      return Math.ceil(this.screenHeight / this.itemHeight)
    },
    // 偏移量对应style 将内容区域向下移动this.startOffset的距离
    getTransform(){
      return `translate3d(0,${this.startOffset}px,0)`;
    },
    // 要显示的列表项数据
    visibleData(){
      return this.listData.slice(this.start, Math.min(this.end,this.listData.length));
    }
  },
  mounted() {
    // 获取视口区域高度,用来求得可显示的列表项数
    this.screenHeight = this.$el.clientHeight;
    // 确定初始索引
    this.start = 0;
    // 确定结束索引
    this.end = this.start + this.visibleCount - 1;
  },
  data() {
    return {
      // 视口区域高度
      screenHeight:0,
      // 偏移量
      startOffset:0,
      // 初始索引
      start:0,
      // 结束索引
      end:0,
    };
  },
  methods: {
    scrollEvent() {
      // 获取当前滚动位置
      let scrollTop = this.$refs.list.scrollTop;
      // 获取此时的开始索引和结束索引 更新显示数据
      this.start = Math.floor(scrollTop / this.itemHeight);
      this.end = this.start + this.visibleCount - 1;
        
      // 计算偏移量 移动内容区域
      /**
       * 注:不能直接让偏移量 == scrollTop 否则会丢失滚动动画效果。举个例子比如列表项高度为100px,此时scrollTop为250px,那么第三个列表项一半在视口区
       * 域内部,一半在视口区域外部,那么在scrollTop未到达300px之前,startOffset一直为200px,于是滚动条会控制页面具体显示的内容,进而实现滚动效果,当
       * scrollTop到达300px时,startOffset也变为300,内容区域和视口区域的最上方再次重合,进而实现丝滑的滚动效果。所以滚动的过程中其实并不是一直在移动
       * 内容区域,而是当scrollTop为列表项高度的整数倍时,才移动内容区域。
       */
      this.startOffset = scrollTop - (scrollTop % this.itemHeight);
    }
  }
};

这样一个基本的虚拟列表其实就已经实现了,怎么样是不是很简单?那么接下来我们还可以对这个虚拟列表进行进一步完善。

虚拟列表的完善

上面我们实现的虚拟列表中,有一个最基本的条件,就是每个列表项的高度相同且固定,因此我们很容易就可以计算出列表的总高度开始索引结束索引以及渲染区域的偏移量。现在我们要对虚拟列表进行进一步优化,让他根据列表项的内容自动决定列表项的高度。

实现思路

给定一个预设高度,每个列表项初始高度都以预设高度为准,在内容加载后,根据每个列表项的实际内容修正每个列表项的高度。下面我们根据这个思路,将上面提到的一些关键变量进行调整。

关键变量调整
  • 列表项高度。列表项高度的初始值即为预设高度,这个高度和之前的itemHeight一样也是由外部传入。后续加载完内容后,我们需要对列表项的高度进行修正,这个修正的高度由具体的内容决定,值为列表项的top减去bottom,所以我们还需要新建一个数组记录每一个列表项具体的位置,以便我们后面修正列表项的实际高度。
// 外部传入预设高度
props: {
  estimatedItemHeight:{
    type:Number
  }
}

// 新建数组positions positions用来记录每个列表项的具体位置 initPositions用来初始化positions
data() {
  return {
     positions: []
  }
}
initPositions() {
  this.positions = this.listData.map((data, index) => ({
     index,
     height: this.estimatedItemHeight, // 初始高度为预设高度
     top: index * this.estimatedItemHeight,
     bottom: (index + 1) * this.estimatedItemHeight
  }));
},
  • 列表总高度。有了每个列表项的位置信息,列表总高度其实就很好求得了,即为最后一个列表项的bottom值。
listHeight(){
  return this.positions[this.positions.length - 1].bottom;
}
  • 初始索引。初始索引其实也很好找到,其实就是第一个bottom大于scrollTop列表项的索引。
getStartIndex(scrollTop = 0){
  return this.positions.find(item => item && item.bottom > scrollTop).index
}

// 这个方法的时间复杂度是O(n),由于我们每个列表项的bottom值是从小到大排序的,所以我们可以采用二分搜索来提高搜索效率
getStartIndex(scrollTop = 0){
  return this.binarySearch(this.positions, scrollTop)
},
// 这里的二分搜索采用的是红蓝二分搜索模板,这个模板更加通用,有兴趣可以自行查阅一下。
binarySearch(list,value) {
  let left = -1
  let right = list.length
  while(left + 1 !== right) {
    let mid = left + ((right-left) >>> 1)
    if(list[mid].bottom > value) {
        right = mid
    }else {
        left = mid
    }
  }
  return left == -1 ? 0 : right;
}
  • 偏移量。偏移量我们就以初始索引对应列表项的top为准即可,但需要注意,当初始索引对应列表项为最后一个列表项时,偏移量应该为该列表项的bottom,否则最后一个列表项无法显示
this.startOffset = this.start == this.positions.length - 1 ? this.positions[this.start].bottom : this.positions[this.start].top
列表项位置信息修正

接下来我们实现列表项位置信息修正的具体逻辑,需要注意的是我们需要在在页面渲染结束后,再对每个列表项的位置进行修正,所以我们这里选择在update钩子里来对列表项的位置信息进行修正。

updated(){
  let nodes = this.$refs.items;
  nodes.forEach((node, index)=>{
    let rect = node.getBoundingClientRect();
    let height = rect.height; // 这个就是列表项的实际高度
    let oldHeight = this.positions[index].height; // 列表项的初始高度
    let dValue = oldHeight - height; // 新旧高度差值
    // 存在差值
    if(dValue){
      // 修正当前列表项bottom
      this.positions[index].bottom = this.positions[index].bottom - dValue;
      // 修正当前列表项height
      this.positions[index].height = height;
      // 依次修正后面所有列表项的top和bottom
      for(let i = index + 1;i < this.positions.length; i++){
        this.positions[i].top = this.positions[i-1].bottom;
        this.positions[i].bottom = this.positions[i].bottom - dValue;
      }
    }
  })
}
完整代码
<template>
  <div ref="list" :style="{height}" class="list-container" @scroll="scrollEvent($event)">
    <div ref="scrollArea" class="scroll-area"></div>
    <div ref="content" class="view-area">
      <div class="list-item" ref="items" :id="item._index" :key="item._index" v-for="item in visibleData">
        <slot ref="slot" :item="item.item"></slot>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    // 所有列表数据
    listData: {
      type: Array,
      default: () => [],
    },
    // 预设高度
    estimatedItemHeight: {
      type: Number,
      required: true,
    },
    // 容器高度
    height: {
      type: String,
      default: '100%',
    },
  },
  computed: {
    // 所有列表数据 加了个索引
    _listData() {
      return this.listData.map((item, index) => {
        return {
          _index: `_${index}`,
          item,
        };
      });
    },
    // 可视区域列表项数量
    visibleCount() {
      return Math.ceil(this.screenHeight / this.estimatedItemHeight);
    },
    // 可视区域列表项数量
    visibleData() {
      return this._listData.slice(this.start, this.end);
    },
  },
  created() {
    // 初始化位置信息
    this.initPositions();
  },
  mounted() {
    // 获取视口高度
    this.screenHeight = this.$el.clientHeight;
    // 初始化初始索引和结束索引
    this.start = 0;
    this.end = this.start + this.visibleCount - 1;
  },
  updated() {
    this.$nextTick(function () {
      if (!this.$refs.items || !this.$refs.items.length) {
        return;
      }
      // 获取真实元素大小,修正列表项的位置信息
      this.updateItemsHeight();
      // 修正列表总高度
      let height = this.positions[this.positions.length - 1].bottom;
      this.$refs.scrollArea.style.height = height + 'px';
      // 重新获取偏移量
      this.setStartOffset();
    });
  },
  data() {
    return {
      // 可视区域高度
      screenHeight: 0,
      // 初始索引
      start: 0,
      // 结束索引
      end: 0,
    };
  },
  methods: {
    // 初始化位置信息
    initPositions() {
      this.positions = this.listData.map((d, index) => ({
        index,
        height: this.estimatedItemHeight,
        top: index * this.estimatedItemHeight,
        bottom: (index + 1) * this.estimatedItemHeight,
      }));
    },
    // 获取列表起始索引
    getStartIndex(scrollTop = 0) {
      return this.binarySearch(this.positions, scrollTop);
    },
    // 二分法查找
    binarySearch(list, value) {
      let left = -1;
      let right = list.length;
      while (left + 1 !== right) {
        let mid = left + ((right - left) >>> 1);
        if (list[mid].bottom > value) {
          right = mid;
        } else {
          left = mid;
        }
      }
      return left == -1 ? 0 : right;
    },
    // 修正列表项的位置信息
    updateItemsHeight() {
      let nodes = this.$refs.items;
      nodes.forEach((node, index) => {
        let rect = node.getBoundingClientRect();
        let height = rect.height;
        let oldHeight = this.positions[index].height;
        let 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;
          }
        }
      });
    },
    // 设置偏移量
    setStartOffset() {
      let startOffset =
        this.start == this.positions.length - 1
          ? this.positions[this.start].bottom
          : this.positions[this.start].top;
      this.$refs.content.style.transform = `translate3d(0,${startOffset}px,0)`;
    },
    // 滚动事件对应回调
    scrollEvent() {
      let scrollTop = this.$refs.list.scrollTop;
      this.start = this.getStartIndex(scrollTop);
      this.end = this.start + this.visibleCount - 1;
      this.setStartOffset();
    },
  },
};
</script>


<style scoped>
.list-container {
  overflow: auto;
  position: relative;
}

.scroll-area {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
  z-index: -1;
}

.view-area {
  left: 0;
  right: 0;
  top: 0;
  position: absolute;
}

.list-item {
  padding: 5px;
  color: #555;
  box-sizing: border-box;
  border-bottom: 1px solid #999;
}
</style>

参考

虚拟列表原理
「前端进阶」高性能渲染十万条数据(虚拟列表)

;