持续创作,加速成长!这是我参与「AdminJS · 10 月更文挑战」的第2天,点击查看活动详情
序言
在上一篇文章中,我们讲述了如何通过时间分片的方式去渲染十万条数据的解决方案,如果还不了解时间切片的读者可以先去看看上一篇文章 --✨前端十万条数据渲染(上) -- 时间分片
本篇文章将会带你了解以及实现该场景下的另一个解决方案 -- 虚拟列表
什么是虚拟列表?
有了解过按需加载概念的读者或许会很容易理解虚拟列表,虚拟列表的本质就是一种按需加载的设计,我们在浏览列表中的数据时,实际上只需要渲染出可视区域中的列表项即可,而不在可视区域内的列表项其实没必要进行渲染
这样一来无论数据量有多少,管他是十万条还是百万条,最终交给浏览器渲染的只有可视区域那一块的部分数据要渲染而已,相比于一次性渲染大量数据的粗暴方式,能够大幅度降低浏览器的渲染压力,就如下图所示:
Vue3 + TypeScript 实现虚拟列表
我们的目标就是要保证可视区域内能够渲染出对应的列表项,为了方便实现,这里我就直接使用Vue
了
明确使用到的数据
基于数据驱动的思想,我们先明确一下可能需要用到哪些数据:
首先要维护一下上图中的这几个状态数据:
startIdx
和endIdx
用于控制可视区域内展示的数据,会在滚动条发生变化的时候动态变化itemHeight
用于描述列表中元素的高度,这里为了简单起见,我们将元素的高度设为一个固定值,之后再考虑如何优化成任意高度的元素渲染visibleAreaHeight
则用于记录可视区域的高度,这样我们就可以结合itemHeight
知道可视区域内的元素个数了
以上这四个都是作为状态去保存的,但仅有它们还不够,我们还需要一些额外的计算属性数据
虚拟列表总高度
首先我们要知道全部数据渲染之后的一个总高度,这样才能将其设置为我们的占位列表容器元素list-container-phantom
的高度
因为我们的真实存放元素的列表容器它的高度应当是确定的,但我们又需要知道全部数据#渲染后的一个总高度,这样才方便我们监听滚动条的滚动事件,根据scrollTop
去更改我们的startIdx
和endIdx
从而更新虚拟列表中的数据
那么所有数据渲染后的总高度怎么计算呢?我们在接口返回的数据中能够知道一共有多少条数据,并且我们已经知道每条数据最终渲染后的一个高度itemHeight
了,那么总高度就是data.length * itemHeight
即可
可视区域中元素个数
为什么需要知道这个数据呢?这是为我们的endIdx
准备的,滚动条变化时,startIdx
根据scrollTop
动态变化,而endIdx
只需要在startIdx
的基础上加上当前可视区域中的元素个数即可,所以需要用到该数据
而这个数据可以通过visibleAreaHeight / itemHeight
获得,因此也将其作为一个计算属性
根据以上描述,我们可以维护一个state
去记录startIdx
、endIdx
、visibleAreaHeight
、itemHeight
和接口返回的数据data
这五个状态数据
其余的则作为计算属性即可
interface IData {
id: number
content: string
}
// 可视区域中渲染元素的起始下标
const state = reactive({
// 要渲染的数据
data: [] as IData[],
// 可视区域内的第一个元素在 data 中的下标
startIdx: 0,
// 可视区域内的最后一个元素在 data 中的下标
endIdx: 0,
// 元素的高度
itemHeight: 24,
// 可视区域的高度
visibleAreaHeight: 0,
})
// 获取接口数据
interface IResponseData {
code: number
msg: string
data: IData[]
}
const fetchData = (dataCount = 100000) => {
return new Promise<IResponseData>(resolve => {
const response: IResponseData = {
code: 0,
msg: 'success',
data: [],
}
for (let i = 0; i < dataCount; i++) {
response.data.push({
id: i,
content: `content-${i + 1}`,
})
}
setTimeout(() => {
resolve(response)
}, 300)
})
}
const virtualListRef = ref<HTMLDivElement>()
const dataDisplay = computed(() =>
state.data.slice(state.startIdx, state.endIdx),
)
// 数据的总高度
const dataTotalHeight = computed(() => state.itemHeight * state.data.length)
// 可视区域中的元素个数
const visualAreaItemCount = computed(() =>
Math.ceil(state.visibleAreaHeight / state.itemHeight),
)
由于虚拟列表中的数据是由startIdx
和endIdx
决定的,所以这里我又加了一个dataDisplay
计算属性,在接口返回的数据数组中进行切片即可
组件结构
虽然我们只渲染可视区域中的列表项,但是还是需要知道完整的列表有多高,这样才方便滑动滚动条去按需加载不同的数据,所以这里我们不仅要有一个列表容器,还需要有一个占位用的列表容器,用于形成滚动条
占位列表容器的高度就是最终全部数据渲染完成后的高度,至于这个高度怎么计算可就有讲究了,稍后会讲,先看看页面结构吧
<template>
<div ref="virtualListRef" class="virtual-list" @scroll="handleScroll">
<!-- 占位容器 高度和数据的真实高度一致 用于形成滚动条 phantom: 幻影 -->
<div
class="list-container-phantom"
:style="{ height: `${dataTotalHeight}px` }"
></div>
<!-- 真实的列表容器 -->
<div class="list-container">
<div
v-for="item in dataDisplay"
:key="item.id"
ref="listItemsRef"
class="list-item"
:data-position-idx="item.id"
>
<span>
{{ item.content }}
</span>
</div>
</div>
</div>
</template>
这里我们还需要监听有滚动条的元素的滚动事件,获取它的scrollTop
const handleScroll = (e: UIEvent) => {
const scrollTop = (e.target as HTMLDivElement).scrollTop
state.startIdx = ~~(scrollTop / state.itemHeight)
state.endIdx = state.startIdx + visualAreaItemCount.value
}
数据初始化
onMounted(async () => {
// 获取接口数据
const response = await fetchData()
state.data = response.data
// 获取虚拟列表容器的高度
state.visibleAreaHeight = virtualListRef.value!.clientHeight
// 初始化 startIdx 和 endIdx
state.startIdx = 0
state.endIdx = state.startIdx + visualAreaItemCount.value
})
现在就已经完成了,我们先来看看效果吧~
咦?怎么好像不太对劲,虽然滚动条移动时虚拟列表内的数据是跟着变化了,但是整个列表貌似也随着滚动条在往上移动,这是怎么回事呢?
加入偏移量让虚拟列表整体偏移到容器中央
之所以会出现上图的情况,是因为我们的滚动条并不是由虚拟列表容器产生的,而是虚拟列表的虚拟列表产生的
说起来有点绕,还记得前面组件结构中我们有一个.list-container-phantom
元素吗?这个元素用来占位产生一个滚动条,该元素的高度就是所有数据最终渲染后的高度
因此我们拖拽滚动条的时候,实际上可以理解为整个可视区域在移动,而真正渲染数据的那个虚拟列表一直在最顶部
这就导致滚动条往下拉的时候,产生了整个虚拟列表上移的现象,那这很简单,我们让虚拟列表整体跟着滚动条的可视区域一起移动不就好了吗?
为此我们再引入一个虚拟列表在垂直方向上的偏移量状态,通过transform: translateY()
让其随着滚动条的滚动而偏移
const state = reactive({
// 要渲染的数据
data: [] as IData[],
// 可视区域内的第一个元素在 data 中的下标
startIdx: 0,
// 可视区域内的最后一个元素在 data 中的下标
endIdx: 0,
// 元素的高度
itemHeight: 24,
// 可视区域的高度
visibleAreaHeight: 0,
// 可视区域在垂直方向上的偏移量
offset: 0,
})
offset
的值实际上就是滚动条的scrollTop
,只需要在滚动条变化时加上对它的更新即可
const handleScroll = (e: UIEvent) => {
const scrollTop = (e.target as HTMLDivElement).scrollTop
state.startIdx = ~~(scrollTop / state.itemHeight)
state.endIdx = state.startIdx + visualAreaItemCount.value
state.offset = scrollTop
}
最后我们再修改一下虚拟列表的样式,加上translateY
<script>
// 虚拟列表整体的偏移量
const offsetStyle = computed(() => `${state.offset}px`)
</script>
<style scoped lang="scss">
.list-container {
// ...
transform: translateY(v-bind(offsetStyle));
}
</style>
现在的效果如下:
适配元素任意高度
目前我们的虚拟列表只支持固定高度的元素,但实际上我们无法保证每个元素的高度都是itemHeight
,因此需要做一些额外的工作去支持渲染任意高度的子元素才行
引入预估高度和位置状态
为了做到这个功能,我们可以先给每个元素设置一个预估高度
,并且建立一个缓存表缓存每一个元素的高度信息,当我们需要用到元素的高度时就直接去找缓存表即可
为此我们引入一个新的状态 -- positions
,用于记录每个元素的高度和位置信息(比如top
和bottom
),在每次加载新的数据的时候就更新这个缓存表,使用的时候则直接使用该缓存表的数据即可
interface IDataItemPosition {
id: number
height: number
top: number
bottom: number
}
const state = reactive({
...
// 预估高度
estimatedItemHeight: 24,
// 记录每个元素的位置信息 -- 包括高度和位置(如 top 和 bottom)
positions: [] as IDataItemPosition[],
})
接下来我们就需要利用这个positions
缓存表,修改之前的实现,首先先看看如何将其初始化
onMounted(async () => {
...
// 初始化每个元素的位置信息
state.positions = state.data.map((item, idx) => ({
id: item.id,
height: state.estimatedItemHeight,
top: idx * state.estimatedItemHeight,
bottom: (idx + 1) * state.estimatedItemHeight,
}))
})
确定列表总高度
前面在元素高度固定的情况下,是通过data.length * itemHeight
去确定列表总高度的,那么现在在元素高度不确定的情况下如何确定列表的总高度呢?
由于我们在positions
缓存表中记录了元素的bottom
,那么最后一个元素的bottom
其实就是整个列表的总高度了
// 列表的总高度
const dataTotalHeight = computed(
() =>
state.positions.length &&
state.positions[state.positions.length - 1].bottom,
)
元素渲染后更新缓存表
由于我们的缓存表中每个元素的高度初始值都是预估高度estimatedItemHeight
,真实的高度只有在渲染完成后才能知道,所以在渲染结束后我们需要及时更新缓存表
onUpdated(() => {
const items = listItemsRef.value
items?.forEach((item, idx) => {
const height = item.getBoundingClientRect().height
const positionIdx = Number(item.dataset.positionIdx)
const oldHeight = state.positions[positionIdx].height
const delta = oldHeight - height
if (delta !== 0) {
// 渲染后元素的实际高度和原先的预估高度不一致时需要更新缓存表
state.positions[positionIdx].bottom -= delta
state.positions[positionIdx].height = height
// 当前元素的后续元素的 top 和 bottom 都需要更新
for (let i = positionIdx + 1; i < state.positions.length; i++) {
state.positions[i].top = state.positions[i - 1].bottom
state.positions[i].bottom -= delta
}
}
})
})
滚动时startIdx和offset的更新
const handleScroll = (e: UIEvent) => {
const scrollTop = (e.target as HTMLDivElement).scrollTop
state.startIdx =
state.positions.find(item => item.bottom > scrollTop)?.id ?? 0
state.endIdx = state.startIdx + visualAreaItemCount.value
state.offset =
state.startIdx === 0 ? 0 : state.positions[state.startIdx - 1].bottom
}
现在就可以针对任意高度的元素进行虚拟列表渲染啦