前言
本文的虚拟列表基于上一篇动态高度虚拟列表原理解析中的核心代码和Vue3实现,上文是看懂思考&设计部分内容的前提条件。
使用
安装
npm install @e.yen/virtual-scroll-vue
在main.js中显式导入或者在组件中按需导入
// main.ts
import VirtualScroll from '@e.yen/virtual-scroll-vue'
app.use(VirtualScroll)
// 或者
// AnyComponent.vue
import { VirtualScroll } from '@e.yen/virtual-scroll-vue'
导入样式
// main.ts
import '@e.yen/virtual-scroll-vue/dist/style.css'
Github仓库:virtual-scroll-vue
props参数
参数 | 类型 | 默认值 | 是否必须 | 描述 |
---|---|---|---|---|
items | VirtualScrollItem[] | - | ✔️ | 列表数据 |
placeholder | VirtualScrollItem | - | ❌ | 最小子项的模拟数据 |
startPosition | [number, number] | [0, 0] | ❌ | 列表初始位置 |
preserved | number | - | ❌ | 子项的最小高度 |
padding | number | 100 | ❌ | 预渲染区域高度 |
type VirtualScrollItem = {
key?: any
height?: number // 可以指定元素高度,具有最高优先级
[k: string | symbol]: any
}
注意:preserved
的优先级高于 placeholder
expose方法
方法 | 参数 | 返回值类型 | 描述 |
---|---|---|---|
scroll | delta: number , duration?: number | - | 滚动指定距离 |
transport | newStartPosition: [number, number] | - | 传送到指定位置 |
getPosition | - | [number, number] | 获取列表当前位置 |
注意事项
让子项拥有唯一的key
由于列表基于v-for渲染子项,因此为子项拥有唯一的key能够大幅度提升性能表现:
const items = [
{
key: 'ABC',
},
{
key: 'BCD',
},
]
任何时候都不要使最小高度为0
由于元素在被渲染之前无法确认其高度,因此列表依赖于子项目的最小高度确定渲染索引范围。虽然能够通过 placeholder
将最小高度设为0,但这会导致列表渲染后续所有子项:
<!-- preserved默认具有最小值5px,设为0不会有任何效果 -->
<VirtualScroll :items="data" :preserved="0">
<template #default="{ item }">
<!-- 子项目结构 -->
</template>
</VirtualScroll>
<!-- 通过placeholder将最小高度设为0,会导致列表一次性渲染所有元素 -->
<VirtualScroll :items="data" :placeholder="{}">
<template #default="{ item }">
<!-- 高度为0的子项目 -->
<div></div>
</template>
</VirtualScroll>
不要向起始元素之前添加新数据
由于列表的渲染索引范围由起始元素索引、起始元素偏移量和最小项目高度共同决定,因此向起始元素之前的位置添加新元素会导致意料之外的结果:
// 假设 startIndex 为 1
// 向items头部添加新数据,会导致列表渲染的起始元素变为旧数组中索引为 0 的项
items.unshift({
key: 'CDE',
})
不要修改预渲染区内的元素高度
具体来说是不要修改靠近列表排列起始方向一侧的元素高度(例如列表从上往下排列,则不要修改上方预渲染区域内的元素高度)
这不是强制要求的,相反,列表仍能正常工作,但是对于具有过渡效果的高度变化,受制于
ResizeObserver
的滞后性,列表可能出现微小的抖动导致用户体验变差
仅在触屏设备上使用
虽然列表支持滚轮滚动,但是暂不支持滚动条,在PC等非触屏设备上应考虑使用分页
示例
- 列表会自动获取可视区域大小,宽高默认为
100%
,建议通过.virtual-scroll_container
进行覆盖,或者在组件外部包裹一个容器 - 列表会将要渲染的数据通过默认作用域插槽传递出来,天然支持动态高度
<script setup lang="ts">
import { ref } from 'vue'
import DynamicItem from '@/components/DynamicItem/DynamicItem.vue'
import { VirtualScroll, type VirtualScrollInstance } from '@e.yen/virtual-scroll-vue'
import {
generateRandomFirstWord,
generateRandomWord,
lorem,
} from '@/utils/helper'
const defaultItem = { name: 'ab', comment: 'abc', index: -1 }
const items = ref(
new Array(10000).fill(0).map((_, i) => ({
key: i.toString(),
name:
generateRandomFirstWord() +
(Math.random() > 0.5
? ' ' + generateRandomWord(Math.floor(Math.random() * 8) + 2)
: ''),
comment: lorem(Math.floor(Math.random() * 5) + 1),
index: i,
})),
)
const vlist = ref<VirtualScrollInstance>()
function lighteningScroll(delta: number) {
vlist.value!.scroll(delta)
}
</script>
<template>
<div class="page">
<div class="scroll_container">
<VirtualScroll
ref="vlist"
:items="items"
:placeholder="defaultItem"
:start-position="[1000, 0]"
:padding="0"
>
<template #default="{ item }">
<DynamicItem
:index="item.index"
:name="item.name"
:comment="item.comment"
></DynamicItem>
</template>
</VirtualScroll>
</div>
<button @click="lighteningScroll(-100000)">向上极速滚动测试</button>
<button @click="lighteningScroll(100000)">向下极速滚动测试</button>
</div>
</template>
思考&设计
虚拟列表的关键在于如何获取列表项高度,确定了每项的高度,就能确定渲染多少个元素。即如何获取列表项高度决定了虚拟列表的实际表现,在这里给出三个思路:
-
固定步长
在浏览器每一帧渲染之前进行判断,若虚拟列表中的元素不足以占满整个可视区域且仍有未被渲染的后续元素,则将渲染结束的索引后移
n
位。- 优点:实现简单直观
- 缺点:引入了超参数
n
,需要依照实际情况确定一个较为合理的值,较大则造成较多性能浪费,较小则容易导致用户快速滚动时出现空白页
-
预渲染 + 固定步长
原理与上述思路没有区别,优缺点与上面一致,可以认为是在计算元素是否足以占满可视区域时,将参与计算的可视区域进行扩大,从而让列表提前渲染元素。能够在一定程度上缓解空白问题,但治标不治本,当以更快的速度滚动(比如通过代码触发)时仍会出现空白页。
-
预渲染 + 高度预测
观察发现,出现空白页的根本原因是无法确定究竟最多还需要多少个元素才能占满可视区域,为此,可以通过每项的最小高度预测最多需要向后渲染多少个子项,从而保证始终有足够的元素占满可视区域。
- 优点:通过高度预测彻底解决了空白问题
- 缺点:在快速滚动时,由于实际高度与预测高度可能不同,可能会导致落点位置与预期不符
预测实现
高度预测主要有两种实现方式:
- 在设计时就确定好最小高度,单位为CSS像素。实现最为简单且性能最高,但是对于一些使用了相对单位的项目结构不友好
- 根据项目结构自动获取最小高度。通过传入一个具有最小高度的元素的模拟数据,列表动态地获取最小高度,通用性最好
错位处理
维持前文的约定:
起始位置
startPosition
由[startIndex, offset]
二元组构成渲染信息
renderInfo
由[viewHeight, paddingHeight, listHeight]
三元组构成函数
move(startPosition, delta, renderInfo, getHeight) => void
通过起始位置、移动距离、渲染信息和高度获取函数计算本次移动后的新起始位置
高度预测会导致快速滚动时出现到达的位置与预期不同的错位问题。粗略地看,当一帧内列表滚动到了未渲染区域,就会转为使用预测高度继续计算下一帧的起始位置,预测高度与实际高度不一致时就会导致列表“移动过头”。举个例子,假设某个未被渲染的元素实际高度为 110px
,但在计算时将其视为 100px
,那么剩余滚动距离就多了 10px
,这是造成错位的根本原因。
既然知道了问题,那么研究其发生条件变得十分重要:
根据移动函数
move
的计算方式得知:在单次移动中,如果:
- 向上移动距离大于
max(offset - paddingHeight, 0)
+ 预测高度- 向下移动距离大于
listHeight
+ 预测高度就可能导致错位
由于虚拟列表是由起始位置决定的,因此向上滚动时的错位将是致命的。原因是计算新的 offset
时使用了预测的高度,但实际高度大于预测高度,导致后续所有元素都下移。在这里使用了自定义指令 + ResizeObserver的方式解决,处理过程分为3步:
v-auto-record
在元素被挂载时,缓存本次计算时使用的高度v-watch-size
在元素高度被缓存后调用elementResize
进行处理elementResize
根据情况更新高度缓存,以及选择修改offset
或重新渲染
// vAutoRecord.ts
export default <Directive>{
mounted(el, binding) {
if (binding.arg && binding.arg === 'mounted') binding.value?.(el)
},
unmounted(el, binding) {
if (binding.arg && binding.arg === 'unmounted') binding.value?.(el)
},
}
// vWatchSize.ts
export default <Directive>{
mounted(el, binding) {
// ! nextTick保证vWatchSize在vAutoRecord之后执行
nextTick(() => {
if (binding.value instanceof Function) binding.value(el)
el.observer = new ResizeObserver(() => {
if (binding.value instanceof Function) binding.value(el)
})
el.observer.observe(el)
})
},
beforeUnmount(el) {
if (el.observer) {
el.observer.disconnect()
delete el.observer
}
},
}
<li
v-for="(i, index) in renderRange"
:key="props.items[i].key || index"
class="virtual-scroll_item"
v-watch-size="el => elementResize(i, el)"
v-auto-record:mounted="el => elementMap.set(i, el)"
v-auto-record:unmounted="() => elementMap.delete(i)"
>
<slot :item="props.items[i]"></slot>
</li>
const elementResize = (index: number, element: HTMLElement) => {
const cur = element.getBoundingClientRect().height
const pre = getHeight(index) // 取出高度缓存
let isInPaddingRange = false
if (cur === pre) return
// 判断高度变化的元素是否在预加载区间
let offset = startPosition.value[1]
let itemIndex = startPosition.value[0]
let height = getHeight(itemIndex)
while (height >= 0 && offset > 0) {
if (itemIndex === index) {
isInPaddingRange = true
break
}
offset -= height
height = getHeight(++itemIndex)
}
// 更新高度缓存
updateHeight(index)
if (isInPaddingRange) {
// 如果高度变化的元素在预加载区间内,将offset加上高度变化量
startPosition.value[1] += cur - pre
} else if (cur < pre) {
// 如果高度变化的元素不在预加载区间内,重新渲染
renderTrigger.value = !renderTrigger.value
}
}
至于向下滚动时的错位问题,这是高度预测的固有局限,因此没有很好的解决方法,一种可能的蒙混过关的解决方式是:快速滚动时用户无法分辨页面上到底呈现了什么,可以在滚动结束的下一帧立即将起始位置修改为目标位置,实现向下快速滚动到指定位置的错觉。但如果在列表项中出现了编号这样容易让小把戏穿帮的内容,可能需要考虑用 transport
定制滚动效果。
- fin -