vue3-infinite-list虚拟滚动的学习
地址
tnfe/vue3-infinite-list: 一个支持百万数量级的Vue3无限滚动列表组件 (github.com)
模板
<template>
<div ref="rootNode" :style="warpStyle">
<div ref="innerNode" :style="innerStyle">
<div
v-for="(item, i) in event?.items"
:style="getItemStyle(i)"
:key="event?.start + i"
class="vue3-infinite-list"
>
<slot :event="event" :item="item" :index="event.start + i"></slot>
</div>
</div>
</div>
</template>
参数
属性 | 类型 | 是否必须? | 描述 |
---|---|---|---|
width | Number or String* | ✓ | 列表宽度. 在滚动方向是 'horizontal' 是用于确定滚动元素个数. |
height | Number or String* | ✓ | 列表宽度. 在滚动方向是 'vertical' 是用于确定滚动元素个数. |
data | any[] | ✓ | 构建滚动元素的数据 |
itemSize | (index: number): number /number/string/Array | 可以是一个固定的宽/高(取决于滚动方向), 一个包含列表所有元素的数组, 或者是返回指定位置元素高度的函数: (index: number): number | |
scrollDirection | String | 指定滚动方向 'vertical' (默认) 或 'horizontal' . | |
scrollOffset | Number | 可以指定滚动位置 | |
scrollToIndex | Number | 可以指定到滚动到哪个元素 | |
scrollToAlignment | String | 结合 scrollToIndex 一起用, 用于控制滚动到的元素的对齐方式. 可选: 'start' , 'center' , 'end' or 'auto' . 使用 'start' 将对齐到容器的起始位置, 'end' 则对齐到元素的结尾. 使用 'center 可以对齐到容器正中间. 'auto' 则是滚动到scrollToIndex 指定元素恰好完全可见的位置 | |
overscanCount | Number | 在可见元素上/下额外渲染的元素数量. 这可以减少在特定浏览器/设备上的闪烁 |
setup全局变量和SizeAndPosManager私有成员
// 根组件节点
let rootNode = ref(null);
// 列表节点
let innerNode = ref(null);
// 根组件样式
let warpStyle = ref(null);
// 列表样式
let innerStyle = ref(null);
// 中间变量,无作用
let items: any[] = [];
// 滚动条偏移量
let offset: number;
// 上一次滚动条偏移量
let oldOffset: number;
// 滚动条产生变化的原因,如果是request,说明是由于滚动条数据改变而产生的变化,如果是observed,说明是由于滚动条滚动而产生的变化
let scrollChangeReason: string;
// 大小和位置管理器,单例
let sizeAndPosManager: SizeAndPosManager;
// 样式缓存
let styleCache: StyleCache = {};
const { itemSize, scrollDirection, scrollToIndex } = toRefs(props);
const util = new Util();
// 最终展示时需要的数据
const event = reactive(new ILEvent());
export class SizeAndPosManager {
private itemSizeGetter: ItemSizeGetter;
private itemCount: number;
private estimatedItemSize: number;
private lastMeasuredIndex: number;
private itemSizeAndPositionData: SizeAndPositionData;
}
生命周期
onMounted
在下一个事件循环中触发initAll函数
onMounted(() => setTimeout(initAll));
initAll
const initAll = () => {
// 创建尺寸和位置管理器
createSizeAndPosManager();
// 为根节点添加事件监听,监听列表滚动事件
util.addEventListener(rootNode.value, "scroll", handleScroll);
// 设置偏移初始值
offset = props.scrollOffset || (props.scrollToIndex != null && getOffsetForIndex(props.scrollToIndex)) || 0;
// 设置修改原因为request
scrollChangeReason = SCROLL_CHANGE_REQUESTED;
// srcoll初始化值,设置宏任务滚动到目标位置
setTimeout(() => {
if (props.scrollOffset != null) {
scrollTo(props.scrollOffset);
} else if (props.scrollToIndex != null) {
scrollTo(getOffsetForIndex(props.scrollToIndex));
}
}, 0);
// 修正样式和执行新的渲染
setDomStyle();
scrollRender();
};
/**
* 获取指定索引的偏移量,不重要
* @param index 索引
* @param scrollToAlignment 滚动对齐方式(start, center, end,auto)
* @param itemCount 列表总数
* @returns 偏移量
*/
const getOffsetForIndex = (
index: number,
scrollToAlignment: string = props.scrollToAlignment,
itemCount: number = getItemCount()
): number => {
// 如果索引小于0或者大于列表总数,则设置为0
if (index < 0 || index >= itemCount) index = 0;
return sizeAndPosManager.getUpdatedOffsetForIndex({
align: props.scrollToAlignment,
containerSize: getCurrSizeVal(),
currentOffset: offset || 0,
targetIndex: index,
});
};
initAll->createSizeAndPosManager
/**
* 创建尺寸和位置管理器的实例,并且为单例模式
*/
const createSizeAndPosManager = () => {
// 单例模式
if (!sizeAndPosManager)
// 创建尺寸和位置管理器
sizeAndPosManager = new SizeAndPosManager({
itemCount: getItemCount(),
itemSizeGetter: (index) => getSize(index),
estimatedItemSize: getEstimatedItemSize(),
});
return sizeAndPosManager;
};
/**
* @description 获取指定索引的尺寸
*/
const getSize = (index: number): number => {
if (typeof itemSize.value === "function") {
return itemSize.value(index);
}
return util.isArray(itemSize.value) ? itemSize.value[index] : itemSize.value;
};
/**
* @description 获取估计的尺寸
*/
const getEstimatedItemSize = () => {
return props.estimatedItemSize || (typeof itemSize.value === "number" && itemSize.value) || 50;
};
initAll->createSizeAndPosManager->SizeAndPosManager.constructor
constructor({ itemCount, itemSizeGetter, estimatedItemSize }: Options) {
this.itemSizeGetter = itemSizeGetter;
this.itemCount = itemCount;
this.estimatedItemSize = estimatedItemSize;
// 按项目索引映射的项目大小和位置数据的缓存。
this.itemSizeAndPositionData = {};
// 在lastMeasuredIndex之前是可信的;在lastMeasuredIndex之后的项目应进行估算。
this.lastMeasuredIndex = -1;
}
initAll->setDomStyle
/**
* @description 重新设置style
*/
const setDomStyle = () => {
warpStyle.value = {
...STYLE_WRAPPER,
height: addUnit(props.height),
width: addUnit(props.width),
};
innerStyle.value = {
...STYLE_INNER,
// 设置列表的宽度或者高度
[getCurrSizeProp()]: addUnit(sizeAndPosManager.getTotalSize()),
};
};
/**
* @description 添加单位
*/
const addUnit = (val: any): string => {
return typeof val === "string" ? val : val + props.unit;
};
/**
* @description 如果是水平的列表,则返回字符串’width‘,如果是垂直的列表,则返回字符串‘height’
*/
const getCurrSizeProp = () => {
return sizeProp[scrollDirection.value];
};
initAll->setDomStyle->SizeAndPosManager.getTotalSize
/**
* 所有测量项目的总尺寸。该值将在最初完全估算。作为测量项目,将更新估算值(前面是精确值,后面是估计值)。
*/
getTotalSize(): number {
const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem();
// lastMeasuredSizeAndPosition.offset + lastMeasuredSizeAndPosition.size是精确值,后面的是估计值
return (
lastMeasuredSizeAndPosition.offset +
lastMeasuredSizeAndPosition.size +
(this.itemCount - this.lastMeasuredIndex - 1) * this.estimatedItemSize
);
}
/**
* @description 获取最后的一个精确项目的大小和位置
*/
getSizeAndPositionOfLastMeasuredItem() {
return this.lastMeasuredIndex >= 0 ? this.itemSizeAndPositionData[this.lastMeasuredIndex] : { offset: 0, size: 0 };
}
initAll->scrollRender
/**
* @description 因为滚动条的滚动而进行的渲染
*/
const scrollRender = () => {
// start和stop为渲染的起始和结束位置
const { start, stop } = sizeAndPosManager.getVisibleRange({
containerSize: getCurrSizeVal() || 0,
offset: offset || 0,
overscanCount: props.overscanCount,
});
// 将从start到stop的元素添加到items中,用于渲染
if (typeof start !== "undefined" && typeof stop !== "undefined") {
items.length = 0;
for (let i = start; i <= stop; i++) {
items.push(props.data[i]);
}
// 更新event
event.start = start;
event.stop = stop;
event.offset = offset;
event.items = items;
event.total = getItemCount();
// 更新列表的高度或者宽度
if (!util.isPureNumber(itemSize.value)) {
innerStyle.value = {
...STYLE_INNER,
[getCurrSizeProp()]: addUnit(sizeAndPosManager.getTotalSize()),
};
}
if (props.debug) {
console.log(event.toString());
}
}
renderEnd();
};
/**
* @description 在scroll发生改变后,更新滚动条位置
*/
const renderEnd = () => {
if (oldOffset !== offset && scrollChangeReason === SCROLL_CHANGE_REQUESTED) {
scrollTo(offset);
}
};
/**
* @description 滚动到指定位置
*/
const scrollTo = (value: number) => {
rootNode.value[getCurrScrollProp()] = value;
oldOffset = value;
};
initAll->scrollRender->sizeAndPosManager.getVisibleRange
/**
* @description 获取可视范围中的元素数量
* @param containerSize 容器视口的大小(宽度或高度)
* @param offset 滚动条偏移量
* @param overscanCount 用于在可见范围之外渲染的元素
*/
getVisibleRange({
containerSize,
offset,
overscanCount,
}: {
containerSize: number;
offset: number;
overscanCount: number;
}): { start?: number; stop?: number } {
const totalSize = this.getTotalSize();
// 如果总尺寸为0,则没有可见的项目
if (totalSize === 0) {
return {};
}
const maxOffset = offset + containerSize;
// 找到第一个可见的项目
let start = this.findNearestItem(offset);
if (typeof start === "undefined") {
throw Error(`Invalid offset ${offset} specified`);
}
const datum = this.getSizeAndPositionForIndex(start);
offset = datum.offset + datum.size;
// 从第一个可见的项目开始,找到最后一个可见的项目,最后stop的值就是最后一个可见的项目的索引
let stop = start;
while (offset < maxOffset && stop < this.itemCount - 1) {
stop++;
offset += this.getSizeAndPositionForIndex(stop).size;
}
// 如果指定了overscanCount,则扩展可见范围
if (overscanCount) {
start = Math.max(0, start - overscanCount);
stop = Math.min(stop + overscanCount, this.itemCount - 1);
}
return {
start,
stop,
};
}
/**
* 搜索最接近指定偏移量的项(索引)。比如3已经有一部分移出,但是仍然在可视范围内,那么返回3
*
* 如果未找到完全匹配项,将返回下一个最低的项目索引。这允许部分可见的项目(在折叠之前有偏移)可见。
*/
findNearestItem(offset: number) {
if (isNaN(offset)) {
throw Error(`Invalid offset ${offset} specified`);
}
// 我们的搜索算法在指定的偏移量或以下找到最接近的匹配。因此,请确保偏移量至少为0,否则将找不到匹配项
offset = Math.max(0, offset);
const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem();
const lastMeasuredIndex = Math.max(0, this.lastMeasuredIndex);
// 如果最后一个测量的项目的偏移量大于或等于我们正在搜索的偏移量,则我们可以使用二分查找(因为前面的项目的偏移量是已知的)
if (lastMeasuredSizeAndPosition.offset >= offset) {
// 如果我们已经测量了这个范围内的项目,只需使二分查找,因为它更快。
return this.binarySearch({
high: lastMeasuredIndex,
low: 0,
offset,
});
} else {
// 如果我们还没有测量到这个高度,则回退到内部二分的指数搜索。
// 指数搜索避免了像二分搜索那样预先计算整个项目集的大小。这种方法的总体复杂性为O(logn)
return this.exponentialSearch({
index: lastMeasuredIndex,
offset,
});
}
}
/**
* 此方法返回指定索引处项的大小和位置。它及时计算(或使用缓存值)通向索引的项目
*/
getSizeAndPositionForIndex(index: number) {
// 索引必须在0和itemCount之间
if (index < 0 || index >= this.itemCount) {
throw Error(`Requested index ${index} is outside of range 0..${this.itemCount}`);
}
// 如果我们已经测量了这个项目,那么我们可以跳过它
if (index > this.lastMeasuredIndex) {
// 从最后一个测量的项目开始,我们可以使用上一个项目的大小和位置来估计剩余的项目的位置。
const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem();
let offset = lastMeasuredSizeAndPosition.offset + lastMeasuredSizeAndPosition.size;
for (let i = this.lastMeasuredIndex + 1; i <= index; i++) {
// 遍历每一个元素的size,并存入缓存中以便下次使用
const size = this.itemSizeGetter(i);
if (size == null || isNaN(size)) {
throw Error(`Invalid size returned for index ${i} of value ${size}`);
}
// 缓存offset和size
this.itemSizeAndPositionData[i] = {
offset,
size,
};
// 更新offset
offset += size;
}
// 到index的准确距离都已经测量完毕,更新最后一个测量的项目的索引
this.lastMeasuredIndex = index;
}
return this.itemSizeAndPositionData[index];
}
/**
* @description 通过二分查找找到对应的索引
* @param low 最低的范围
* @param high 最高的范围
* @param offset 偏移量
* @private
*/
private binarySearch({ low, high, offset }: { low: number; high: number; offset: number }) {
let middle = 0;
let currentOffset = 0;
while (low <= high) {
middle = low + Math.floor((high - low) / 2);
currentOffset = this.getSizeAndPositionForIndex(middle).offset;
if (currentOffset === offset) {
return middle;
} else if (currentOffset < offset) {
low = middle + 1;
} else if (currentOffset > offset) {
high = middle - 1;
}
}
if (low > 0) {
return low - 1;
}
return 0;
}
/**
* @description 通过指数查找找到对应的索引
* @param index
* @param offset
* @private
*/
private exponentialSearch({ index, offset }: { index: number; offset: number }) {
let interval = 1;
// 通过指数查找找到比index大的最小值
while (index < this.itemCount && this.getSizeAndPositionForIndex(index).offset < offset) {
index += interval;
interval *= 2;
}
// 通过二分查找找到对应的索引
return this.binarySearch({
high: Math.min(index, this.itemCount - 1),
low: Math.floor(index / 2),
offset,
});
}
列表样式
const getItemStyle = (index: number): any => {
// 获取真正的索引值
index += event.start;
// 如果缓存中有,直接返回
const style = styleCache[index];
if (style) return style;
const { size, offset } = sizeAndPosManager.getSizeAndPositionForIndex(index);
const debugStyle = props.debug ? { backgroundColor: util.randomColor() } : null;
return (styleCache[index] = {
...STYLE_ITEM,
...debugStyle,
// 根据滚动方向设置宽高
[getCurrSizeProp()]: addUnit(size),
// 根据滚动方向设置偏移(top/left)
[positionProp[props.scrollDirection]]: addUnit(offset),
});
};
发生scroll事件
/**
* @description 当滚动条发生变化时,
*/
const handleScroll = (e: UIEvent) => {
const nodeOffset = getNodeOffset();
// 如果滚动条偏移量小于0,或者滚动条偏移量和上一次滚动条偏移量相等,或者滚动条不在根节点上,直接返回
if (nodeOffset < 0 || offset === nodeOffset || e.target !== rootNode.value) return;
// 更新滚动条偏移量
offset = nodeOffset;
// 设置修改原因为observed,不需要再次更新滚动条
scrollChangeReason = SCROLL_CHANGE_OBSERVED;
scrollRender();
};
/**
* @description 获取rootNode偏移量的值
*/
const getNodeOffset = () => {
return rootNode.value[getCurrScrollProp()];
};
/**
* @description 如果是水平的列表,则返回字符串’scrollTop‘,如果是垂直的列表,则返回字符串’scrollLeft‘
*/
const getCurrScrollProp = () => {
return scrollProp[scrollDirection.value];
};
监听事件
// 监听data的变化,重新计算尺寸
watch(
() => props.data,
(newVal, oldVal) => {
sizeAndPosManager.updateConfig({
itemCount: getItemCount(),
estimatedItemSize: getEstimatedItemSize(),
});
oldOffset = null;
recomputeSizes();
setDomStyle();
setTimeout(scrollRender, 0);
}
);
// scrollOffset变化时,将滚动条设置到指定位置
watch(
() => props.scrollOffset,
(newVal, oldVal) => {
offset = props.scrollOffset || 0;
scrollChangeReason = SCROLL_CHANGE_REQUESTED;
scrollRender();
}
);
// 监听scrollToIndex变化,将滚动条设置到指定位置
watch(
() => props.scrollToIndex,
(newVal, oldVal) => {
offset = getOffsetForIndex(props.scrollToIndex, props.scrollToAlignment, getItemCount());
scrollChangeReason = SCROLL_CHANGE_REQUESTED;
scrollRender();
}
);
onBeforeUnmount
onBeforeUnmount(() => {
clearStyleCache();
sizeAndPosManager.destroy();
util.removeEventListener(rootNode.value, "scroll", handleScroll);
});
/**
* @description 清理样式缓存
*/
const clearStyleCache = () => {
for (let key in styleCache) {
delete styleCache[key];
}
};
// 删除缓存
destroy() {
for (let key in this.itemSizeAndPositionData) {
delete this.itemSizeAndPositionData[key];
}
}
理解
- rootNode的overflow为auto,宽度或者高度设置为定值,另一个高度或宽度设置为100%
- innerNode的position设置为relative,隐藏滚动条,高度设置为估计值,由已经计算的最后一个值的offset和剩下的未计算值的个数*估计值
- 解决高度不定的问题(以竖滚动条为例):position设置为absolute,left设置为0,高度通过itemSize计算得出,上一个的top+上一个height就是当前元素的top,把已经计算的元素放入缓存中,并更新innerNode的高度,
- 解决动态渲染可视的元素:通过offset+容器的高度或者宽度(rootNode的),计算出最大的offset,通过缓存或者计算(二分查找和指数查找)找到最上方的可见元素的索引,即start的值,累加索引和offset,直到超过最大的offset为止,即stop的值,为两个各-+overscan的值,减少白屏和闪烁
- 解决如何滚动的问题:一开始为滚动条添加scroll事件,当滚动时更新滚动条偏移量,然后重新渲染
- 滚动到指定元素,offset设置到固定索引,但是滚动条本身没有动,所以还需要改变滚动条到位置
- 数据更新,已有的缓存已经失效,需要重新进行计算,同时更新itemCount,重新设置rootNode和innerNode的样式## vue3-infinite-list虚拟滚动的学习
地址
tnfe/vue3-infinite-list: 一个支持百万数量级的Vue3无限滚动列表组件 (github.com)
模板
<template>
<div ref="rootNode" :style="warpStyle">
<div ref="innerNode" :style="innerStyle">
<div
v-for="(item, i) in event?.items"
:style="getItemStyle(i)"
:key="event?.start + i"
class="vue3-infinite-list"
>
<slot :event="event" :item="item" :index="event.start + i"></slot>
</div>
</div>
</div>
</template>
参数
属性 | 类型 | 是否必须? | 描述 |
---|---|---|---|
width | Number or String* | ✓ | 列表宽度. 在滚动方向是 'horizontal' 是用于确定滚动元素个数. |
height | Number or String* | ✓ | 列表宽度. 在滚动方向是 'vertical' 是用于确定滚动元素个数. |
data | any[] | ✓ | 构建滚动元素的数据 |
itemSize | (index: number): number /number/string/Array | 可以是一个固定的宽/高(取决于滚动方向), 一个包含列表所有元素的数组, 或者是返回指定位置元素高度的函数: (index: number): number | |
scrollDirection | String | 指定滚动方向 'vertical' (默认) 或 'horizontal' . | |
scrollOffset | Number | 可以指定滚动位置 | |
scrollToIndex | Number | 可以指定到滚动到哪个元素 | |
scrollToAlignment | String | 结合 scrollToIndex 一起用, 用于控制滚动到的元素的对齐方式. 可选: 'start' , 'center' , 'end' or 'auto' . 使用 'start' 将对齐到容器的起始位置, 'end' 则对齐到元素的结尾. 使用 'center 可以对齐到容器正中间. 'auto' 则是滚动到scrollToIndex 指定元素恰好完全可见的位置 | |
overscanCount | Number | 在可见元素上/下额外渲染的元素数量. 这可以减少在特定浏览器/设备上的闪烁 |
setup全局变量和SizeAndPosManager私有成员
// 根组件节点
let rootNode = ref(null);
// 列表节点
let innerNode = ref(null);
// 根组件样式
let warpStyle = ref(null);
// 列表样式
let innerStyle = ref(null);
// 中间变量,无作用
let items: any[] = [];
// 滚动条偏移量
let offset: number;
// 上一次滚动条偏移量
let oldOffset: number;
// 滚动条产生变化的原因,如果是request,说明是由于滚动条数据改变而产生的变化,如果是observed,说明是由于滚动条滚动而产生的变化
let scrollChangeReason: string;
// 大小和位置管理器,单例
let sizeAndPosManager: SizeAndPosManager;
// 样式缓存
let styleCache: StyleCache = {};
const { itemSize, scrollDirection, scrollToIndex } = toRefs(props);
const util = new Util();
// 最终展示时需要的数据
const event = reactive(new ILEvent());
export class SizeAndPosManager {
private itemSizeGetter: ItemSizeGetter;
private itemCount: number;
private estimatedItemSize: number;
private lastMeasuredIndex: number;
private itemSizeAndPositionData: SizeAndPositionData;
}
生命周期
onMounted
在下一个事件循环中触发initAll函数
onMounted(() => setTimeout(initAll));
initAll
const initAll = () => {
// 创建尺寸和位置管理器
createSizeAndPosManager();
// 为根节点添加事件监听,监听列表滚动事件
util.addEventListener(rootNode.value, "scroll", handleScroll);
// 设置偏移初始值
offset = props.scrollOffset || (props.scrollToIndex != null && getOffsetForIndex(props.scrollToIndex)) || 0;
// 设置修改原因为request
scrollChangeReason = SCROLL_CHANGE_REQUESTED;
// srcoll初始化值,设置宏任务滚动到目标位置
setTimeout(() => {
if (props.scrollOffset != null) {
scrollTo(props.scrollOffset);
} else if (props.scrollToIndex != null) {
scrollTo(getOffsetForIndex(props.scrollToIndex));
}
}, 0);
// 修正样式和执行新的渲染
setDomStyle();
scrollRender();
};
/**
* 获取指定索引的偏移量,不重要
* @param index 索引
* @param scrollToAlignment 滚动对齐方式(start, center, end,auto)
* @param itemCount 列表总数
* @returns 偏移量
*/
const getOffsetForIndex = (
index: number,
scrollToAlignment: string = props.scrollToAlignment,
itemCount: number = getItemCount()
): number => {
// 如果索引小于0或者大于列表总数,则设置为0
if (index < 0 || index >= itemCount) index = 0;
return sizeAndPosManager.getUpdatedOffsetForIndex({
align: props.scrollToAlignment,
containerSize: getCurrSizeVal(),
currentOffset: offset || 0,
targetIndex: index,
});
};
initAll->createSizeAndPosManager
/**
* 创建尺寸和位置管理器的实例,并且为单例模式
*/
const createSizeAndPosManager = () => {
// 单例模式
if (!sizeAndPosManager)
// 创建尺寸和位置管理器
sizeAndPosManager = new SizeAndPosManager({
itemCount: getItemCount(),
itemSizeGetter: (index) => getSize(index),
estimatedItemSize: getEstimatedItemSize(),
});
return sizeAndPosManager;
};
/**
* @description 获取指定索引的尺寸
*/
const getSize = (index: number): number => {
if (typeof itemSize.value === "function") {
return itemSize.value(index);
}
return util.isArray(itemSize.value) ? itemSize.value[index] : itemSize.value;
};
/**
* @description 获取估计的尺寸
*/
const getEstimatedItemSize = () => {
return props.estimatedItemSize || (typeof itemSize.value === "number" && itemSize.value) || 50;
};
initAll->createSizeAndPosManager->SizeAndPosManager.constructor
constructor({ itemCount, itemSizeGetter, estimatedItemSize }: Options) {
this.itemSizeGetter = itemSizeGetter;
this.itemCount = itemCount;
this.estimatedItemSize = estimatedItemSize;
// 按项目索引映射的项目大小和位置数据的缓存。
this.itemSizeAndPositionData = {};
// 在lastMeasuredIndex之前是可信的;在lastMeasuredIndex之后的项目应进行估算。
this.lastMeasuredIndex = -1;
}
initAll->setDomStyle
/**
* @description 重新设置style
*/
const setDomStyle = () => {
warpStyle.value = {
...STYLE_WRAPPER,
height: addUnit(props.height),
width: addUnit(props.width),
};
innerStyle.value = {
...STYLE_INNER,
// 设置列表的宽度或者高度
[getCurrSizeProp()]: addUnit(sizeAndPosManager.getTotalSize()),
};
};
/**
* @description 添加单位
*/
const addUnit = (val: any): string => {
return typeof val === "string" ? val : val + props.unit;
};
/**
* @description 如果是水平的列表,则返回字符串’width‘,如果是垂直的列表,则返回字符串‘height’
*/
const getCurrSizeProp = () => {
return sizeProp[scrollDirection.value];
};
initAll->setDomStyle->SizeAndPosManager.getTotalSize
/**
* 所有测量项目的总尺寸。该值将在最初完全估算。作为测量项目,将更新估算值(前面是精确值,后面是估计值)。
*/
getTotalSize(): number {
const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem();
// lastMeasuredSizeAndPosition.offset + lastMeasuredSizeAndPosition.size是精确值,后面的是估计值
return (
lastMeasuredSizeAndPosition.offset +
lastMeasuredSizeAndPosition.size +
(this.itemCount - this.lastMeasuredIndex - 1) * this.estimatedItemSize
);
}
/**
* @description 获取最后的一个精确项目的大小和位置
*/
getSizeAndPositionOfLastMeasuredItem() {
return this.lastMeasuredIndex >= 0 ? this.itemSizeAndPositionData[this.lastMeasuredIndex] : { offset: 0, size: 0 };
}
initAll->scrollRender
/**
* @description 因为滚动条的滚动而进行的渲染
*/
const scrollRender = () => {
// start和stop为渲染的起始和结束位置
const { start, stop } = sizeAndPosManager.getVisibleRange({
containerSize: getCurrSizeVal() || 0,
offset: offset || 0,
overscanCount: props.overscanCount,
});
// 将从start到stop的元素添加到items中,用于渲染
if (typeof start !== "undefined" && typeof stop !== "undefined") {
items.length = 0;
for (let i = start; i <= stop; i++) {
items.push(props.data[i]);
}
// 更新event
event.start = start;
event.stop = stop;
event.offset = offset;
event.items = items;
event.total = getItemCount();
// 更新列表的高度或者宽度
if (!util.isPureNumber(itemSize.value)) {
innerStyle.value = {
...STYLE_INNER,
[getCurrSizeProp()]: addUnit(sizeAndPosManager.getTotalSize()),
};
}
if (props.debug) {
console.log(event.toString());
}
}
renderEnd();
};
/**
* @description 在scroll发生改变后,更新滚动条位置
*/
const renderEnd = () => {
if (oldOffset !== offset && scrollChangeReason === SCROLL_CHANGE_REQUESTED) {
scrollTo(offset);
}
};
/**
* @description 滚动到指定位置
*/
const scrollTo = (value: number) => {
rootNode.value[getCurrScrollProp()] = value;
oldOffset = value;
};
initAll->scrollRender->sizeAndPosManager.getVisibleRange
/**
* @description 获取可视范围中的元素数量
* @param containerSize 容器视口的大小(宽度或高度)
* @param offset 滚动条偏移量
* @param overscanCount 用于在可见范围之外渲染的元素
*/
getVisibleRange({
containerSize,
offset,
overscanCount,
}: {
containerSize: number;
offset: number;
overscanCount: number;
}): { start?: number; stop?: number } {
const totalSize = this.getTotalSize();
// 如果总尺寸为0,则没有可见的项目
if (totalSize === 0) {
return {};
}
const maxOffset = offset + containerSize;
// 找到第一个可见的项目
let start = this.findNearestItem(offset);
if (typeof start === "undefined") {
throw Error(`Invalid offset ${offset} specified`);
}
const datum = this.getSizeAndPositionForIndex(start);
offset = datum.offset + datum.size;
// 从第一个可见的项目开始,找到最后一个可见的项目,最后stop的值就是最后一个可见的项目的索引
let stop = start;
while (offset < maxOffset && stop < this.itemCount - 1) {
stop++;
offset += this.getSizeAndPositionForIndex(stop).size;
}
// 如果指定了overscanCount,则扩展可见范围
if (overscanCount) {
start = Math.max(0, start - overscanCount);
stop = Math.min(stop + overscanCount, this.itemCount - 1);
}
return {
start,
stop,
};
}
/**
* 搜索最接近指定偏移量的项(索引)。比如3已经有一部分移出,但是仍然在可视范围内,那么返回3
*
* 如果未找到完全匹配项,将返回下一个最低的项目索引。这允许部分可见的项目(在折叠之前有偏移)可见。
*/
findNearestItem(offset: number) {
if (isNaN(offset)) {
throw Error(`Invalid offset ${offset} specified`);
}
// 我们的搜索算法在指定的偏移量或以下找到最接近的匹配。因此,请确保偏移量至少为0,否则将找不到匹配项
offset = Math.max(0, offset);
const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem();
const lastMeasuredIndex = Math.max(0, this.lastMeasuredIndex);
// 如果最后一个测量的项目的偏移量大于或等于我们正在搜索的偏移量,则我们可以使用二分查找(因为前面的项目的偏移量是已知的)
if (lastMeasuredSizeAndPosition.offset >= offset) {
// 如果我们已经测量了这个范围内的项目,只需使二分查找,因为它更快。
return this.binarySearch({
high: lastMeasuredIndex,
low: 0,
offset,
});
} else {
// 如果我们还没有测量到这个高度,则回退到内部二分的指数搜索。
// 指数搜索避免了像二分搜索那样预先计算整个项目集的大小。这种方法的总体复杂性为O(logn)
return this.exponentialSearch({
index: lastMeasuredIndex,
offset,
});
}
}
/**
* 此方法返回指定索引处项的大小和位置。它及时计算(或使用缓存值)通向索引的项目
*/
getSizeAndPositionForIndex(index: number) {
// 索引必须在0和itemCount之间
if (index < 0 || index >= this.itemCount) {
throw Error(`Requested index ${index} is outside of range 0..${this.itemCount}`);
}
// 如果我们已经测量了这个项目,那么我们可以跳过它
if (index > this.lastMeasuredIndex) {
// 从最后一个测量的项目开始,我们可以使用上一个项目的大小和位置来估计剩余的项目的位置。
const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem();
let offset = lastMeasuredSizeAndPosition.offset + lastMeasuredSizeAndPosition.size;
for (let i = this.lastMeasuredIndex + 1; i <= index; i++) {
// 遍历每一个元素的size,并存入缓存中以便下次使用
const size = this.itemSizeGetter(i);
if (size == null || isNaN(size)) {
throw Error(`Invalid size returned for index ${i} of value ${size}`);
}
// 缓存offset和size
this.itemSizeAndPositionData[i] = {
offset,
size,
};
// 更新offset
offset += size;
}
// 到index的准确距离都已经测量完毕,更新最后一个测量的项目的索引
this.lastMeasuredIndex = index;
}
return this.itemSizeAndPositionData[index];
}
/**
* @description 通过二分查找找到对应的索引
* @param low 最低的范围
* @param high 最高的范围
* @param offset 偏移量
* @private
*/
private binarySearch({ low, high, offset }: { low: number; high: number; offset: number }) {
let middle = 0;
let currentOffset = 0;
while (low <= high) {
middle = low + Math.floor((high - low) / 2);
currentOffset = this.getSizeAndPositionForIndex(middle).offset;
if (currentOffset === offset) {
return middle;
} else if (currentOffset < offset) {
low = middle + 1;
} else if (currentOffset > offset) {
high = middle - 1;
}
}
if (low > 0) {
return low - 1;
}
return 0;
}
/**
* @description 通过指数查找找到对应的索引
* @param index
* @param offset
* @private
*/
private exponentialSearch({ index, offset }: { index: number; offset: number }) {
let interval = 1;
// 通过指数查找找到比index大的最小值
while (index < this.itemCount && this.getSizeAndPositionForIndex(index).offset < offset) {
index += interval;
interval *= 2;
}
// 通过二分查找找到对应的索引
return this.binarySearch({
high: Math.min(index, this.itemCount - 1),
low: Math.floor(index / 2),
offset,
});
}
列表样式
const getItemStyle = (index: number): any => {
// 获取真正的索引值
index += event.start;
// 如果缓存中有,直接返回
const style = styleCache[index];
if (style) return style;
const { size, offset } = sizeAndPosManager.getSizeAndPositionForIndex(index);
const debugStyle = props.debug ? { backgroundColor: util.randomColor() } : null;
return (styleCache[index] = {
...STYLE_ITEM,
...debugStyle,
// 根据滚动方向设置宽高
[getCurrSizeProp()]: addUnit(size),
// 根据滚动方向设置偏移(top/left)
[positionProp[props.scrollDirection]]: addUnit(offset),
});
};
发生scroll事件
/**
* @description 当滚动条发生变化时,
*/
const handleScroll = (e: UIEvent) => {
const nodeOffset = getNodeOffset();
// 如果滚动条偏移量小于0,或者滚动条偏移量和上一次滚动条偏移量相等,或者滚动条不在根节点上,直接返回
if (nodeOffset < 0 || offset === nodeOffset || e.target !== rootNode.value) return;
// 更新滚动条偏移量
offset = nodeOffset;
// 设置修改原因为observed,不需要再次更新滚动条
scrollChangeReason = SCROLL_CHANGE_OBSERVED;
scrollRender();
};
/**
* @description 获取rootNode偏移量的值
*/
const getNodeOffset = () => {
return rootNode.value[getCurrScrollProp()];
};
/**
* @description 如果是水平的列表,则返回字符串’scrollTop‘,如果是垂直的列表,则返回字符串’scrollLeft‘
*/
const getCurrScrollProp = () => {
return scrollProp[scrollDirection.value];
};
监听事件
// 监听data的变化,重新计算尺寸
watch(
() => props.data,
(newVal, oldVal) => {
sizeAndPosManager.updateConfig({
itemCount: getItemCount(),
estimatedItemSize: getEstimatedItemSize(),
});
oldOffset = null;
recomputeSizes();
setDomStyle();
setTimeout(scrollRender, 0);
}
);
// scrollOffset变化时,将滚动条设置到指定位置
watch(
() => props.scrollOffset,
(newVal, oldVal) => {
offset = props.scrollOffset || 0;
scrollChangeReason = SCROLL_CHANGE_REQUESTED;
scrollRender();
}
);
// 监听scrollToIndex变化,将滚动条设置到指定位置
watch(
() => props.scrollToIndex,
(newVal, oldVal) => {
offset = getOffsetForIndex(props.scrollToIndex, props.scrollToAlignment, getItemCount());
scrollChangeReason = SCROLL_CHANGE_REQUESTED;
scrollRender();
}
);
onBeforeUnmount
onBeforeUnmount(() => {
clearStyleCache();
sizeAndPosManager.destroy();
util.removeEventListener(rootNode.value, "scroll", handleScroll);
});
/**
* @description 清理样式缓存
*/
const clearStyleCache = () => {
for (let key in styleCache) {
delete styleCache[key];
}
};
// 删除缓存
destroy() {
for (let key in this.itemSizeAndPositionData) {
delete this.itemSizeAndPositionData[key];
}
}
理解
- rootNode的overflow为auto,宽度或者高度设置为定值,另一个高度或宽度设置为100%
- innerNode的position设置为relative,隐藏滚动条,高度设置为估计值,由已经计算的最后一个值的offset和剩下的未计算值的个数*估计值
- 解决高度不定的问题(以竖滚动条为例):position设置为absolute,left设置为0,高度通过itemSize计算得出,上一个的top+上一个height就是当前元素的top,把已经计算的元素放入缓存中,并更新innerNode的高度,
- 解决动态渲染可视的元素:通过offset+容器的高度或者宽度(rootNode的),计算出最大的offset,通过缓存或者计算(二分查找和指数查找)找到最上方的可见元素的索引,即start的值,累加索引和offset,直到超过最大的offset为止,即stop的值,为两个各-+overscan的值,减少白屏和闪烁
- 解决如何滚动的问题:一开始为滚动条添加scroll事件,当滚动时更新滚动条偏移量,然后重新渲染
- 滚动到指定元素,offset设置到固定索引,但是滚动条本身没有动,所以还需要改变滚动条到位置
- 数据更新,已有的缓存已经失效,需要重新进行计算,同时更新itemCount,重新设置rootNode和innerNode的样式