该插件目的
当页面数据量较大,例如有几千条数据渲染的时候,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 | created () { |
原理
整个插件最主要的原理集中在updateVisibleItems(视图刷新函数),该函数会在初始化、页面滚动、页面resize等情况下触发。总共的过程分为以下四步:
计算可视范围:获取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
26sizes () {
// 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 []
}
- 获取当前展示部分的start、end值, 并判断是否进行了足够的滚动。滚动较小则可视区展示的items不变动,不需要刷新。
视图回收:遍历pool中视图,判断view的索引超出startIndex、endIndex范围,则走到unuseView函数进行视图回收,放到复用池unusedViews。(此时放到复用池只是放的引用,仍指向pool中对应的元素,不会改变pool元素个数,只改对应元素的属性
1 | if (this.$_continuous !== continuous) { |
以下为unuseView的实现:
1 | unuseView (view, fake = false) { |
- 更新视图:在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 | let item, type, unusedPool |
以下是addView的逻辑,复用池没有的时候走到addView新增视图:
1 | addView (pool, index, item, key, type) { |
- 排序视图:以上处理完成之后pool可能是无序的,因为存在复用池复用等情况,因此要进行排序,调用sortViews方法会根据pool中视图存的index值进行重排。
1 | clearTimeout(this.$_sortTimer) |
结语
该插件中pool
、$_unusedViews
、$_views
三者对应的处理是很值得学习的。$_unusedViews
的使用使得不比每次都去删减pool的数据达到渲染的目的,反观自己平时的开发,类似滚动、轮播等处理的方式,大概率直接截取源数据的某一范围给到pool达到刷新目的,效果是实现,但是有优化的空间。$_views
的使用、以及计算属性sizes的使用均是为了降低复杂度,对于我们动不动就遍历、findIndex等处理,这种预先存储进map、或者预先存储累加值的做法更为优雅,同时也大大的降低了复杂度,减少在每次刷新视图中的遍历逻辑。