什么是虚拟列表?
虚拟列表相对于普通列表的区别就是,虚拟列表只展示可视区需要展示的列表项,并通过监听滚动实时更新列表项,从而避免对整个列表进行渲染。对于大数据量的列表展示,虚拟列表是一个很好的解决方案。
虚拟列表dom结构组成
<!-- 可视区(container) 滚动容器 -->
<div ref="containerRef" class="container" @scroll="scrollEvent">
<!-- 内容虚拟撑开区(phantom) -->
<div class="phantom"></div>
<!-- 内容可见区(content) -->
<div class="content">
<div v-for="item in actualRenderData" :key="item.index" class="content-item">
{{ item.text }}
</div>
</div>
</div>
.container {
width: 300px;
height: 500px;
-webkit-overflow-scrolling: touch;
overflow: auto;
position: relative;
background-color: aqua;
}
.content {
position: absolute;
left: 0;
top: 0;
width: 100%;
background-color: yellow;
}
- 可视区:即滚动容器,需指定高度height,并添加overflow: auto css样式,从而实现滚动;
- 虚拟区:用来撑开可视区高度,它的高度为列表中所有子项的高度之和,我们借助它来实现可视区的滚动效果;
- 内容区:展示实际渲染的列表项,滚动的时候内容区里面的元素是动态变化的,且为了实现渲染的数据始终处于可视区,针对内容区进行处理,给它设置
transform: translateY(n)值。
使用hooks实现虚拟列表
不封装成组件的目的就是为了让此方法更加的通用,不局限外部使用的第三方组件或自己封装的组件,让其既支持 table 形式,又让其支持普通的 list 形式,还能让其支持 select 形式。
完整代码
// useVirtualList.ts 优化虚拟列表(增加缓冲区以及滚动节流)
import { ref, onMounted, onBeforeUnmount, nextTick } from "vue";
import type { Ref } from "vue";
interface Config {
data: Ref<any[]>; // 数据源
scrollContainer: string; // 滚动容器的元素选择器(可视区)
actualHeightContainer: string; // 用于撑开高度的元素选择器(虚拟区)
translateContainer: string; // 用于偏移的元素选择器(内容区)
itemContainer: string; // 列表项选择器(内容区列表项)
itemHeight: number; // 列表项高度(根据具体使用场景进行估算 用于给撑开高度的元素设置初始高度)
bufferRatio: number; // 缓冲区比例(缓冲区数据与可视区数据的比例)
}
type HtmlElType = HTMLElement | null;
type timeout = string | number | undefined | null;
export default function useOptimizeVirtualList(config: Config) {
// 获取元素
let actualHeightContainerEl: HtmlElType = null,
translateContainerEl: HtmlElType = null,
scrollContainerEl: HtmlElType = null;
let size = 10; // 可视区展示列表项个数
// 数据源,便于后续直接访问
let dataSource: any[] = [];
onMounted(() => {
scrollContainerEl = document.querySelector(config.scrollContainer);
actualHeightContainerEl = document.querySelector(config.actualHeightContainer);
translateContainerEl = document.querySelector(config.translateContainer);
// 获取可视区的高度,用于计算可视区的展示列表项个数
// @ts-expect-error 这里是获取元素高度,所以可以忽略类型错误
size = Math.ceil(scrollContainerEl.clientHeight / config.itemHeight);
dataSource = config.data.value;
// 根据滚动高度计算需要渲染的数据
updateActualRenderData(0);
});
// 数据源发生变动
watch(
() => config.data.value,
newVla => {
// 更新数据源
dataSource = newVla;
// 计算需要渲染的数据
updateActualRenderData(0);
}
);
// 缓存已渲染元素的高度
const renderedItemsCache: any = {};
// 获取缓存高度,无缓存,取配置项的 itemHeight
const getItemHeightFromCache = (index: number | string) => {
const val = renderedItemsCache[index];
return val === void 0 ? config.itemHeight : val;
};
/**
* 更新虚拟区实际高度
*/
const updateActualHeight = () => {
// 计算所有项目的总高度
const totalHeight = dataSource.reduce((accumulator, _, index) => {
try {
// 从缓存中获取每个项目的高度并累加
return accumulator + getItemHeightFromCache(index);
} catch (error) {
// 如果获取项目高度时发生错误,打印错误信息并保持当前累加值不变
console.error(`Error getting height for item ${index}:`, error);
return accumulator;
}
}, 0);
// 更新容器的高度
if (actualHeightContainerEl) {
// 如果容器元素存在,则设置其高度为计算出的总高度
actualHeightContainerEl.style.height = totalHeight + "px";
} else {
// 如果容器元素未定义,则打印错误信息
console.error("actualHeightContainerEl is not defined");
}
};
// 更新已渲染列表项的缓存高度
const updateRenderedItemCache = (index: number) => {
const start = index;
// 当所有元素的实际高度更新完毕,就不需要重新计算高度
const shouldUpdate = Object.keys(renderedItemsCache).length < dataSource.length;
if (!shouldUpdate) return;
nextTick(() => {
// 获取所有列表项元素
const Items: HTMLElement[] = Array.from(document.querySelectorAll(config.itemContainer));
// 进行缓存
Items.forEach(el => {
if (!renderedItemsCache[index]) {
renderedItemsCache[index] = el.offsetHeight;
}
index++;
});
// 更新实际高度
updateActualHeight();
// 更新偏移值
getOffsetY(start);
});
};
// 实际渲染的数据
const actualRenderData: Ref<any[]> = ref([]);
// // 更新实际渲染数据
// const updateActualRenderData = (scrollTop: number) => {
// let startIndex = 0;
// let endIndex = 0;
// let offsetHeight = 0;
// // 根据滚动高度找到渲染列表的开始位置
// for (let i = 0; i < dataSource.length; i++) {
// offsetHeight += getItemHeightFromCache(i);
// if (offsetHeight >= scrollTop) {
// startIndex = i;
// endIndex = i + size;
// break;
// }
// }
// // 起始缓冲数量
// const aboveCount = Math.min(startIndex, size * config.bufferRatio);
// // 终止缓冲数量
// const belowCount = Math.min(config.data.value.length - endIndex, size * config.bufferRatio);
// // 计算得出的渲染数据
// let start = startIndex - aboveCount;
// const end = endIndex + belowCount;
// actualRenderData.value = dataSource.slice(start, end);
// // 缓存渲染列表中各个列表项高度
// updateRenderedItemCache(start);
// };
const updateActualRenderData = (scrollTop: number) => {
// 异常处理
if (!dataSource || !config) {
console.error("dataSource or config is undefined");
return;
}
let startIndex = 0;
let endIndex = 0;
let offsetHeight = 0;
// 使用二分查找来加速 startIndex 的计算
let low = 0;
let high = dataSource.length - 1;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
offsetHeight = getAccumulatedHeight(mid);
if (offsetHeight >= scrollTop) {
high = mid - 1;
} else {
low = mid + 1;
}
}
startIndex = low;
endIndex = startIndex + size;
// 边界条件处理 确保 startIndex 和 endIndex 在有效范围内
startIndex = Math.max(0, startIndex);
endIndex = Math.min(dataSource.length, endIndex);
// 起始缓冲数量
const aboveCount = Math.min(startIndex, size * config.bufferRatio);
// 终止缓冲数量
const belowCount = Math.min(config.data.value.length - endIndex, size * config.bufferRatio);
// 计算得出的渲染数据
let start = startIndex - aboveCount;
const end = endIndex + belowCount;
actualRenderData.value = dataSource.slice(start, end);
// 缓存渲染列表中各个列表项高度
updateRenderedItemCache(start);
nextTick(() => {
// 更新偏移值
getOffsetY(start);
});
};
// 辅助函数:获取累积高度
const getAccumulatedHeight = (index: number): number => {
let height = 0;
for (let i = 0; i <= index; i++) {
height += getItemHeightFromCache(i);
}
return height;
};
// 获取偏移量
const getOffsetY = start => {
let startOffset = 0;
if (start >= 1) {
startOffset = new Array(start).fill("").reduce((acc, _, index) => {
return acc + getItemHeightFromCache(index);
}, 0);
}
translateContainerEl!.style.transform = `translateY(${startOffset}px)`;
};
// 滚动事件
const handleScroll = (e: any) => {
// 渲染正确的数据
updateActualRenderData(e.target.scrollTop);
};
// 注册滚动事件
onMounted(() => {
scrollContainerEl?.addEventListener("scroll", throttle(handleScroll, 100));
});
// 移除滚动事件
onBeforeUnmount(() => {
scrollContainerEl?.removeEventListener("scroll", handleScroll);
});
function throttle(fn, delay) {
// last为上一次触发回调的时间, timer是定时器
let last = 0,
timer: timeout = null;
// 将throttle处理结果当作函数返回
return function (...args) {
// 保留调用时的this上下文
const context = this;
// 保留调用时传入的参数
// let args = arguments;
// 记录本次触发回调的时间
let now = +new Date();
// 判断上次触发的时间和本次触发的时间差是否小于时间间隔的阈值
if (now - last < delay) {
// 如果时间间隔小于我们设定的时间间隔阈值,则为本次触发操作设立一个新的定时器
clearTimeout(timer);
timer = setTimeout(function () {
last = now;
fn.apply(context, args);
}, delay);
} else {
// 如果时间间隔超出了我们设定的时间间隔阈值,那就不等了,无论如何要反馈给用户一次响应
last = now;
fn.apply(context, args);
}
};
}
return { actualRenderData };
}
代码解析
<script setup>
import { ref } from "vue";
import { faker } from "@faker-js/faker";
import useOptimizeVirtualList from "@/hooks/useOptimizeVirtualList";
// 列表数据
const listData = ref(new Array(1000).fill({}).map((item, index) => ({ index, text: index + "、" + faker.lorem.sentences() })));
const { actualRenderData } = useOptimizeVirtualList({
data: listData, // 列表项数据
scrollContainer: ".container", // 滚动容器
actualHeightContainer: ".phantom", // 渲染实际高度的容器
translateContainer: ".content", // 需要偏移的目标元素,
itemContainer: ".content-item", // 列表项
itemHeight: 100, // 列表项的大致高度
bufferRatio: 1 // 缓冲比例
});
</script>
使用hooks进行封装,在需要使用虚拟列表的Vue文件中,引入@/hooks/useOptimizeVirtualList,调用useOptimizeVirtualList方法,传入列表数据、可视区/虚拟区/内容区/列表项的元素选择器、估算的单个列表项大致高度以及缓冲比例,即可得到内容区需要渲染的列表数据。
interface Config {
data: Ref<any[]>; // 数据源
scrollContainer: string; // 滚动容器的元素选择器(可视区)
actualHeightContainer: string; // 用于撑开高度的元素选择器(虚拟区)
translateContainer: string; // 用于偏移的元素选择器(内容区)
itemContainer: string; // 列表项选择器(内容区列表项)
itemHeight: number; // 列表项高度(根据具体使用场景进行估算 用于给撑开高度的元素设置初始高度)
bufferRatio: number; // 缓冲区比例(缓冲区数据与可视区数据的比例)
}
使用ts定义传入的数据类型
onMounted(() => {
scrollContainerEl = document.querySelector(config.scrollContainer);
actualHeightContainerEl = document.querySelector(config.actualHeightContainer);
translateContainerEl = document.querySelector(config.translateContainer);
// 获取可视区的高度,用于计算可视区的展示列表项个数
// @ts-expect-error 这里是获取元素高度,所以可以忽略类型错误
size = Math.ceil(scrollContainerEl.clientHeight / config.itemHeight);
dataSource = config.data.value;
// 根据滚动高度计算需要渲染的数据
updateActualRenderData(0);
});
在onMounted生命周期即组件挂载完成后
- 通过document.querySelector以及传入的元素选择器获取dom元素;
- 根据可视区的高度以及传入的单个列表项大致高度,算出可视区展示的列表项个数;
- 用变量dataSource存储传入的列表数据,调用updateActualRenderData方法算出内容区需要渲染的列表项、缓存渲染列表中各个列表项高度并更新内容区的偏移值**
transform: translateY(n)** 。
const updateActualRenderData = (scrollTop: number) => {
// 异常处理
if (!dataSource || !config) {
console.error("dataSource or config is undefined");
return;
}
let startIndex = 0;
let endIndex = 0;
let offsetHeight = 0;
// 使用二分查找来加速 startIndex 的计算
let low = 0;
let high = dataSource.length - 1;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
offsetHeight = getAccumulatedHeight(mid);
if (offsetHeight >= scrollTop) {
high = mid - 1;
} else {
low = mid + 1;
}
}
startIndex = low;
endIndex = startIndex + size;
// 边界条件处理 确保 startIndex 和 endIndex 在有效范围内
startIndex = Math.max(0, startIndex);
endIndex = Math.min(dataSource.length, endIndex);
// 起始缓冲数量
const aboveCount = Math.min(startIndex, size * config.bufferRatio);
// 终止缓冲数量
const belowCount = Math.min(config.data.value.length - endIndex, size * config.bufferRatio);
// 计算得出的渲染数据
let start = startIndex - aboveCount;
const end = endIndex + belowCount;
actualRenderData.value = dataSource.slice(start, end);
// 缓存渲染列表中各个列表项高度
updateRenderedItemCache(start);
nextTick(() => {
// 更新偏移值
getOffsetY(start);
});
};
// 辅助函数:获取累积高度
const getAccumulatedHeight = (index: number): number => {
let height = 0;
for (let i = 0; i <= index; i++) {
height += getItemHeightFromCache(i);
}
return height;
};
- 在updateActualRenderData方法中使用二分法快速计算当前滚动高度scrollTop对应的 startIndex以及endIndex;
- 根据传入的缓冲比例算出前置缓冲数量aboveCount以及后置缓冲数量belowCount,起始索引startIndex需要减去加入的前置缓冲元素aboveCount,结束的索引endIndex需要加上后置缓冲区的元素belowCount,从而通过dataSource.slice(start, end)计算出渲染数据。增加缓冲区可以解决快速滚动时,可能会出现子项元素未占满可视区的现象。
/**
* 更新虚拟区实际高度
*/
const updateActualHeight = () => {
// 计算所有项目的总高度
const totalHeight = dataSource.reduce((accumulator, _, index) => {
try {
// 从缓存中获取每个项目的高度并累加
return accumulator + getItemHeightFromCache(index);
} catch (error) {
// 如果获取项目高度时发生错误,打印错误信息并保持当前累加值不变
console.error(`Error getting height for item ${index}:`, error);
return accumulator;
}
}, 0);
// 更新容器的高度
if (actualHeightContainerEl) {
// 如果容器元素存在,则设置其高度为计算出的总高度
actualHeightContainerEl.style.height = totalHeight + "px";
} else {
// 如果容器元素未定义,则打印错误信息
console.error("actualHeightContainerEl is not defined");
}
};
// 更新已渲染列表项的缓存高度
const updateRenderedItemCache = (index: number) => {
// 当所有元素的实际高度更新完毕,就不需要重新计算高度
const shouldUpdate = Object.keys(renderedItemsCache).length < dataSource.length;
if (!shouldUpdate) return;
nextTick(() => {
// 获取所有列表项元素
const Items: HTMLElement[] = Array.from(document.querySelectorAll(config.itemContainer));
// 进行缓存
Items.forEach(el => {
if (!renderedItemsCache[index]) {
renderedItemsCache[index] = el.offsetHeight;
}
index++;
});
// 更新实际高度
updateActualHeight();
});
};
在updateActualRenderData方法算出内容区需要渲染的列表项后,调用updateRenderedItemCache更新当前内容区渲染的列表项的缓存高度,再调用updateActualHeight更新虚拟区实际高度。
// 获取偏移量
const getOffsetY = start => {
let startOffset = 0;
if (start >= 1) {
startOffset = new Array(start).fill("").reduce((acc, _, index) => {
return acc + getItemHeightFromCache(index);
}, 0);
}
translateContainerEl!.style.transform = `translateY(${startOffset}px)`;
};
通过getOffsetY设置内容区的偏移量,因为内容区是相对于可视区绝对定位的,如果不设置偏移量,内容区跟随滚动条滚动而脱离可视区。至于传入的参数为啥是startIndex - aboveCount,而不是startIndex,我一直没想通,脑容量不够!!!
// 注册滚动事件
onMounted(() => {
scrollContainerEl?.addEventListener("scroll", throttle(handleScroll, 100));
});
// 移除滚动事件
onBeforeUnmount(() => {
scrollContainerEl?.removeEventListener("scroll", handleScroll);
});
function throttle(fn, delay) {
// last为上一次触发回调的时间, timer是定时器
let last = 0,
timer: timeout = null;
// 将throttle处理结果当作函数返回
return function (...args) {
// 保留调用时的this上下文
const context = this;
// 保留调用时传入的参数
// let args = arguments;
// 记录本次触发回调的时间
let now = +new Date();
// 判断上次触发的时间和本次触发的时间差是否小于时间间隔的阈值
if (now - last < delay) {
// 如果时间间隔小于我们设定的时间间隔阈值,则为本次触发操作设立一个新的定时器
clearTimeout(timer);
timer = setTimeout(function () {
last = now;
fn.apply(context, args);
}, delay);
} else {
// 如果时间间隔超出了我们设定的时间间隔阈值,那就不等了,无论如何要反馈给用户一次响应
last = now;
fn.apply(context, args);
}
};
}
通过节流限制监听可视区滚动事件的频率,避免短时间会触发多次计算,从而造成页面卡顿等性能问题。
总结
通过增加缓冲区解决快速滚动时可视区展示不完整的问题,但是通过getOffsetY计算内容区的偏移量时至于传入的参数为啥是startIndex - aboveCount,而不是startIndex,我一直没想通,脑容量不够!!!