定高与不定高虚拟列表实现
核心
虚拟列表的实现本质就是手动控制滚动需要显示的对应数据和偏移量,为此我们需要三个盒子来达成这个目的
- 容器盒子(父盒子):作为数据展示视口的父容器,固定高度
- 高度盒子(总高度盒子):作为高度计算的二级容器,用于计算总高度并撑开父盒子的高度产生滚动条
- 数据盒子(偏移盒子):作为数据展示视口的子容器,其高度为我们需要展示的数据高度,并计算每次容器盒子偏移时对应的偏移量
TIP
因为容器盒子高度是限制死的, 所以数据盒子需要同时计算盒子的偏移,举个例子: 当容器盒子高度为 750px,要展示 10 条数据,而 10 条数据包含图文总高度是 1200px,此时在容器盒子进行滚动时,同时也要得到当前滚动位置所需要的偏移量用于数据盒子
定高
核心滚动实现
ts
const handleScroll = (e: Event) => {
// 拿到当前滚动量
const target = e.target as HTMLDivElement;
const scrollTop = target.scrollTop;
let scrollIndex = 0; // 当前偏移量对应的元素索引
let offsetHeight = 0; // 当前偏移量对应的元素高度
for (let i = 0; i < dataArray.length; i++) {
offsetHeight += itemHeight; // 累计当前偏移量的元素的高度得到总高度
// 根据偏移量确定当前偏移到第几个元素
if (offsetHeight > scrollTop) {
scrollIndex = i;
break;
}
}
// 每次滚动记录偏移的变化,如果未发生变化则不改变数据
if (scrollIndex === recordIndex) return;
recordIndex = scrollIndex;
// 改变渲染数据,从拿到的对应元素索引位置取10个数据渲染
dataSource.value = dataArray.slice(scrollIndex, scrollIndex + 10);
// 总高度 - 当前偏移量对应的元素高度 = 实际数据盒子偏移量, 再减去当前元素的高度,保证刚好偏移到第一位元素的顶部
translateContainer!.style.transform = `translateY(${offsetHeight - itemHeight}px)`;
};
- 每次发生滚动时,我们首先拿到当前滚动量,然后遍历数据,累加当前偏移量的元素高度得到总高度,根据总高度和当前偏移量确定当前偏移到第几个元素
- 数据盒子所需要的偏移量实际就是总高度 - 当前偏移量之前所有元素的元素高度总和,因为我们要保证数据盒子的 10 条数据刚好是偏移到对应滚动量的位置
完整代码
点击查看
vue
<script setup lang="ts">
import type { Ref } from "vue";
import { ref, onMounted } from "vue";
// 类型定义
type Container = HTMLElement | null;
// 初始化数据
const dataArray = new Array(200).fill(null).map((_, index) => `${index}-----${Math.random().toString(36).slice(2, 10)}`);
const dataSource: Ref<string[]> = ref([]);
const itemHeight: number = 50; // 每条50高
const size: number = 10; // 一次最多渲染10条
let recordIndex: number = -1;
// DOM元素
let scrollContainer: Container = null;
let calculateContainer: Container = null;
let translateContainer: Container = null;
onMounted(() => {
initializeContainers();
setupScrollContainer();
renderViewData(0, size);
// 绑定滚动事件
if (scrollContainer) scrollContainer.addEventListener("scroll", handleScroll);
});
// 初始化容器
const initializeContainers = () => {
scrollContainer = document.querySelector(".scroll-wrap");
calculateContainer = document.querySelector(".calculate-wrap");
translateContainer = document.querySelector(".translate-wrap");
};
// 设置滚动容器样式
const setupScrollContainer = () => {
if (scrollContainer) {
scrollContainer.style.height = `${itemHeight * size}px`;
scrollContainer.style.overflowY = "auto";
}
if (calculateContainer) {
calculateContainer.style.height = `${itemHeight * dataArray.length}px`;
}
};
// 渲染数据显示
const renderViewData = (min: number, max: number): void => {
dataSource.value = dataArray.slice(min, max);
};
const handleScroll = (e: Event) => {
// 拿到当前滚动量
const target = e.target as HTMLDivElement;
const scrollTop = target.scrollTop;
let scrollIndex = 0; // 当前偏移量对应的元素索引
let offsetHeight = 0; // 当前偏移量对应的元素高度
for (let i = 0; i < dataArray.length; i++) {
offsetHeight += itemHeight; // 累计当前偏移量的元素的高度得到总高度
// 根据偏移量确定当前偏移到第几个元素
if (offsetHeight > scrollTop) {
scrollIndex = i;
break;
}
}
// 每次滚动记录偏移的变化,如果未发生变化则不改变数据
if (scrollIndex === recordIndex) return;
recordIndex = scrollIndex;
// 改变渲染数据,从拿到的对应元素索引位置取10个数据渲染
renderViewData(scrollIndex, scrollIndex + 10);
// 总高度 - 当前偏移量对应的元素高度 = 实际数据盒子偏移量, 再减去当前元素的高度,保证刚好偏移到第一位元素的顶部
translateContainer!.style.transform = `translateY(${offsetHeight - itemHeight}px)`;
};
</script>
<template>
<div class="scroll-wrap">
<div class="calculate-wrap">
<div class="translate-wrap">
<ul class="render-item">
<li v-for="item in dataSource" class="item-li" :key="item">
{{ item }}
</li>
</ul>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.translate-wrap {
display: flex;
justify-content: center;
}
.render-item {
width: 800px;
li {
display: flex;
justify-content: center;
align-items: center;
list-style: none;
padding: 20px;
height: 50px;
box-sizing: border-box;
}
}
</style>
不定高
与定高不同,此时所有元素高度都是未知的,我们需要依赖一个缓存变量来记录所有元素对应的高度,一开始所有元素是固定高的(不准确的估算值),当元素产生滚动时,将偏移量和元素的高度缓存交给微任务(保证偏移和高度的重新赋值和计算发生在下一次的渲染中)
每次滚动过后我们能通过缓存变量拿到最新的元素实际高度,再通过实际高度计算出偏移量,从而实现滚动时元素真正的位置变化
缓存元素高度
ts
const getItemHeight = (startIndex: number) => {
return itemCacheHeight[startIndex] ? itemCacheHeight[startIndex] : itemHeight;
};
重新计算偏移量
每次偏移量计算都要从缓存中重新拿到最新的实际高度值;
ts
// 重新计算实际偏移
const handleTranslate = (scrollTop: number) => {
let scrollIndex = 0;
let offsetHeight = 0;
for (let i = 0; i < dataArray.length; i++) {
offsetHeight += getItemHeight(i); // 每次从缓存中取元素高度, 每次偏移都更新缓存
// 从缓存读取总高度进行比较
if (offsetHeight > scrollTop) {
scrollIndex = i;
break;
}
}
let offsetFirst = itemCacheHeight[scrollIndex] ? itemCacheHeight[scrollIndex] : itemHeight;
translateContainer!.style.transform = `translateY(${offsetHeight - offsetFirst}px)`;
return scrollIndex;
};
更新高度
每次滚动后,重新计算总高度,并将新渲染的数据实际高度缓存起来, 并 i 保证每次更新高度同时都重新计算偏移量,否则偏移量会不准确
ts
// 增加微任务获取偏移实际的元素高度
const updateItemsHeight = (scrollIndex: number, scrollTop: number) => {
if (Object.keys(itemCacheHeight).length >= dataArray.length) return; // 如果已缓存实际高度元素到达上限则不再进行更新
nextTick(() => {
const Items: HTMLElement[] = Array.from(document.querySelectorAll(".item-li")); // 拿到当前页面渲染后的列表项
// 根据当前渲染的元素缓存真正的元素高度
Items.forEach((item: HTMLElement) => {
if (!itemCacheHeight[scrollIndex]) {
itemCacheHeight[scrollIndex] = item.clientHeight;
}
scrollIndex++; // 所有偏移都更新缓存
});
// 然后更新当前偏移容器的高度,因为实际元素高度发生了变化
let newHeight = 0;
for (let i = 0; i < dataArray.length; i++) {
newHeight += getItemHeight(i); // 每次从缓存中取实际的元素高度
}
// 重新赋值总高度
calculateContainer!.style.height = newHeight + "px";
// 赋值总高度后再重新计算一次对应的偏移量
handleTranslate(scrollTop);
});
};
核心滚动事件
ts
const handleScroll = (e: Event) => {
// 拿到当前滚动量
const target = e.target as HTMLDivElement;
const scrollTop = target.scrollTop;
// 计算偏移量和拿到对应偏移位置
const scrollIndex = handleTranslate(scrollTop);
// 每次滚动记录偏移变化,如果未发生变化则不改变
if (scrollIndex === recordIndex) return;
recordIndex = scrollIndex;
// 更新一次缓存数据 并记录真实的元素高度,并且内部会在下一次DOM渲染之前再重新计算一次偏移量保证偏移和高度一致
updateItemsHeight(scrollIndex, scrollTop);
// 改变对应的渲染数据
renderViewData(scrollIndex, scrollIndex + 10);
};
完整代码
点击查看
vue
<script setup lang="ts">
import type { Ref } from "vue";
import { ref, onMounted, nextTick } from "vue";
// 类型定义
type Container = HTMLElement | null;
// 生成一个随机字符内容
const generateRandomString = (index: number, minLength: number, maxLength: number) => {
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 ";
const length = Math.floor(Math.random() * (maxLength - minLength + 1)) + minLength;
let result = "";
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * characters.length));
}
return `${index}-----${result}`;
};
// 初始化变量
const dataArray = new Array(200).fill(null).map((_, index) => generateRandomString(index, 100, 1000));
const dataSource: Ref<string[]> = ref([]);
const itemHeight = 50; // 默认元素高度
const size = 10; // 一次最多渲染10条
let recordIndex = -1;
// DOM元素
let scrollContainer: Container = null;
let calculateContainer: Container = null;
let translateContainer: Container = null;
// 缓存元素高度变量
const itemCacheHeight: Record<number, number> = {};
onMounted(() => {
initializeContainers();
setupScrollContainer();
renderViewData(0, size);
// 绑定滚动事件
if (scrollContainer) scrollContainer.addEventListener("scroll", handleScroll);
});
// 初始化容器
const initializeContainers = () => {
scrollContainer = document.querySelector(".scroll-wrap");
calculateContainer = document.querySelector(".calculate-wrap");
translateContainer = document.querySelector(".translate-wrap");
};
// 设置滚动容器样式
const setupScrollContainer = () => {
if (scrollContainer) {
scrollContainer.style.height = `${itemHeight * size}px`;
scrollContainer.style.overflowY = "auto";
}
if (calculateContainer) {
calculateContainer.style.height = `${itemHeight * dataArray.length}px`;
}
};
// 渲染数据显示
const renderViewData = (min: number, max: number): void => {
dataSource.value = dataArray.slice(min, max);
};
// 从缓存中获取元素高度 缓存不存在时读取默认高度
const getItemHeight = (startIndex: number) => {
return itemCacheHeight[startIndex] ? itemCacheHeight[startIndex] : itemHeight;
};
// 增加微任务获取偏移实际的元素高度
const updateItemsHeight = (scrollIndex: number, scrollTop: number) => {
if (Object.keys(itemCacheHeight).length >= dataArray.length) return; // 如果已缓存实际高度元素到达上限则不再进行更新
nextTick(() => {
const Items: HTMLElement[] = Array.from(document.querySelectorAll(".item-li")); // 拿到当前页面渲染后的列表项
// 根据当前渲染的元素缓存真正的元素高度
Items.forEach((item: HTMLElement) => {
if (!itemCacheHeight[scrollIndex]) {
itemCacheHeight[scrollIndex] = item.clientHeight;
}
scrollIndex++; // 所有偏移都更新缓存
});
// 然后更新当前偏移容器的高度,因为实际元素高度发生了变化
let newHeight = 0;
for (let i = 0; i < dataArray.length; i++) {
newHeight += getItemHeight(i); // 每次从缓存中取实际的元素高度
}
// 重新赋值总高度
calculateContainer!.style.height = newHeight + "px";
// 赋值总高度后再重新计算一次对应的偏移量
handleTranslate(scrollTop);
});
};
// 重新计算实际偏移
const handleTranslate = (scrollTop: number) => {
let scrollIndex = 0;
let offsetHeight = 0;
for (let i = 0; i < dataArray.length; i++) {
offsetHeight += getItemHeight(i); // 每次从缓存中取元素高度, 每次偏移都更新缓存
// 从缓存读取总高度进行比较
if (offsetHeight > scrollTop) {
scrollIndex = i;
break;
}
}
let offsetFirst = itemCacheHeight[scrollIndex] ? itemCacheHeight[scrollIndex] : itemHeight;
translateContainer!.style.transform = `translateY(${offsetHeight - offsetFirst}px)`;
return scrollIndex;
};
const handleScroll = (e: Event) => {
// 拿到当前滚动量
const target = e.target as HTMLDivElement;
const scrollTop = target.scrollTop;
// 计算偏移量
const scrollIndex = handleTranslate(scrollTop);
// 每次滚动记录偏移变化,如果未发生变化则不改变
if (scrollIndex === recordIndex) return;
recordIndex = scrollIndex;
// 更新一次缓存数据 并记录真实的元素高度,并且内部会在下一次DOM渲染之前再重新计算一次偏移量保证偏移和高度一致
updateItemsHeight(scrollIndex, scrollTop);
// 改变对应的渲染数据
renderViewData(scrollIndex, scrollIndex + 10);
};
</script>
<template>
<div class="scroll-wrap">
<div class="calculate-wrap">
<div class="translate-wrap">
<ul class="render-item">
<li v-for="item in dataSource" class="item-li" :key="item">
{{ item }}
</li>
</ul>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.translate-wrap {
display: flex;
justify-content: center;
}
.render-item {
width: 800px;
li {
display: flex;
justify-content: center;
align-items: center;
list-style: none;
padding: 20px;
white-space: wrap;
word-break: break-all;
box-sizing: border-box;
}
}
</style>