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 中使用函数式组件。
-
列表项组件对
itemprop 的更新要是响应式的,即在不重新创建列表项组件的情况下进行更新(可以使用计算属性和侦听器来实现对 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 创建事件总线做简单的事件管理,因为它非常小,可以减少资源占用。
