虚拟列表vue-virtual-scroll-list实现原理

4,156 阅读9分钟

虚拟列表vue-virtual-scroll-list

vue-virtual-scroll-list 官网:tangbc.github.io/vue-virtual…

vue-virtual-scroll-list github:github.com/tangbc/vue-…

1. 基本思想

虚拟列表是长列表的一种优化方式

  • 假设在一个列表中有10000个列表项,如果直接循环渲染的话,就会创建10000个dom元素
  • 但是如果页面上的dom元素过多,就会导致页面卡顿
  • 因此虚拟列表的本质就是避免渲染过多的dom元素,只在页面上渲染固定数量的dom元素
  • 在用户滚动过程中,不断改变dom元素渲染的内容,并且控制dom元素始终在页面的可视区域

2. 使用

2.1. 基本使用

image.png

列表组件

<template>
  <div class="example">
        <virtual-list 
          class="list"
          :data-sources="items"
          :data-key="'id'"
          :data-component="itemComponent"

          :estimate-size="50"
          :item-class="'list-item-fixed'"
        />
    		<!-- data-sources: 列表项数组,这个数组中的每一个对象都代表一个列表项 -->
    		<!-- data-key: 用来指定每个列表项的key值是data-sources中对应对象的哪个属性,这里指的就是id属性 -->
    		<!-- data-component: 自定义的列表项组件 -->
  </div>
</template>

<script>
import VirtualList from 'vue-virtual-scroll-list'
import Item from './Item'

import { Random } from '../../common/mock'
import genUniqueId from '../../common/gen-unique-id'

const TOTAL_COUNT = 10000

const DataItems = []
let count = TOTAL_COUNT
while (count--) {
  const index = TOTAL_COUNT - count
  DataItems.push({
    index,
    name: Random.name(),
    id: genUniqueId(index),
  })
}

export default {
  name: 'fix-size',
  
  data () {
    return {
      items: DataItems,
      itemComponent: Item,
    }
  },
}
</script>

<style lang="less">
.list {
  width: 100%;
  height: 500px;
  border: 2px solid;
  border-radius: 3px;
  overflow-y: auto;
  border-color: dimgray;

  .list-item-fixed {
    display: flex;
    align-items: center;
    padding: 0 1em;
    height: 60px;
    border-bottom: 1px solid;
    border-color: lightgray;
  }
}
</style>

列表项组件

<template>
  <div class="item-inner">
    <span># {{ source.index }}</span>
    <span>{{ source.name }}</span>
  </div>
</template>

<script>

export default {
  name: 'fix-size-item',

  props: {
    // data-sources中的数据对象
    source: {
      type: Object,
      default () {
        return {}
      }
    }
  },

  mounted() {
    console.log('slot mounted')
  }
}
</script>

<style lang="less" scoped>
.item-inner {
  span:first-child {
    margin-right: 1em;
  }
}
</style>

2.2. 参数详解

必选参数

参数类型说明
data-keyString / Function唯一键来自每个数据对象中的 data-sources。或者使用每个 data-sources 调用的函数并返回它们的唯一键。它的值在 data-sources 中必须是唯一的,用于标识节点大小。
data-sourcesArray[Object]列表构建的源数组,每个数组数据必须是一个对象,并且有一个唯一的 key 或 generate for data-key 属性。
data-componentComponent由 vue 创建 / 声明的渲染项组件,将使用 data-sources 中的数据对象作为渲染 prop 并命名为:source。

可选参数

  • 常用
参数类型默认值说明
keepsNumber30虚拟列表在真实 dom 中保持渲染的节点数量。
extra-propsObject{ }额外的参数(不在 data-sources 中的)分配给节点组件。注意:index 和 source 都已被占用。
estimate-sizeNumber50每个节点的预计尺寸,如果更接近平均尺寸,滚动条长度看起来更准确。建议:分配自己计算的平均值。
  • 不常用
参数类型默认值说明
startNumber0设置滚动位置停留开始索引。
offsetNumber0设置滚动位置保持偏移。
scrollEvent滚动时发出,回调参数(event, range)
totopEvent滚动到顶部或左侧时发出,无参数。
tobottomEvent滚动到底部或右侧时发出,无参数。
resizedEvent调整项目大小时发出 (mounted),回调参数 (id, size)。
directionStringvertical滚动方向,可用值为 vertical 和 horizontal。
page-modeBooleanfalse设置虚拟列表使用全局文档滚动列表。
top-thresholdNumber0触发 totop 事件的阈值,注意:多次调用
bottom-thresholdNumber0触发 tobottom 事件的阈值,注意:多次调用
root-tagStringdiv根元素标记名称。
wrap-tagStringdiv包裹元素(role = item)标签名称。
item-classString包裹元素类名。
item-class-addFunction可将额外的类(字符串)返回到节点包裹元素参数(索引)的函数。
item-styleObject{ }节点包裹元素内联样式。
item-scoped-slotsObject{ }节点组件的 $scopedSlots。
header-tagStringdiv对于使用头槽,头槽包裹元素(role = header)标签名称。
header-classString{ }对于使用头槽,头槽包裹元素类名。
header-styleObject{ }对于使用头槽,头槽包裹元素内联样式。
footer-tagStringdiv对于使用页脚槽,页脚槽包裹元素(role = footer)标签名称。
footer-classStringdiv对于使用页脚槽,页脚槽包裹元素类名。
footer-styleObject{ }用于使用页脚槽、页脚槽包裹元素内联样式。

2.3. 公共方法

可以通过 ref 调用这些方法:

方法说明
reset()将所有状态重置回初始状态。
scrollToBottom()手动将滚动位置设置为底部。
scrollToIndex(index)手动将滚动位置设置为指定的索引。
scrollToOffset(offset)手动将滚动位置设置为指定的偏移量。
getSize(id)通过 id 获取指定的 item 大小(来自 data-key value)。
getSizes()获取存储(渲染)节点的总数。
getOffset()获取当前滚动偏移量。
getClientSize()获取包裹元素视口大小(宽度或高度)。
getScrollSize()获取所有滚动大小(scrollHeight 或 scrollWidth)。
updatePageModeFront()当使用页面模式和虚拟列表根元素 offsetTop 或 offsetLeft 变化时,需要手动调用该方法。

3. 原理

3.1. 常用变量

  • keeps:在页面上渲染dom元素的个数,默认为30
  • start:要渲染的第一个列表项在列表数组中索引,初始值为0
  • end:要渲染的最后一个列表项在列表数组中的索引,初始值为29
  • buffer:缓冲的列表项个数,默认为 keeps/3=30/3=10

    • 作用是为了避免由于用户滚动太快从而出现白屏现象
  • overs:已经滚动到可视区域外的列表项的个数

3.2. dom结构

  • VirtualList组件由两个div组成,外层为root div,内层为wrap div
  • root div 为可视区域,高度固定
  • wrap div 为可滚动区域,高度由列表项padding 撑开

3.3. 具体过程

image.png

首次渲染

  1. 为 wrap div 设置 padding

    • paddingTop = 0
    • paddingBottom = 剩余的列表项个数 * 列表项预估高度
  2. 根据 start 和 end 从列表数组中取出列表项进行渲染

    • 初始值:keeps=30,start=0,end=29

滚动过程

  • 在滚动过程中,计算出「已经滚动到可视区域外的列表项个数」overs
  • 判断 overs 和「start+buffer」之间的大小关系,也就是判断用户滚动过去的列表项的个数是否有 buffer 个
  • 如果 overs 小于 start+buffer ,说明用户滚动过去的列表项的个数没有 buffer 个,不执行任何操作
  • 如果 overs 大于/等于 start+buffer,说明用户滚动过去的列表项的个数有 buffer 个,则更新 start,end,paddingTop,paddingBottom,重新渲染列表项

3.4. 列表项固定/动态高度的区别

  • 整体流程基本相同
  • 区别在于某些变量的计算方式不同:overs, paddingTop, paddingBottom

overs

固定高度

  • overs = scrollTop / 列表项固定高度

动态高度

  • 二分查找

    • 从中间的列表项开始查找,判断列表项距离 wrap div 顶部的距离是否等于当前的scrollTop
    • 如果等于,则该 overs = 该列表项的索引
    • 否则继续二分查找
  getScrollOvers () {
    // 二分查找
    let low = 0
    let middle = 0
    let middleOffset = 0
    let high = this.param.uniqueIds.length

    while (low <= high) {
      // this.__bsearchCalls++
      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
  }

paddingTop

固定高度

  • paddingTop = start * 列表项固定高度

动态高度

  • VirtualList组件在渲染过程中不断将每个列表项的 id 以及对应的高度记录到一个map对象中
  • 所以只需要从这个map对象中获取 索引为start 的列表项 之前所有列表项的高度,全部相加即可得到 paddingTop
  getIndexOffset (givenIndex) {
    // start = 0 => paddingTop = 0
    if (!givenIndex) {
      return 0
    }

    // 列表项为动态高度,通过 sizes map 获取在start之前已经渲染的列表项的高度
    // 全部相加即为 paddingTop
    let offset = 0
    let indexSize = 0
    for (let index = 0; index < givenIndex; index++) {
      // this.__getIndexOffsetCalls++
      indexSize = this.sizes.get(this.param.uniqueIds[index])
      offset = offset + (typeof indexSize === 'number' ? indexSize : this.getEstimateSize())
    }

    return offset
  }

paddingBottom

固定高度

  • paddingBottom = 剩余的列表项的个数 * 列表项的高度

动态高度

  • paddingBottom = 剩余的列表项的个数 * 预估高度

4. VirtualList组件的渲染流程

  • VirtualList 组件对于列表项高度是否固定,不是通过传入某个属性来决定的,而是在渲染的过程中进行判断的
  • 所以接下来我们通过分析 VirtualList 组件的渲染流程来理解它是如何判断列表项的高度是固定还是动态的

4.1. created

1. 执行installVirtual方法

  1. 实例化Virtual类,得到Virtual实例「Virtual类包含了核心的数据和计算方法」

  2. 调用Virtual实例的 getRange 方法,得到初始化的 range 对象,range对象中的数据是在render中需要使用到的数据

{
    start: 0, // 要渲染的第一个列表项在列表数组中的索引
    end: 29, // 要渲染的最后一个列表项在列表数组中的索引
    padFront: 0, // wrap div 的 paddingTop
    padEnd: 剩余列表项个数*列表项预估高度  // wrap div 的 paddingBottom
}

2. 监听item_resize事件

  • 当 Item 组件大小变化时就会触发该事件,然后执行onItemResized方法

Item 组件是 vue-virtual-scroll-list 用来包裹 dataComponent 「用户传入的自定义列表项组件」的组件

onItemResized

  • 调用 Virtual 实例的saveSize方法,将列表项的 id 和 size 作为参数传入

saveSize(决定列表项高度为固定/动态)

  1. 将列表项的 id 和 size 的映射关系存储到一个map对象中
  2. 判断列表项的高度是否是动态高度
  3. 在处理第一个列表项的时候,默认是固定高度
  4. 从第二个列表项开始判断,如果之前是固定高度,但是当前列表项的高度跟之前的固定高度不同,则认为是动态高度

4.2. render

1. 渲染 root div

  • 监听其 scroll 事件

2. 渲染 wrap div

  • wrap div 为 root div的子节点
  • 它的 paddinggTop 和 paddingBottom 是根据 range 对象中的 padFront 和 padBehind 设置的

3. 渲染 keeps(30) 个 Item 组件

  • Item 组件是 wrap div 的子节点
  • 根据 range 对象中的 start 和 end,从列表项数组中截取出需要渲染的列表项,遍历渲染 Item组件,并将对应的数据对象作为参数传入

Item组件的执行流程

render
  1. 渲染 item div「列表项对象中的 id 就是作为 item div 的 key 值」

  2. 渲染用户传入的 dataComponent,作为 item div 的子节点

mounted
  1. 创建 ResizeObserver实例,赋值给 this.resizeObserver

  2. 调用 this.resizeObserver.observe 方法观察 item div 的大小变化,当大小变化时执行 dispatchSizeChange 方法

  3. 因为给 this.resizeObserver 赋值了,所以会触发组件更新,重新执行 render

render
  • 重新渲染 item div 和 dataComponent

  • 由于重新渲染导致 item div 发生了变化,所以 dispatchSizeChange 方法被触发

    • 触发父组件(VirtualList)的 item_resize 事件,并将列表项的 iditem div 的大小作为参数传入
updated

5. 懒加载+虚拟列表

  • 对于长列表来说,列表项多意味着接口需要返回的数据量大,这样就会导致首屏加载的时间长,用户体验差
  • 因此只通过虚拟列表来优化长列表并不是最优的方案
  • 对于请求数据过多的情况,我们可以通过懒加载来优化
  • 通过懒加载可以避免一次性请求所有的数据,而是分段请求,当下拉了指定个数才再次请求数据

类似以下案例的效果:

tangbc.github.io/vue-virtual…