vue-virtual-scroller
提供了以下几个组件:
RecycleScroller
是一个仅呈现列表中可见项目的组件。 它还重用组件和 dom 元素,以尽可能提高效率和性能。
DynamicScroller
是一个包装 RecycleScroller
组件实现的,用于在列表项的大小不是固定的场景,在事先不知道列表项的大小的情况,该组件可以在列表滚动期间计算出列表项的大小。
DynamicScrollerItem
必须包装在 DynamicScroller 中使用以处理列表项大小计算。
IdState
是一个 mixin,可以简化 RecycleScroller
内重用组件的本地状态管理。
RecycleScroller 组件
RecycleScroller
是一个虚拟滚动组件,仅渲染可见项。 当用户滚动时,RecycleScroller
会重用所有组件和 DOM 节点以保持最佳性能。
基本使用
使用作用域插槽来渲染列表中的每一项
html
<template>
<RecycleScroller
class="scroller"
:items="list"
:item-size="32"
key-field="id"
v-slot="{ item }"
>
<div class="user">
{{ item.name }}
</div>
</RecycleScroller>
</template>
<script>
export default {
props: {
list: Array,
},
}
</script>
<style scoped>
.scroller {
height: 100%;
}
.user {
height: 32%;
padding: 0 12px;
display: flex;
align-items: center;
}
</style>
注意事项
-
⚠️ 您需要设置 virtual-scroller 元素和 items 元素的大小(可以使用 css 来设置)。 除非您使用可变大小模式,否则所有列表项都应具有相同的高度(或水平模式下的宽度)以防止样式异常。
-
⚠️如果 item 是对象,则 RecycleScroller 组件需要能够识别它们。 默认情况下,它将在 item 上查找 id 字段。 如果您使用其他字段名称,可以使用 keyField 属性进行配置。
-
在 RecycleScroller 中不建议使用函数式组件,因为组件会被重用,反而会变慢。
- Tips:在 RecycleScroller 中,组件会被重用,意味着每次滚动时,已经渲染过的组件会被复用,并且只会更新其内容。如果在 RecycleScroller 中使用函数式组件,每次组件被复用时,由于函数式组件是无状态的,其内部状态都会被重置为初始状态。这就导致了每次复用组件时都需要重新计算和管理组件的内部状态,从而增加了计算和更新的开销,使渲染变慢。因此,不建议在 RecycleScroller 中使用函数式组件。
-
列表项组件对
item
prop 的更新要是响应式的,即在不重新创建列表项组件的情况下进行更新(可以使用计算属性和侦听器来实现对 prop 的响应式更新) -
你不需要在列表内容上设置key属性(但是你应该在所有嵌套的元素上设置key属性,以防止加载时出现闪烁问题)。
-
在 Vue 中,使用 key 属性可以帮助 Vue 识别元素的身份,从而更好地进行渲染和更新。但是,在使用 vue-virtual-scroller 插件时,由于它会根据实际可见的列表项来动态渲染,并且会进行滚动和重用,因此不需要给列表项设置 key 属性。
-
然而,对于嵌套在列表项中的元素,由于它们可能在滚动过程中被动态加载,取消绑定和重新加载,需要设置key属性可以保证元素正确更新,以防止加载时出现闪烁的问题。
-
-
浏览器对 DOM 元素有大小限制,这意味着当前虚拟滚动条无法显示超过 500k 的列表项,具体取决于浏览器。
-
由于列表项的 DOM 元素被重用,建议使用提供的hover类来定义悬停样式,而不是使用:hover状态选择器(例如,.vue-recycle-scroller__item-view.hover或.hover .some-element-inside-the-item-view)。
它是如何工作的
-
RecycleScroller创建了一个视图池(pools of views),用于将可见的列表项渲染给用户。这个池中的视图会被重复使用。
-
视图(view)持有已渲染的列表项,并在它所在的视图池内被重用。也就是说,当一个列表项离开可见区域时,其视图会被重新利用。
-
对于每种类型的列表项,都会创建一个新的视图池,以确保相同的组件和 DOM 树被用于相同类型的列表项。这样做的目的是为了提高性能,避免重复渲染相同类型的列表项。
-
视图可以在其离开可视区范围时被停用,并随时可以被重新使用来展示新的可见的列表项。这一机制有助于减少 DOM 操作和渲染的开销
总的来说,RecycleScroller 通过创建视图池,并在内部对视图进行重用和动态激活/停用,来提高在滚动时渲染项目的效率,从而优化前端性能。
以下是垂直模式下 RecycleScroller 的内部结构:
html
<RecycleScroller>
<!-- Wrapper element with a pre-calculated total height -->
<wrapper
:style="{ height: computedTotalHeight + 'px' }"
>
<!-- Each view is translated to the computed position -->
<view
v-for="view of pool"
:style="{ transform: 'translateY(' + view.computedTop + 'px)' }"
>
<!-- Your elements will be rendered here -->
<slot
:item="view.item"
:index="view.nr.index"
:active="view.nr.used"
/>
</view>
</wrapper>
</RecycleScroller>
当用户在 RecycleScroller 内滚动时,视图主要只是被移动以填充新的可见空间,并更新默认插槽属性。通过这种方式,我们最大程度地减少了组件/元素的创建和销毁,并且利用了 Vue 虚拟 DOM算法的全部优势来优化 DOM
Props
-
items
,指定要在滚动区域中显示的项目列表。 -
direction
,滚动的方向,可选值为vertical
、horizontal
,默认值为vertical
-
itemSize
(默认值:null
),项目的展示高度(或水平模式下的宽度),以像素为单位,用于计算滚动区域的大小和位置。如果将其设置为null
(默认值),则将使用可变大小模式。 -
gridItems
,在同一行上显示gridItems
项,创建一个网格布局。您必须为 itemSize 属性设置一个值来使用此属性(不支持动态大小)。 -
itemSecondarySize
: 当gridItems
被设置时,网格中项目的像素大小(在纵向模式下为宽度,在横向模式下为高度)。如果未设置itemSecondarySize
,则将使用itemSize
的值。 -
minItemSize
: 如果项目的高度(或横向模式下的宽度)未知,则使用的最小尺寸。 -
sizeField
(默认为'size'
):在可变尺寸模式下用于获取项目尺寸的字段。 -
typeField
(默认为'type'
):用于区分列表中不同类型组件的字段。对于每种不同的类型,将创建一组可重用的项目的资源池。 -
keyField
(默认为'id'
):用于标识项目并优化管理已渲染视图的字段。- 简单来说,
keyField
是用于标识项目并优化已渲染视图管理的字段。在渲染列表时,每个项目都需要一个唯一标识符,以便能够准确地跟踪和管理它们。默认情况下,keyField
的值为'id'
,意味着每个项目都应具有一个名为'id'
的属性作为其唯一标识符。通过使用正确的唯一标识符,keyField
可以帮助优化更新和渲染已经呈现的视图,提高性能和效率。
- 简单来说,
-
pageMode
(默认为false
): 启用页面模式。 -
prerender
(默认值为0
):为服务器端渲染(SSR)预渲染固定数量的项目。 -
buffer
(默认值 200):在滚动可见区域的边缘增加的像素量,以提前渲染列表项。 -
emitUpdate
(默认值false
): 每当虚拟滚动条内容更新时发出update
事件(可能会影响性能)。 -
listClass
(默认值:''
):添加到项目列表包装器的自定义类。 -
itemClass
(默认值:''
):添加到每个列表项的自定义类。 -
listTag
(默认值:div
):列表包装元素的 html 标签 -
itemTag
(默认值:div
):列表项的 html 标签(默认插槽内容的直接父级)
Events
-
resize
:当虚拟列表组件的大小发生改变时触发的事件。 -
visible
:当虚拟列表组件可见时触发的事件 -
hidden
:当虚拟列表组件隐藏时触发的事件 -
update (startIndex, endIndex, visibleStartIndex, visibleEndIndex)
:仅当emitUpdate
属性为true
时生效,每次视图更新时会触发的事件 -
scroll-start
:当第一个列表项渲染时触发的事件 -
scroll-end
:当最后一个列表项渲染时触发的事件
默认作用域插槽的属性
-
item
:在视图中渲染的列表项。 -
index
:每个列表项在items
数组中的位置。 -
active
:视图是否处于激活状态。激活状态的视图被认为是可见的,并且由 RecycleScroller 进行定位。非激活状态的视图被认为是不可见的,并且对用户隐藏。如果视图处于非激活状态,则应跳过任何与渲染相关的计算。
其它插槽
html
<main>
<slot name="before"></slot>
<wrapper>
<!-- Reused view pools here -->
<slot name="empty"></slot>
</wrapper>
<slot name="after"></slot>
</main>
例子:
html
<RecycleScroller
class="scroller"
:items="list"
:item-size="32"
>
<template #before>
Hey! I'm a message displayed before the items!
</template>
<template v-slot="{ item }">
<div class="user">
{{ item.name }}
</div>
</template>
</RecycleScroller>
页面模式
页面模式扩展 virtual-scroller 并使用页面视口来计算哪些项目是可见的。 这样,您就可以在前面或后面带有 HTML 元素(例如页眉和页脚)的大页面中使用它。 将 page-mode
属性设置为 true
:
html
<header>
<menu></menu>
</header>
<RecycleScroller page-mode>
<!-- ... -->
</RecycleScroller>
<footer>
Copyright 2017 - Cat
</footer>
可变大小模式
⚠️对于很大数据量的场景,这种模式性能可能很差,谨慎使用
如果未设置 itemSize
属性或将其设置为null
,虚拟滚动器将切换到可变大小模式。然后,您需要在 item 对象上公开一个 number 类型的字段,该字段记录 item 元素的大小。
⚠️您仍然需要使用 CSS 正确设置项目的大小(例如使用样式类)。
使用 sizeField
属性(默认为 'size'
)来设置滚动器使用的字段,该字段用于获取每个项目的大小。
例如:
javascript
const items = [
{
id: 1,
label: 'Title',
size: 64,
},
{
id: 2,
label: 'Foo',
size: 32,
},
{
id: 3,
label: 'Bar',
size: 32,
},
]
Buffer
您可以在虚拟滚动器上设置 buffer
属性(以像素为单位),扩展可视区的范围。例如,如果您设置了1000 像素的缓冲区,虚拟滚动器将开始渲染距离滚动器可见区域底部 1000 像素以下的项目,并保留距离可见区域顶部 1000 像素以上的项目。
默认值为 200
html
<RecycleScroller :buffer="200" />
服务端渲染
prerender
属性可以在虚拟滚动器内设置为服务器渲染的项目数量:
html
<RecycleScroller
:items="items"
:item-size="42"
:prerender="10"
>
DynamicScroller 组件
这与RecycleScroller的工作原理相同,但它可以渲染具有未知大小的列表项!
基本使用
html
<template>
<DynamicScroller
:items="items"
:min-item-size="54"
class="scroller"
>
<template v-slot="{ item, index, active }">
<DynamicScrollerItem
:item="item"
:active="active"
:size-dependencies="[
item.message,
]"
:data-index="index"
>
<div class="avatar">
<img
:src="item.avatar"
:key="item.avatar"
alt="avatar"
class="image"
>
</div>
<div class="text">{{ item.message }}</div>
</DynamicScrollerItem>
</template>
</DynamicScroller>
</template>
<script>
export default {
props: {
items: Array,
},
}
</script>
<style scoped>
.scroller {
height: 100%;
}
</style>
注意事项
-
minItemSize
是必需的,用于初始渲染项目。 -
DynamicScroller
无法自行检测列表项大小的变化,但是您可以通过在 DynamicScrollerItem 上添加size-dependencies
的值来实现。 -
您不需要在列表项中设置
size
字段
Props
继承所有 RecycleScroller
组件的 props
- 不建议更改
sizeField
属性,因为所有大小管理都是在组件内部完成的。
Events
继承所有 RecycleScroller
组件的事件
默认作用域插槽
继承所有 RecycleScroller
默认作用域插槽
其它插槽
继承所有 RecycleScroller
组件的其它插槽
DynamicScrollerItem 组件
该组件应该包裹在 DynamicScroller 中。
Props
-
item
(必填项):在滚动器中渲染的数据项。 -
active
(必填项):在 RecycleScroller 中是否将保持的视图设置为激活状态。这将防止不必要的列表项的大小的重新计算。 -
sizeDependencies
:可以影响列表项的尺寸的值。该属性将被监听,如果其中一个值发生变化,尺寸将重新计算。推荐使用sizeDependencies
而不是watchData
。 -
watchData
(默认值:false
):深度监听 item 的变化,以重新计算尺寸(不推荐,可能会影响性能)。 -
tag
(默认值:'div'
):用于指定渲染元素的 html 标签。 -
emitResize
(默认值:false
):每次尺寸重新计算时发出resize
事件(可能会影响性能)。
Events
resize
:仅当emitResize
属性为true
时,每次尺寸重新计算时发出的事件。
IdState
这是一个方便的 mixin,可以在 RecycleScroller 中渲染的组件中替换 data
。
为什么这有用?
由于 RecycleScroller 中的组件是被重复使用的,你不能直接使用 Vue 的标准 data
属性,否则它们将与列表中的不同项目共享! 相反,IdState
会提供一个 idState
对象,它等效于 $data
,但它与具有唯一标识符的单个项目相关联(你可以使用 idProp
参数更改哪个字段)。
例子
在这个例子中,我们使用 item
的 id
来为每个 item
创建一个具有"作用域"的状态。
html
<template>
<div class="question">
<p>{{ item.question }}</p>
<button @click="idState.replyOpen = !idState.replyOpen">Reply</button>
<textarea
v-if="idState.replyOpen"
v-model="idState.replyText"
placeholder="Type your reply"
/>
</div>
</template>
<script>
import { IdState } from 'vue-virtual-scroller'
export default {
mixins: [
IdState({
// You can customize this
idProp: vm => vm.item.id,
}),
],
props: {
// Item in the list
item: Object,
},
// This replaces data () { ... }
idState () {
return {
replyOpen: false,
replyText: '',
}
},
}
</script>
参数
idProp
(默认值:vm => vm.item.id
):组件上的字段名称(例如:'id'
)或返回 id 的函数。
vue-virtual-scroller 源码分析
components\RecycleScroller.vue 文件分析
html
<template>
<div v-observe-visibility="handleVisibilityChange">
</div>
</template>
v-observe-visibility
是能够检测页面元素是否可见的自定义指令,是 vue-observe-visibility 包提供的能力。
html
<template>
<ResizeObserver @notify="handleResize" />
</template>
ResizeObserver 组件是由 vue-resize 包提供的,可以用于监测 dom 元素大小调整,当 dom 元素的大小发生了变化后,该组件会触发 notify
事件。更多可以参考 vue-resize深度解读 、vue-resize。
html
<template>
<!-- 滚动事件的默认行为 (scrolling) 将立即发生而非等待 `onScroll` 完成 -->
<!-- 以防其中包含 `event.preventDefault()` -->
<div
@scroll.passive="handleScroll"
>
<!-- 代码省略 -->
</div>
</template>
.passive
,修饰符一般用于触摸事件的监听器,可以用来改善移动端设备的滚屏性能。参考 事件修饰符
$slots
,表示父组件传入插槽的对象。可用于检测是否存在插槽,为 Vue 组件提供了获取插槽内容的能力。组件实例 | Vue.js
html
<div
v-if="$slots.before"
ref="before"
class="vue-recycle-scroller__slot"
>
<slot
name="before"
/>
</div>
用于放置在列表前展示的内容的具名插槽
html
<div
v-if="$slots.after"
ref="after"
class="vue-recycle-scroller__slot"
>
<slot
name="after"
/>
</div>
用于放置在列表后展示的内容的具名插槽
html
<component
>
<!-- 代码省略 -->
<slot
name="empty"
/>
</component>
用于放置空状态占位的具名插槽
整个 vue-virtual-scroller
组件的主要原理集中在 updateVisibleItems
方法,该方法会在 items
、pageMode
、sizes
、gridItems
、itemSecondarySize
变化时调用。
js
watch: {
items () {
this.updateVisibleItems(true)
},
pageMode () {
this.applyPageMode()
this.updateVisibleItems(false)
},
sizes: {
handler () {
this.updateVisibleItems(false)
},
deep: true,
},
gridItems () {
this.updateVisibleItems(true)
},
itemSecondarySize () {
this.updateVisibleItems(true)
},
},
同时 updateVisibleItems
方法也会在调整页面大小、滚动、页面初始化(mounted
或 created
生命周期)中调用。
js
created () {
// 省略部分代码
this.updateVisibleItems(false)
}
js
mounted () {
// 省略部分代码
this.updateVisibleItems(true)
}
js
handleResize () {
this.$emit('resize')
if (this.ready) this.updateVisibleItems(false)
}
js
handleScroll (event) {
// 省略代码
const { continuous } = this.updateVisibleItems(false, true)
}
updateVisibleItems
整体分析(忽略细节)
-
初始化相关变量
-
根据滚动区域的高(宽)度,计算需要展示列表的区间,即计算出可视区的起始索引(
startIndex
)和终止索引(endIndex
) -
将
startIndex
与endIndex
之间的数据更新到pool
数组
updateVisibleItems
整体思路拆解:
关键在于如何根据可视区域的高度和各项列表的高度,快速(高性能)地计算出需要在可视区展示的开始索引和结束索引。
RecycleScroller
组件渲染的列表数据是 pool
数组
html
<ItemView
v-for="view of pool"
ref="items"
:key="view.nr.id"
:view="view"
:item-tag="itemTag"
>
<!-- 忽略一些代码 -->
</ItemView>
pool
数组的长度是根据滚动区域的大小计算的,当虚拟列表可视区占满了列表项后,pool
数组的大小就不会改变了
一旦 pool
的长度被塞满后,里面的每一项的数据的引用都不会改变,改变的只是每个数据项中的 item
、nr.index
等属性的值
js
if (view) {
view.item = item
view.nr.index = i
view.nr.key = key
if (view.nr.type !== type) {
console.warn("Reused view's type does not match pool's type")
}
}
pool
中的每一项数据都可以在 views
($_views
) 中找到与之对应的数据,并且它们的引用相同,因此改动其中一项,另外一项也会跟着改变
js
createView (pool, index, item, key, type) {
const nr = markRaw({
id: uid++,
index,
used: true,
key,
type,
})
const view = shallowReactive({
item,
position: 0,
nr,
})
// 将 view push 进 pool
pool.push(view)
return view
}
// 省略了细节代码
view = views.get(key)
view = this.createView(pool, i, item, key, type)
// 将 view set 进 views
views.set(key, view)
$_recycledPools
是复用池,根据 type 存储不在可视区的视图。每次滚动先把超出可视区的丢到 $_recycledPools
,丢完之后。进行startIndex 和 endIndex 之间的可视区遍历,在新增 view 出现的时候优先在 $_recycledPools
中找,找到就取出来。找不到则走 createView
js
// 以下代码均省略代码细节
removeAndRecycleView (view) {
const type = view.nr.type
const recycledPool = this.getRecycledPool(type)
recycledPool.push(view)
// 将 view 设置为不可见的
view.nr.used = false
view.position = -9999
this.$_views.delete(view.nr.key)
}
for (let i = 0, l = pool.length; i < l; i++) {
// 视图回收
view = pool[i]
if (view.nr.used) {
const viewVisible = view.nr.index >= startIndex && view.nr.index < endIndex
const viewSize = itemSize || sizes[i].size
if (!viewVisible || !viewSize) {
this.removeAndRecycleView(view)
}
}
}
for (let i = startIndex; i < endIndex; i++) {
view = this.getRecycledView(type)
if (!view) {
view = this.getRecycledView(type)
if (view) {
view.item = item
} else {
view = this.createView(pool, i, item, key, type)
}
}
}
createView (pool, index, item, key, type) {
const nr = markRaw({
id: uid++,
index,
used: true,
key,
type,
})
const view = shallowReactive({
item,
position: 0,
nr,
})
pool.push(view)
return view
}
为了提升计算的性能,提前在计算属性 sizes
维护了记录了高度与累计高度的数组,避免了后续的重复计算
js
sizes () {
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 []
}
相关变量初始化:
ts
updateVisibleItems (itemsChanged, checkPositionDiff = false) {
// 列表项的高/宽度
const itemSize = this.itemSize
// 在同一行上显示 gridItems 项,创建一个网格布局
const gridItems = this.gridItems || 1
// 当 gridItems 被设置时,网格中项目的像素大小(在纵向模式下为宽度,在横向模式下为高度)。如果未设置 itemSecondarySize ,则将使用 itemSize 的值。
const itemSecondarySize = this.itemSecondarySize || itemSize
// 项目的最小尺寸
const minItemSize = this.$_computedMinItemSize
// 用于区分列表中不同类型组件的字段。对于每种不同的类型,将创建一组可重用的项目的资源池
const typeField = this.typeField
// 如果是简单数据 keyField 设置为 null ,否则取用户传入的 keyField
const keyField = this.simpleArray ? null : this.keyField
const items = this.items
}
minItemSize
为项目的最小尺寸,在 computed 属性 sizes
中计算得出。
在计算属性 sizes
中,会遍历需要渲染的数组 items
,然后从数组项中取出最小的尺寸赋值给 $_computedMinItemSize
js
sizes () {
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 []
},
js
// 获取当前可视区的范围,getScroll 根据 scrollerTop 等计算
const scroll = this.getScroll()
-
shallowReactive()
,可以创建一个浅层响应式对象。它只会对根级别的属性进行响应式转换,不会对嵌套属性进行递归转换。这样可以减少依赖追踪和触发响应的开销。在某些场景下,我们可能不需要对所有属性进行深层次的响应式处理,使用shallowReactive
可以提升性能。 响应式 API:进阶 | Vue.js -
markRaw()
,将一个对象标记为不可被转为代理。返回该对象本身。响应式 API:进阶 | Vue.js- 当呈现带有不可变数据源的大型列表时,跳过代理转换可以提高性能。
这两个函数可以在某些特定场景下提供更细粒度的控制,并且帮助我们在性能和功能上做出更好的权衡。
js
import { shallowReactive, markRaw } from 'vue'
createView (pool, index, item, key, type) {
const nr = markRaw({
id: uid++,
index,
used: true,
key,
type,
})
const view = shallowReactive({
item,
position: 0,
nr,
})
pool.push(view)
return view
}
startIndex
、endIndex
的计算分为两种情况
-
动态大小模式(Variable size mode,列表项的高/宽度不是固定的)
-
固定大小模式(Fixed size mode,列表项的高/宽度是固定的)
在动态大小模式的情况下,为了快速找到 startIndex
、endIndex
使用了二分查找算法
js
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) {
a = i
} else if (i < count - 1 && sizes[i + 1].accumulator > scroll.start) {
b = i
}
i = ~~((a + b) / 2)
} while (i !== oldI)
i < 0 && (i = 0)
startIndex = i
固定大小模式的场景计算 startIndex
、endIndex
较为简单,简单地说就是滚动区域的高/宽度除以每一个列表项的高/宽度。
js
startIndex = ~~(scroll.start / itemSize * gridItems)
const remainer = startIndex % gridItems
startIndex -= remainer
endIndex = Math.ceil(scroll.end / itemSize * gridItems)
applyPageMode
方法分析:
applyPageMode
主要是调用了两个函数 addListeners
、removeListeners
js
applyPageMode () {
if (this.pageMode) {
this.addListeners()
} else {
this.removeListeners()
}
}
removeListeners
取消事件监听和删除相关 dom ,防止内存泄漏
js
removeListeners () {
if (!this.listenerTarget) {
return
}
this.listenerTarget.removeEventListener('scroll', this.handleScroll)
this.listenerTarget.removeEventListener('resize', this.handleResize)
this.listenerTarget = null
}
如果是页面模式,则会相应添加页面的事件监听:
js
addListeners () {
this.listenerTarget = this.getListenerTarget()
this.listenerTarget.addEventListener('scroll', this.handleScroll, supportsPassive
? {
passive: true,
}
: false)
this.listenerTarget.addEventListener('resize', this.handleResize)
},
其中 getListenerTarget
函数用于获取监听的 dom 对象,其中的核心,则是 getScrollParent
函数。
js
getListenerTarget () {
let target = getScrollParent(this.$el)
// Fix global scroll target for Chrome and Safari
if (window.document && (target === window.document.documentElement || target === window.document.body)) {
target = window
}
return target
}
$el
指向 Vue 组件实例的 DOM 根节点。可以使用 $el
属性来添加/删除类名、修改元素的文本内容、设置样式等。
$el
属性只有在 Vue 实例真正挂载到 DOM 元素后才会被设置。因此,如果在 Vue 实例挂载之前访问 $el
属性,将会返回undefined
。 组件实例 | Vue.js
getScrollParent
函数的主要作用则是找到列表的第一个有滚动条的父 DOM 元素,如果没有则是 document 节点,即 html
元素。由于 getScrollParent
是找到第一个有 scroll 样式的父元素,因此在 page-mode 模式下,虚拟列表组件的父元素尽量不要手动设置任何 overflow 样式,否则会出现列表渲染异常或者无法出现页面级别的滚动条
js
export function getScrollParent (node) {
if (!(node instanceof HTMLElement || node instanceof SVGElement)) {
return
}
const ps = parents(node.parentNode, [])
for (let i = 0; i < ps.length; i += 1) {
if (scroll(ps[i])) {
return ps[i]
}
}
return document.scrollingElement || document.documentElement
}
判断元素是否有滚动条主要是使用以下函数配合
js
// 递归找到所有的父 DOM 元素
function parents (node, ps) {
if (node.parentNode === null) { return ps }
return parents(node.parentNode, ps.concat([node]))
}
// 获取 node 相关样式中的 prop 属性值
const style = function (node, prop) {
return getComputedStyle(node, null).getPropertyValue(prop)
}
// 获取 overflow 、overflow-y、overflow-x 的属性值
const overflow = function (node) {
return style(node, 'overflow') + style(node, 'overflow-y') + style(node, 'overflow-x')
}
const regex = /(auto|scroll)/
// 判断是否有滚动属性
const scroll = function (node) {
return regex.test(overflow(node))
}
components\ItemView.vue 文件分析
ItemView
组件的功能较简单,使用作用域插槽,将数据传递给父组件
html
<template>
<component
:is="itemTag"
class="vue-recycle-scroller__item-view"
>
<slot
:item="view.item"
:index="view.nr.index"
:active="view.nr.used"
/>
</component>
</template>
<script>
export default {
props: {
view: Object,
itemTag: String,
},
}
</script>
components\DynamicScroller.vue 与 components\DynamicScrollerItem.vue文件分析
DynamicScroller
可以渲染列表项高/宽度未知的情况。同时由于无法提前知道列表项的高/宽度,会出现渲染列表的时候一开始列表项没有高/宽度,等列表项有内容的时候才有高/宽度而造成的页面闪烁的情况。
DynamicScroller
组件是通过封装 RecycleScroller
组件得来
html
<template>
<RecycleScroller
ref="scroller"
:items="itemsWithSize"
:min-item-size="minItemSize"
:direction="direction"
key-field="id"
:list-tag="listTag"
:item-tag="itemTag"
v-bind="$attrs"
@resize="onScrollerResize"
@visible="onScrollerVisible"
>
<template #default="{ item: itemWithSize, index, active }">
<slot
v-bind="{
item: itemWithSize.item,
index,
active,
itemWithSize
}"
/>
</template>
</RecycleScroller>
</template>
keyField
此处写死为 id
。keyField
用于标识项目并优化管理已渲染视图的字段。
DynamicScroller
组件渲染的列表数据是其中的计算属性 itemsWithSize
,其返回值 result
中的 size
由 DynamicScrollerItem
组件内部计算得出。
js
itemsWithSize () {
const result = []
const { items, keyField, simpleArray } = this
const sizes = this.vscrollData.sizes
const l = items.length
for (let i = 0; i < l; i++) {
const item = items[i]
const id = simpleArray ? i : item[keyField]
let size = sizes[id]
if (typeof size === 'undefined' && !this.$_undefinedMap[id]) {
size = 0
}
result.push({
item,
id,
size,
})
}
return result
}
updateSize()
计算列表项 size 的入口函数
js
mounted() {
this.updateSize()
}
updateSize () {
if (this.finalActive) {
if (this.$_pendingSizeUpdate !== this.id) {
this.computeSize(this.id)
}
}
}
computeSize()
获取列表项的 offsetWidth
、offsetHeight
js
computeSize (id) {
this.$nextTick(() => {
if (this.id === id) {
const width = this.$el.offsetWidth
const height = this.$el.offsetHeight
this.applyWidthHeight(width, height)
}
})
}
applyWidthHeight()
区分水平、垂直模式
js
applyWidthHeight (width, height) {
const size = ~~(this.vscrollParent.direction === 'vertical' ? height : width)
if (size && this.size !== size) {
this.applySize(size)
}
}
applySize()
将 size 传递给父组件的 vscrollData
js
applySize (size) {
this.vscrollData.sizes[this.id] = size
}
综合上面 4 个函数分析可知,DynamicScrollerItem
计算列表项 size 的方法就是等内容充满列表项的 dom 元素后,读取其 offsetWidth
、offsetHeight
,然后赋值给 vscrollData
计算属性 finalActive
,用于判断 DynamicScrollerItem 、DynamicScroller 组件是否已挂载
js
finalActive () {
return this.active && this.vscrollData.active
}
DynamicScroller、DynamicScrollerItem 通过 provide/inject 进行通信。
$_resizeObserver
是 ResizeObserver 对象,通过 provide 传递给 DynamicScrollerItem。在 DynamicScroller 中通过该 ResizeObserver 对象监听 DynamicScrollerItem 大小的变化,当 DynamicScrollerItem 大小变化后,通过调用 $_vs_onResize
方法,将最新的 size 保存到 vscrollData 中。
inlineSize
,简单地说就是 dom 的宽度;blockSize
,简单地说就是 dom 的高度。ResizeObserverEntry.contentBoxSize
js
provide () {
if (typeof ResizeObserver !== 'undefined') {
this.$_resizeObserver = new ResizeObserver(entries => {
requestAnimationFrame(() => {
if (!Array.isArray(entries)) {
return
}
for (const entry of entries) {
if (entry.target && entry.target.$_vs_onResize) {
let width, height
if (entry.borderBoxSize) {
const resizeObserverSize = entry.borderBoxSize[0]
width = resizeObserverSize.inlineSize
height = resizeObserverSize.blockSize
} else {
// @TODO remove when contentRect is deprecated
width = entry.contentRect.width
height = entry.contentRect.height
}
entry.target.$_vs_onResize(entry.target.$_vs_id, width, height)
}
}
})
})
}
return {
vscrollData: this.vscrollData,
vscrollParent: this,
vscrollResizeObserver: this.$_resizeObserver,
}
},
js
inject: [
'vscrollData',
'vscrollParent',
'vscrollResizeObserver',
]
$_vs_onResize
是在 DynamicScrollerItem 中定义的
js
observeSize () {
this.$el.$_vs_onResize = this.onResize
},
DynamicScroller 与 DynamicScrollerItem 也通过事件总线进行通信,其中利用了小型的 JavaScript 事件总线库 mitt。它提供了一种简洁的方式来创建和管理事件及其对应的监听器。
js
import mitt from 'mitt'
this.$_events = mitt()
this.$_events.emit('vscroll:update', { force: false })
DynamicScroller 通过 provide 将自身实例传递给 DynamicScrollerItem ,在 DynamicScrollerItem 通过 DynamicScroller 的实例获取 $_events ,监听相应的事件
js
this.vscrollParent.$_events.on('vscroll:update', this.onVscrollUpdate)
禁用一个组件自动地继承 attribute,你可以在组件选项中设置 inheritAttrs: false
。禁用 Attributes 继承
总结
vue-virtual-scroller
的实现很精妙,其中有非常多在日常开发中可以借鉴的点
- 仅在内部使用,并且不需要响应式特性的数据,可以直接在 this 中赋值,不要在 data 中声明,这样可以减少响应式数据的包装,从而提升性能
js
created () {
this.$_startIndex = 0
this.$_endIndex = 0
// Visible views by their key
this.$_views = new Map()
// Pools of recycled views, by view type
this.$_recycledPools = new Map()
this.$_scrollDirty = false
this.$_lastUpdateScrollPosition = 0
}
- 使用
ResizeObserver
更加先进的 API 监听 dom 的大小的变化,而不是监听resize
事件
js
<ResizeObserver @notify="handleResize" />
js
if (typeof ResizeObserver !== 'undefined') {
this.$_resizeObserver = new ResizeObserver(entries => {
requestAnimationFrame(() => {
if (!Array.isArray(entries)) {
return
}
for (const entry of entries) {
if (entry.target && entry.target.$_vs_onResize) {
let width, height
if (entry.borderBoxSize) {
const resizeObserverSize = entry.borderBoxSize[0]
width = resizeObserverSize.inlineSize
height = resizeObserverSize.blockSize
} else {
// @TODO remove when contentRect is deprecated
width = entry.contentRect.width
height = entry.contentRect.height
}
entry.target.$_vs_onResize(entry.target.$_vs_id, width, height)
}
}
})
})
}
- 使用
requestAnimationFrame
做滚动事件的节流。
使用 requestAnimationFrame
做节流比 setTimeout
好很多,一方面可以充分利用高性能、高刷新率设备,从而带来更流畅的交互体验,另一方面可以避免 setTimeout
处理回调函数时间不准确的问题,同时,在页面处理非活动状态时,requestAnimationFrame
回调函数会自动停止,从而提升性能和电池寿命。使用 requestAnimationFrame 替代 throttle 优化页面性能、【前端性能】高性能滚动 scroll 及页面渲染优化 - ChokCoco - 博客园
js
handleScroll (event) {
if (!this.$_scrollDirty) {
this.$_scrollDirty = true
if (this.$_updateTimeout) return
const requestUpdate = () => requestAnimationFrame(() => {
this.$_scrollDirty = false
const { continuous } = this.updateVisibleItems(false, true)
// It seems sometimes chrome doesn't fire scroll event :/
// When non continous scrolling is ending, we force a refresh
if (!continuous) {
clearTimeout(this.$_refreshTimout)
this.$_refreshTimout = setTimeout(this.handleScroll, this.updateInterval + 100)
}
})
requestUpdate()
}
}
- 使用 Map 数据结构代替数组的循环查找,Map 比数组有更高的查找性能。在没法使用 Map 数据结构的情况下尽量使用高效率的查找算法,例如:二分法。
js
// 省略细节代码
this.$_views = new Map()
const views = this.$_views
js
// 省略细节代码
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) {
a = i
} else if (i < count - 1 && sizes[i + 1].accumulator > scroll.start) {
b = i
}
i = ~~((a + b) / 2)
} while (i !== oldI)
i < 0 && (i = 0)
startIndex = i
- 巧用
shallowReactive()
、markRaw()
API ,减少数据依赖追踪和触发响应的开销,从而提高组件的性能
js
import { shallowReactive, markRaw } from 'vue'
createView (pool, index, item, key, type) {
const nr = markRaw({
id: uid++,
index,
used: true,
key,
type,
})
const view = shallowReactive({
item,
position: 0,
nr,
})
pool.push(view)
return view
}
- 推荐使用 mitt 创建事件总线做简单的事件管理,因为它非常小,可以减少资源占用。