虚拟列表
最基本的计算值
- startIndex 起始索引
- endIndex 结束索引
- scrollTop 用户滑动的距离
- startOffset 准确的偏移量
针对千万级数据需要考虑的因素
-
数据量过多
- 直接放在普通数组中会炸开
- 直接设置响应式,vue3拦截处理也会造成阻塞
- 仅通过遍历数据和scrollTop比对查找,复杂度为O(n)
-
动态高度
- 图片加载完成
- 文字内容不同
- Dom的高度瞬间改变会导致用户所看到的图片被压下去(如果在视口上方)
-
快读移动
- 计算慢,会导致白屏
解决方式
-
先实现最基础的虚拟列表,滚动层占位层视页层
-
引入ResizeObsever 维护heights数组,动态高度问题
-
将数组换为Float32Array 二分查找定位起点 惰性求值,性能速度优化
-
跳动补偿,防止突变高度导致视野区受干扰
重要理解
用户看到的内容是 出现在窗口中的渲染视区层 而占位层提供的占位器会导致整个窗口向下移动,而渲染视区层和占位层都使用的是绝对定位,top:0。当窗口下移的时候,渲染视区层不会跟着下移,用户此时看到的是空白内容,因为我们只渲染了需要的DOM节点,并未将所有DOM节点渲染,当窗口下移时,渲染视区层的没有DOM啊,这就是一片空白。可以将整个过程看作"在望远镜中看天空",望远镜就是窗口,天空就是渲染视区层,而占位层形成的滚动条是让望远镜上下移动的转轴。
我们的渲染视区层与天空不同,只有一部分是有星星的(内容),其余皆是黑暗。所以偏移量就是让你的渲染视区层也向下移动,同时也只移动被完全销毁的DOM节点数。
代码实现
javascript
<template>
<div
class="virtual-list-container"
ref="containerRef"
@scroll="onScroll"
:style="{ height: containerHeight || '100%' }"
>
<!-- 占位层:构造全部数据的预期物理总高度,撑开原生滚动条 -->
<div
class="virtual-list-phantom"
:style="{ height: listTotalHeight + 'px' }"
></div>
<!-- 渲染层:采用绝对定位,随滑动动态偏移至视区 -->
<div
class="virtual-list-content"
:style="{ transform: `translate3d(0, ${startOffset}px, 0)` }"
>
<template v-for="node in visibleData" :key="node.__index">
<!-- 单个节点包裹层:绑定 Ref 用于 ResizeObserver 监听真实高度 -->
<div
class="virtual-list-item-wrapper"
:data-index="node.__index"
:ref="(el) => setItemRef(el, node.__index)"
>
<!-- 作用域插槽:对外抛出数据字典,实现 headless API 逻辑解耦 -->
<!-- 严密的空指针防御 / 极速拖拽处理 -->
<slot
:item="node.data"
:index="node.__index"
:loading="node.data == null"
>
<!-- 默认防跌落骨架屏插槽 -->
<div v-if="node.data == null" class="default-skeleton">
Loading chunk {{ node.__index }}...
</div>
</slot>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue';
interface VirtualListProps {
total: number; // 千万级数据总长度
dataSource: any[]; // 数据源(可含洞,未加载的数据为 undef / null)
estimatedItemHeight?: number; // 默认预估高度
bufferSize?: number; // 缓冲区数量
containerHeight?: string; // 整个列表容器的高度
}
// 为props设置默认值
const props = withDefaults(defineProps<VirtualListProps>(), {
estimatedItemHeight: 50,
bufferSize: 10,
containerHeight: '100%'
});
// 解耦流控:滚动彻底停顿或低速时才抛出真实视窗范围,用于发请求
const emit = defineEmits<{
(e: 'range-change', start: number, end: number): void;
}>();
// =================【 1. 核心状态 Refs 】=================
const containerRef = ref<HTMLElement | null>(null);
// 性能黑科技,由于数据过多,定义一个保存所有数据的数组时,vue的响应式会奔溃
// 需要拦截添加响应式,这样会卡死,所以我们设置为普通变量
// 当计算属性依赖的数据不是响应式的(也及时我们设置的普通变量)
// 但这些数据变化后,我需要强制计算属性重新执行
// 就可以用一个 trigger 响应式变量来 "手动触发" 更新
const trigger = ref(0);
const startIndex = ref(0);
const endIndex = ref(0);
const startOffset = ref(0);
const itemRefs = new Map<number, HTMLElement>();
// 状态标志
let isScrollJumping = false; // 用于标识因计算导致的坐标跳动补偿
let scrollDebounceTimer: number | null = null; // 请求防抖
// =================【 2. O(1) 终极性能 Cache 模型 】=================
// 常规的对象缓存数组在千万级下直接内存爆炸 (10M * 3属性 * 8字节 + 对象头 ≈ 1000MB+)
// 这里采用 TypedArray 占用连续内存,极大降低堆积与 V8 GC 压力 (约 10M * ~12 Byte = 120MB)
// 记载每个盒子的高度(可能动态改变)
let heights: Float32Array;
// 这个盒子的顶部离最上面的起点的绝对距离
let tops: Float64Array;
// 这个盒子的底部离最上面的起点的绝对距离 top+height
let bottoms: Float64Array;
// 惰性游标指针
// 如果初始值为-1 一项都没测过
// 滚动到20行 lastMeasuredIndex为19
let lastMeasuredIndex = -1;
// 初始化坐标池
const initCache = () => {
// Float32Array(length)
heights = new Float32Array(props.total); //存储每条高度的数组
tops = new Float64Array(props.total); //存储每条数据距离视口最顶部的距离
bottoms = new Float64Array(props.total); //存储每条数据距离视口最底部的距离
// 默认预估高度(.fill(value) 所有数组中的元素都将是value默认值)
heights.fill(props.estimatedItemHeight);
// 初始状态:没有任何数据被测量过
lastMeasuredIndex = -1;
};
// 惰性递推计算:用到了哪里,就算到哪里,优化内部循环性能
// 用户滑动到15(index)那我就只算15(index)之前和15条(index)的内容,15之后的内容等用户滑动到16的时候再算
const processPositionsUpToIndex = (index: number) => {
if (index <= lastMeasuredIndex) return; // O(1) 短路
let lastBottom = lastMeasuredIndex >= 0 ? bottoms[lastMeasuredIndex] : 0;
// 上一次算完的最后一项+1
const startIndex = Math.max(0, lastMeasuredIndex + 1);
// 极致优化的紧凑循环,消除原先的大量数组寻址和 if 判断
for (let i = startIndex; i <= index; i++) {
// 当前项 top = 上一项 bottom
// 当前项 bottom = 当前项 top + 当前项高度
tops[i] = lastBottom;
lastBottom += heights[i];
bottoms[i] = lastBottom;
}
lastMeasuredIndex = index;
};
// 获取某一项的bottom时
const getBottom = (index: number) => {
processPositionsUpToIndex(index);
return bottoms[index];
};
// 获取某一项的top时
const getTop = (index: number) => {
processPositionsUpToIndex(index);
return tops[index];
};
// =================【 3. 动态高度 & 坐标补偿机制 】=================
// 动态高度自愈护板,同时offsetCorrection用于修正滚动条位置
// ResizeObserver 浏览器原生自带的"DOM尺寸监听工具"
let ro: ResizeObserver | null = null;
onMounted(() => {
initCache();
ro = new ResizeObserver((entries) => {
let heightChanged = false; //高度是否变化
let offsetCorrection = 0; //作用:记录所需修正画面的滚动距离
// entries = 数组(里面装着所有刚刚发生了尺寸变化的DOM信息)
entries.forEach((entry) => {
const el = entry.target as HTMLElement; //拿到dom元素
const index = Number(el.dataset.index); //这个DOM是列表的第几条,index是模版中绑定的
if (Number.isNaN(index)) return;
const newHeight = entry.borderBoxSize?.[0]?.blockSize ?? el.getBoundingClientRect().height;
const oldHeight = heights[index];
// 精度容差防抖(abs取绝对值,只有大于0.1px我再进行修改)
if (newHeight && Math.abs(newHeight - oldHeight) > 0.1) {
// [动态鲁棒性]: 如果发生改变的 DOM 在当前可视区上方,会打破视角的偏移对立
// 我们必须动态回补差值拉伸,绝对消除 Visual Jumping (画面跳窗) 现象!
if (index < startIndex.value) {
offsetCorrection += (newHeight - oldHeight);
}
heights[index] = newHeight;
// 从这个index开始,后面已经计算过的所有位置全部作废,下次重新计算
lastMeasuredIndex = Math.min(lastMeasuredIndex, index - 1);
// 标记高度发生变化
heightChanged = true;
}
});
if (heightChanged) {
if (offsetCorrection !== 0 && containerRef.value) {
isScrollJumping = true; // 上锁:拦截由于补偿拉伸而虚假触发的原生 Scroll 侦听
//视区上方盒子高度变化(高了50),那么我们手动让滚动条往下走50
containerRef.value.scrollTop += offsetCorrection;
}
trigger.value++; // 强制视图刷新
updateView(); // 重新计算可视区域
}
});
// 初始算一次
updateView();
});
// 管理 Refs 生命周期
const setItemRef = (el: any, index: number) => {
if (el) {
if (!itemRefs.has(index)) {
itemRefs.set(index, el);
ro?.observe(el);
}
}
};
// =================【 4. 滚动事件 & 核心算子 】=================
// 二分查找求起始点
const getStartIndexByBinarySearch = (scrollTop: number) => {
// 【白屏修复核心】:千万级数据暴力下发拉条时,如果使用 while (getBottom() < scrollTop) i++
// 会导致产生几百万次函数挂起的开销,直接卡顿白屏!
// 解决方案:使用预估高度直接猜测 index(指数级靠近),并使用纯二分。
// 1. 根据估算高度,直接定位一个高概率的起始探测点(缩小二分查找范围)
let guessIndex = Math.floor(scrollTop / props.estimatedItemHeight);
guessIndex = Math.max(0, Math.min(props.total - 1, guessIndex));
// 2. 将探针高速推进到 guessIndex,这在底层 V8 只是一瞬间的 TypedArray 遍历,无函数栈开销
processPositionsUpToIndex(guessIndex);
let left = 0;
let right = props.total - 1;
let result = 0;
// 纯二分查找 O(log N)
// 找的是第一个bottom大于scrollTop的项,这个就是最顶部的项
while (left <= right) {
const mid = (left + right) >> 1; // 极速向下取整
const bottom = getBottom(mid); // 按需计算
if (bottom === scrollTop) {
return mid + 1;
} else if (bottom < scrollTop) {
left = mid + 1;
} else {
result = mid;
right = mid - 1;
}
}
return result;
};
// 视区更新机
const updateView = () => {
if (!containerRef.value) return;
// 获取滚动信息
const scrollTop = containerRef.value.scrollTop;
const clientHeight = containerRef.value.clientHeight;
if (clientHeight === 0 || props.total === 0) return; // 防御:容器 0 高度或空数据
// 计算屏幕第一项
startIndex.value = getStartIndexByBinarySearch(scrollTop);
// 计算屏幕最后一项
let currentEnd = startIndex.value;
const visibleEnd = scrollTop + clientHeight; //屏幕可视区域最底部像素位置
//只要当前项没超过屏幕底部或者不是最后一个,那我就继续往下找
while (currentEnd < props.total && getBottom(currentEnd) <= visibleEnd) {
currentEnd++;
}
// 可视区域最后一项的下一项
endIndex.value = currentEnd;
// 计算带 Buffer 的补偿偏移, translate 需要回退到 buffer 头部的坐标!
const realStart = Math.max(0, startIndex.value - props.bufferSize);
startOffset.value = realStart >= 1 ? getBottom(realStart - 1) : 0;
trigger.value++; // 驱动 Buffer Computed 更新
};
// 滚动时触发视图更新
const onScroll = () => {
if (isScrollJumping) {
isScrollJumping = false; // 放行修复期幽灵事件
return;
}
updateView();
// Headless 请求防抖 - 数据流动只在合理的用户滞留期触发
if (scrollDebounceTimer) clearTimeout(scrollDebounceTimer);
scrollDebounceTimer = window.setTimeout(() => {
emit('range-change', startIndex.value, endIndex.value);
}, 120) as unknown as number;
};
// =================【 5. 视图数据绑定/计算 】=================
// 估算的沙盘虚拟总高
const listTotalHeight = computed(() => {
trigger.value; // trigger变了就重新计算
if (props.total === 0) return 0;
if (lastMeasuredIndex === -1) {
return props.total * props.estimatedItemHeight;
}
const measuredHeight = bottoms[lastMeasuredIndex];
const unmeasuredCount = props.total - 1 - lastMeasuredIndex;
return measuredHeight + unmeasuredCount * props.estimatedItemHeight;
});
// 解构保护+节点重用 Buffer 的安全输出集
const visibleData = computed(() => {
trigger.value; // subscribe map
if (props.total === 0) return [];
const start = Math.max(0, startIndex.value - props.bufferSize);
const end = Math.min(props.total, endIndex.value + props.bufferSize);
const viewData = [];
for (let i = start; i < end; i++) {
viewData.push({
__index: i,
data: props.dataSource[i] // 防御态:若是请求未返回的数据,它是 undefined,由插槽 fallback
});
}
return viewData;
});
// 改变整体 Total 级变化清空
watch(() => props.total, () => {
initCache();
updateView();
});
onUnmounted(() => {
ro?.disconnect();
itemRefs.clear();
if (scrollDebounceTimer) clearTimeout(scrollDebounceTimer);
});
</script>
<style scoped>
.virtual-list-container {
overflow-y: auto;
position: relative;
-webkit-overflow-scrolling: touch; /* 滑动玄学流畅护航 */
}
.virtual-list-phantom {
position: absolute;
left: 0;
top: 0;
right: 0;
z-index: -1;
}
.virtual-list-content {
position: absolute;
left: 0;
right: 0;
top: 0;
}
.virtual-list-item-wrapper {
/* 规避闪烁必须的 Layout box 隔离 */
contain: layout;
}
.default-skeleton {
display: flex;
align-items: center;
justify-content: center;
height: 50px;
background-color: #f5f5f5;
border-bottom: 1px solid #e0e0e0;
color: #999;
}
</style>