08-技术讲解
# 技术讲解
# 什么问题
# 1. 长列表渲染问题
问题:渲染整个长列表容易造成页面阻塞,用户体验不好
解决方案:
- 时间分片
- 效率低
- 不直观
- 性能差
- 虚拟列表(推荐使用)
# 2. 虚拟列表原理
设置一个可视区域,然后用户在滚动列表的时候,本质上是动态修改可视区域里面的内容。
例如,一开始渲染前面 5 个项目
之后用户进行滚动,就会动态的修改可视区域里面的内容,如下图所示:
# 3. 虚拟列表的实现
假设列表里面每一项定高,我们需要得到一些信息:
- 可视区域起始数据索引(startIndex)
- 可视区域结束数据索引(endIndex)
- 可视区域的数据
- 整个列表中的偏移位置 startOffset
如下图所示:
整个虚拟列表的设计如下:
<!-- 可视区域容器 -->
<div class="infinite-list-container">
<!-- 这是容器里面的占位,高度是总列表高度,用于形成滚动条 -->
<div class="infinite-list-phantom"></div>
<!-- 列表项渲染区域 -->
<div class="infinite-list">
<!-- item-1 -->
<!-- item-2 -->
<!-- ...... -->
<!-- item-n -->
</div>
</div>
- infinite-list-container: 可视区域容器
- infinite-list-phantom: 这是容器里面的占位,高度是总列表高度,用于形成滚动条
- infinite-list:列表项渲染区域
如下图所示:
接下来监听 infinite-list-container 的 scroll 事件,获取滚动位置的 scrollTop
- 假定可视区域高度固定,称之为 screenHeight
- 假定列表每项高度固定,称之为 itemSize
- 假定列表数据称之为 listData
- 假定当前滚动位置称之为 scrollTop
那么我们能够计算出这么一些信息:
- 列表总高度 listHeight = listData.length * itemSize
- 可显示的列表项数 visibleCount = Math.ceil(screenHeight / itemSize)
- 数据的起始索引 startIndex = Math.floor(scrollTop / itemSize)
- 数据的结束索引 endIndex = startIndex + visibleCount
- 列表显示数据为 visibleData = listData.slice(startIndex, endIndex)
当发生滚动之后,由于渲染区域相对于可视区域发生了偏移。我们需要计算出来这个偏移量,然后使用 transform 将 list 重新移回到可视区域。
偏移量 startOffset = scrollTop - (scrollTop % itemSize)
# 4. 遗留问题
1. 动态高度
目前的虚拟列表,是定高度 itemSize,所以很多东西很容易计算
- 列表总高度:listHeight = listData.length * itemSize
- 偏移量的计算:startOffset = scrollTop - (scrollTop % itemSize)
- 数据的起始索引 startIndex = Math.floor(scrollTop / itemSize)
但是在实际应用中,很多条目并非高度相同:
因此在这种不定高的场景下,会遇到这么一些问题:
- 如何获取真实高度?
- 相关属性的计算有何变化?
- 列表的渲染方式有什么改变?
2. 白屏问题
因为现在仅渲染可视区域的元素,如果用户滚动过快,会出现白屏闪烁。
# 解决思路
# 1. 动态高度
- 如何获取真实高度?
- 如果能获得列表项高度数组,真实高度问题就很好解决。但在实际渲染之前是很难拿到每一项的真实高度的,所以我们采用预估一个高度渲染出真实 DOM,再根据 DOM 的实际情况去设置真实高度。
- 创建一个缓存列表,其中列表项字段为 索引、高度与定位,并预估列表项高度用于初始化缓存列表。在渲染后根据 DOM 实际情况更新缓存列表。
- 相关属性该如何计算?
- 显然以前的计算方式都无法使用了,因为那都是针对固定值设计的。
- 于是我们需要 根据缓存列表重写计算属性、滚动回调函数,例如列表总高度的计算可以使用缓存列表最后一项的定位字段的值。
- 列表渲染方式有何改变?
- 因为用于渲染页面元素的数据是根据 开始/结束索引 在 数据列表 中筛选出来的,所以只要保证索引的正确计算,那么渲染方式是无需变化的。
- 对于开始索引,我们将原先的计算公式改为:在 缓存列表 中搜索第一个底部定位大于 列表垂直偏移量 的项并返回它的索引
- 对于结束索引,它是根据开始索引生成的,无需修改。
# 2. 白屏闪烁
添加缓存区,整个渲染区域由 可视区 + 缓冲区 共同组成。
# 解决细节
# 1. 动态高度
1. 预估并初始化列表
首先增加一个 props,存储预估高度
onst props = defineProps({
// ...
// 预估高度
estimatedItemSize: {
type: Number,
required: true
},
// ...
})
父组件在使用该虚拟列表组件时,就需要传递这个 props:
<VirtualList :listData="data" :estimatedItemSize="100" v-slot="slotProps">
<Item :item="slotProps.item" />
</VirtualList>
在虚拟列表组件里面,就需要维护一个缓存列表 postions,一开始以预估的高度来做初始化
let positions = []
// ...
const initPositions = () => {
positions = props.listData.map((d, index) => ({
index,
height: props.estimatedItemSize, // 一开始以预估高度来做初始化
top: index * props.estimatedItemSize,
bottom: (index + 1) * props.estimatedItemSize
}))
}
2. 更新真实数据
每次渲染之后,需要获取 DOM 的真实高度,然后去替换 postions 里面的预估高度。
这个操作放在 Vue 里面 updated 钩子里面来处理:
// 更新每一项的真实高度
const updateItemsSize = () => {
items.value.forEach((node) => {
let rect = node.getBoundingClientRect()
let height = rect.height
let index = +node.id.slice(1)
let oldHeight = positions[index].height
let dValue = oldHeight - height
if (dValue) {
positions[index].bottom -= dValue
positions[index].height = height
for (let k = index + 1; k < positions.length; k++) {
positions[k].top = positions[k - 1].bottom
positions[k].bottom -= dValue
}
}
})
}
// 更新偏移量
const setStartOffset = () => {
let startOffset = start.value >= 1 ? positions[start.value - 1].bottom : 0
content.value.style.transform = `translate3d(0,${startOffset}px,0)`
}
onUpdated(() => {
requestAnimationFrame(() => {
if (!items.value || !items.value.length) {
return
}
updateItemsSize()
let height = positions[positions.length - 1].bottom
phantom.value.style.height = height + 'px'
setStartOffset()
})
})
3. 重写滚动回调
滚动回调里面,主要是需要更新获取 startIndex 的方式。
遍历缓存列表 positions,找到第一个定位大于当前滚动距离 scorllTop 的条目,返回该条目的索引值即可。
//获取列表起始索引
getStartIndex(scrollTop = 0){
let item = this.positions.find(i => i && i.bottom > scrollTop);
return item.index;
}
const scrollEvent = () => {
let scrollTop = list.value.scrollTop
start.value = getStartIndex(scrollTop) // 获取 startIndex
end.value = start.value + visibleCount.value // 根据 startIndex 获取 endIndex
setStartOffset()
}
这里有一个优化的点。postions 是一个有序的数组,因此我们在查找的时候就可以做优化。
之前用的 find:顺序查找,时间复杂度为 O(n)
因为是有序数组,可以改为二分查找,时间复杂度为 (O(logN))
const getStartIndex = (scrollTop = 0) => {
return binarySearch(positions, scrollTop)
}
const binarySearch = (list, value) => {
let start = 0
let end = list.length - 1
let tempIndex = null
while (start <= end) {
let midIndex = parseInt((start + end) / 2)
let midValue = list[midIndex].bottom
if (midValue === value) {
return midIndex + 1
} else if (midValue < value) {
start = midIndex + 1
} else if (midValue > value) {
if (tempIndex === null || tempIndex > midIndex) {
tempIndex = midIndex
}
end = end - 1
}
}
return tempIndex
}
# 2. 白屏闪烁
通过设置缓冲区的方式来解决。
增加一个 props 叫做 bufferScale,用于接收缓冲区数据和可视区域数据的一个比例
const props = defineProps({
// ...
bufferScale: {
type: Number,
default: 1
},
// ...
})
接下来就可以根据这个比例,计算出上下缓冲区的数量:
// 上方缓冲区
const aboveCount = computed(() => {
return Math.min(start.value, props.bufferScale * visibleCount.value)
})
// 下方缓冲区
const belowCount = computed(() => {
return Math.min(props.listData.length - end.value, props.bufferScale * visibleCount.value)
})
现在 visibleData 的计算也需要更新,需要加入上下缓冲区
const visibleData = computed(() => {
let startIdx = start.value - aboveCount.value
let endIdx = end.value + belowCount.value
return _listData.value.slice(startIdx, endIdx)
})
另外偏移量的计算也需要更新,需要将缓冲区考虑进去:
const setStartOffset = () => {
let startOffset
if (start.value >= 1) {
let size =
positions.value[start.value].top -
(positions.value[start.value - aboveCount.value]
? positions.value[start.value - aboveCount.value].top
: 0)
startOffset = positions.value[start.value - 1].bottom - size
} else {
startOffset = 0
}
content.value.style.transform = `translate3d(0,${startOffset}px,0)`
}
-EOF-