vue-virtual-scroller源码分析
Star Sea

该插件目的

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

原理

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

页面中数据流动

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

  • pool:当前页面显示得视图池,存储当前页面要渲染得数据,即pool是tempalte中渲染真实使用到的。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    <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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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不变动,不需要刷新。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
      74
      75
      76
      77
      78
      79
      80
      81
      82
      83
      84
      85
      // 获取当前可视区的范围,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之和,而不必每次都重新计算。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      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元素个数,只改对应元素的属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
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的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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中增加字典。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
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新增视图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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值进行重排。
1
2
3
4
5
6
7
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、或者预先存储累加值的做法更为优雅,同时也大大的降低了复杂度,减少在每次刷新视图中的遍历逻辑。