vue-virtual-scroller源码分析

2,460 阅读4分钟

该插件目的

当页面数据量较大,例如有几千条数据渲染的时候,dom过多产生滚动卡顿的现象。此时使用该插件可以动态渲染可视区的dom,滚动时实时计算和变更可视区显示的数据。

原理

根据可视区的高度以及items中每一项的高度(itemSize,可为高度或者是横向滑动的宽度)来决定页面展示多少个item,能显示的item包装后放到了pool数组中进行渲染,页面滚动的时候动态的修改pool数组。为了在滚动的时候尽可能的减少开销,pool中超出范围的view会回收到复用池,pool中新增的view会优先从复用池中取出view,如果没有复用的才会新增。

页面中数据流动

为了达到动态渲染和dom复用的目的,主要维护了一下三个存放对应item的池子。

  • pool:当前页面显示得视图池,存储当前页面要渲染得数据,即pool是tempalte中渲染真实使用到的。
<div
    v-for="view of pool"
    :key="view.nr.id"
    :style="ready ? { transform: `translate${direction === 'vertical' ? 'Y' : 'X'}(${view.position}px)` } : null"
    class="vue-recycle-scroller__item-view"
    :class="{ hover: hoverKey === view.nr.key }"
    @mouseenter="hoverKey = view.nr.key"
    @mouseleave="hoverKey = null"
>
  • $_views: 和pool对应,每一次addView新增一个视图得时候,除了要把视图放到pool中,还要放一份到views中。只是views是map,数据字典方便查找view,当页面滚动得时候,会取范围在startIndex和endIndex之间得view,每个view先去views中找,这样比在pool中遍历效率要高,如果找到了说明当前view一直在可视区内,这个时候直接显示复用views中得即可。如果在views中没找到,说明是新增得view,则先去复用池中根据type找,找到则复用,找不到则addView新增,新增之后views中也要加进去。
  • $_unusedViews: 复用池,根据type存储不在可视区的视图。每次滚动先把超出可视区的丢到unusedViews,丢完之后。进行startIndex和endIndex之间的可视区遍历,在新增view出现的时候优先在unusedViews中找,找到就取出来。找不到则走addView

以下是初始化的时候对数据的初始化

created () {
    // 记录刷新完成的开始索引
    this.$_startIndex = 0
    // 记录刷新完成的结束索引
    this.$_endIndex = 0
    // 页面上所有展示的视图,与pool对应,方便快速查找
    this.$_views = new Map()
    // 复用池:根据视图的type暂存不在使用的view
    this.$_unusedViews = new Map()
    // 标记是否正在滚动,用于滚动节流
    this.$_scrollDirty = false
    // 记录上一次滚到了哪里start值
    this.$_lastUpdateScrollPosition = 0

    // In SSR mode, we also prerender the same number of item for the first render
    // to avoir mismatch between server and client templates
    if (this.prerender) {
      this.$_prerender = true
      this.updateVisibleItems(false)
    }
},

原理

整个插件最主要的原理集中在updateVisibleItems(视图刷新函数),该函数会在初始化、页面滚动、页面resize等情况下触发。总共的过程分为以下四步:

  1. 计算可视范围:获取scroll信息后,先算出此次需要展现到可视区的items索引范围,即startIndex和endIndex。

    • 获取当前展示部分的start、end值, 并判断是否进行了足够的滚动。滚动较小则可视区展示的items不变动,不需要刷新。
    // 获取当前可视区的范围,getScroll根据scrollerTop等计算
    const scroll = this.getScroll()
    
    // Skip update if use hasn't scrolled enough
        if (checkPositionDiff) {
          // 此处判断当前滚动的范围未超出设置的itemSize,即没有超过一个view,此时pool不需要改变,则此次不进行update操作
          let positionDiff = scroll.start - this.$_lastUpdateScrollPosition
          if (positionDiff < 0) positionDiff = -positionDiff
          if ((itemSize === null && positionDiff < minItemSize) || positionDiff < itemSize) {
            return {
              continuous: true,
            }
          }
        }
        // 刷新此次滚动后的位置信息
        this.$_lastUpdateScrollPosition = scroll.start
    
        // 计算偏移量,默认buffer为200,可自定义
        const buffer = this.buffer
        scroll.start -= buffer
        scroll.end += buffer
    
        // Variable size mode
        // 高度可变模式
        // 因为每个item的高度不固定,无法直接用scroll.start得到startIndex。所以通过二分法快速查找到第一个出现在可视区的视图,即startIndex。
        // 由于计算属性已缓存了可变高度的所有size记录,二分法查找的目的等价于查找到sizes中的索引,该索引满足index项的accumulator小于scroll.start,index+1项的accumulator大于scroll.start,则为刚滑到可视区的startIndex
        if (itemSize === null) {
          let h
          let a = 0
          let b = count - 1
          // 此处记录二分查找起始点
          let i = ~~(count / 2)
          let oldI
    
          // Searching for startIndex
          do {
            oldI = i
            h = sizes[i].accumulator
            if (h < scroll.start) {
              // 说明此次i取小了,则最小值设置为i
              a = i
            } else if (i < count - 1 && sizes[i + 1].accumulator > scroll.start) {
              // 说明i、i+1都超出了范围,则最大值设置为i,继续查找
              b = i
            }
            // 继续二分
            i = ~~((a + b) / 2)
          } while (i !== oldI)
          i < 0 && (i = 0)
          startIndex = i
    
          // For container style
          totalSize = sizes[count - 1].accumulator
    
          // Searching for endIndex
          // 找到刚好超出的endIndex
          for (endIndex = i; endIndex < count && sizes[endIndex].accumulator < scroll.end; endIndex++);
          if (endIndex === -1) {
            endIndex = items.length - 1
          } else {
            endIndex++
            // Bounds
            endIndex > count && (endIndex = count)
          }
        } else {
          // Fixed size mode
          // 固定高度:根据滚动的距离计算固定itemSize的startIndex和endIndex
          startIndex = ~~(scroll.start / itemSize)
          endIndex = Math.ceil(scroll.end / itemSize)
    
          // Bounds
          startIndex < 0 && (startIndex = 0)
          endIndex > count && (endIndex = count)
    
          totalSize = count * itemSize
        }
      }
    
      if (endIndex - startIndex > config.itemsLimit) {
        this.itemsLimitError()
      }
    
      // 刷新items的总高度, totalSize会给到外层盒子的高度,为了制造出滚动条
      this.totalSize = totalSize
    
    
    • 对于可变高度,计算属性会优先维护一个sizes表,已记录对应索引的size累计值。此操作目的是为了后续根据索引即可拿到size之和,而不必每次都重新计算。
    sizes () {
      // itemSize不提供,则进入variable size mode
      if (this.itemSize === null) {
        const sizes = {
          '-1': { accumulator: 0 },
        }
        const items = this.items
        const field = this.sizeField
        const minItemSize = this.minItemSize
        let computedMinSize = 10000
        let accumulator = 0
        let current
        for (let i = 0, l = items.length; i < l; i++) {
          current = items[i][field] || minItemSize
          if (current < computedMinSize) {
            computedMinSize = current
          }
          accumulator += current
          sizes[i] = { accumulator, size: current }
        }
        // eslint-disable-next-line
        this.$_computedMinItemSize = computedMinSize
        return sizes
      }
      return []
    }
    
  2. 视图回收:遍历pool中视图,判断view的索引超出startIndex、endIndex范围,则走到unuseView函数进行视图回收,放到复用池unusedViews。(此时放到复用池只是放的引用,仍指向pool中对应的元素,不会改变pool元素个数,只改对应元素的属性

if (this.$_continuous !== continuous) {
    if (continuous) {
        // 不是连续滑动,则页面出现了大的改变,初始化数据
        views.clear()
        unusedViews.clear()
        for (let i = 0, l = pool.length; i < l; i++) {
            // 将当前显示的view回收
            view = pool[i]
            this.unuseView(view)
        }
    }
    this.$_continuous = continuous
} else if (continuous) {
    // 此时为连续滑动,遍历回收pool
    for (let i = 0, l = pool.length; i < l; i++) {
        view = pool[i]
        if (view.nr.used) {
            // Update view item index
            if (checkItem) {
                view.nr.index = items.findIndex(
                item => keyField ? item[keyField] === view.item[keyField] : item === view.item,
                )
            }

            // Check if index is still in visible range
            // 此处判断如果,index已经超出范围,则进行回收
            if (
                view.nr.index === -1 ||
                view.nr.index < startIndex ||
                view.nr.index >= endIndex
            ) {
                this.unuseView(view)
            }
        }
    }
}

以下为unuseView的实现:

unuseView (view, fake = false) {
    // 根据view的类别放到缓存池
    const unusedViews = this.$_unusedViews
    const type = view.nr.type
    // 根据type类别进行存放,后续复用也是根据type去取
    let unusedPool = unusedViews.get(type)
    if (!unusedPool) {
        unusedPool = []
        unusedViews.set(type, unusedPool)
    }
    unusedPool.push(view)
    if (!fake) {
        // 此时将视图回收设置位置(让view不可见),且used置为false
        view.nr.used = false
        view.position = -9999
        this.$_views.delete(view.nr.key)
    }
}
  1. 更新视图:在startIndex和endIndex之间遍历,每次拿到items中的一个item,开始包装item后刷到pool中。
    • 根据item取views字典中查找,如果找到了,则当前view还在可视区,只是滚动了,则直接复用view即可。
    • 在views中未找到,则去unusedViews中找有没有可复用的view,有则使用复用视图,修改view的item、key、index等属性后即可。且后面重新设置views中对应字典,方便后面查找。
    • 如果unusedViews中未找到,则无复用view。此时调用addView新增视图,view增加item属性关联到items、position属性后面用于transform样式、增加used、key、id、index等标识。新增视图push到pool中,同时在views中增加字典。
let item, type, unusedPool
let v
// 在可视区范围内遍历
for (let i = startIndex; i < endIndex; i++) {
  item = items[i]
  const key = keyField ? item[keyField] : item
  if (key == null) {
    throw new Error(`Key is ${key} on item (keyField is '${keyField}')`)
  }
  // 3.1 根据item取views字典中查找,如果找到了,则当前view还在可视区,只是滚动了,则直接复用view即可。
  view = views.get(key)

  // 此处size不存在,则高度不存在,则不加到pool,因为显示不出来
  if (!itemSize && !sizes[i].size) {
    if (view) this.unuseView(view)
    continue
  }

  // No view assigned to item
  // 3.2 在views中未找到,则去unusedViews中找有没有可复用的view,有则使用复用视图,修改view的item、key、index等属性后即可。且后面重新设置views中对应字典,方便后面查找。
  if (!view) {
    type = item[typeField]
    unusedPool = unusedViews.get(type)

    if (continuous) {
      // Reuse existing view
      // 根据类型找出复用池中可用的的视图,修改索引等进行复用
      if (unusedPool && unusedPool.length) {
        view = unusedPool.pop()
        view.item = item
        view.nr.used = true
        view.nr.index = i
        view.nr.key = key
        view.nr.type = type
      } else {
        // 复用池中不存在则新增
        // 3.3 如果unusedViews中未找到,则无复用view。此时调用addView新增视图,view增加item属性关联到items、position属性后面用于transform样式、增加used、key、id、index等标识。新增视图push到pool中,同时在views中增加字典。
        view = this.addView(pool, i, item, key, type)
      }
    } else {
      // Use existing view
      // We don't care if they are already used
      // because we are not in continous scrolling
      // 因为不是连续滑动,无交叉,不用考虑使用占用的问题,直接从对应复用池中的第一个开始找
      v = unusedIndex.get(type) || 0

      if (!unusedPool || v >= unusedPool.length) {
        view = this.addView(pool, i, item, key, type)
        this.unuseView(view, true)
        unusedPool = unusedViews.get(type)
      }

      view = unusedPool[v]
      view.item = item
      view.nr.used = true
      view.nr.index = i
      view.nr.key = key
      view.nr.type = type
      unusedIndex.set(type, v + 1)
      v++
    }
    // 放到views池中,此处对应字典,方便后续查找
    views.set(key, view)
  } else {
    // 当前视图中已经存在,则直接重新used即可
    view.nr.used = true
    view.item = item
  }

  // Update position
  // 刷新视图位置
  if (itemSize === null) {
    view.position = sizes[i - 1].accumulator
  } else {
    view.position = i * itemSize
  }
}

// 记录本地的索引
this.$_startIndex = startIndex
this.$_endIndex = endIndex

以下是addView的逻辑,复用池没有的时候走到addView新增视图:

addView (pool, index, item, key, type) {
const view = {
  item,
  position: 0,
}
const nonReactive = {
  id: uid++,
  // 此处的index对应传进来的源数据中的索引,方便后续视图复用后重新排序
  index,
  used: true,
  key,
  type,
}
Object.defineProperty(view, 'nr', {
  configurable: false,
  value: nonReactive,
})
// 新增视图放到pool当中
pool.push(view)
return view
}
  1. 排序视图:以上处理完成之后pool可能是无序的,因为存在复用池复用等情况,因此要进行排序,调用sortViews方法会根据pool中视图存的index值进行重排。
clearTimeout(this.$_sortTimer)
this.$_sortTimer = setTimeout(this.sortViews, 300)

// sortViews的实现
sortViews () {
  this.pool.sort((viewA, viewB) => viewA.nr.index - viewB.nr.index)
}

结语

该插件中pool$_unusedViews$_views三者对应的处理是很值得学习的。 $_unusedViews的使用使得不比每次都去删减pool的数据达到渲染的目的,反观自己平时的开发,类似滚动、轮播等处理的方式,大概率直接截取源数据的某一范围给到pool达到刷新目的,效果是实现,但是有优化的空间。 $_views的使用、以及计算属性sizes的使用均是为了降低复杂度,对于我们动不动就遍历、findIndex等处理,这种预先存储进map、或者预先存储累加值的做法更为优雅,同时也大大的降低了复杂度,减少在每次刷新视图中的遍历逻辑。