虚拟列表
面试题:上万条数据的长列表如何渲染不卡?
直接渲染上万个 DOM 节点会导致首屏慢、滚动卡、内存高。虚拟列表(虚拟滚动)的核心思想:只渲染可视区域内的少量节点,滚动时动态替换内容,并用占位撑起正确的滚动条高度。
核心原理
- 一个固定高度的可视容器
viewport(overflow: auto)。 - 一个撑高的占位元素
phantom,高度 =总条数 × 单项高度,让滚动条正常。 - 监听滚动,根据
scrollTop算出当前应渲染的起止索引,只渲染这部分。 - 用
transform: translateY()把渲染内容偏移到正确位置。
定高实现(核心代码)
const itemHeight = 50;
const viewportHeight = 500;
const visibleCount = Math.ceil(viewportHeight / itemHeight);
const buffer = 5; // 上下缓冲,避免快速滚动露白
function onScroll(scrollTop, data) {
const start = Math.floor(scrollTop / itemHeight);
const startIndex = Math.max(0, start - buffer);
const endIndex = Math.min(data.length, start + visibleCount + buffer);
const visibleData = data.slice(startIndex, endIndex);
const offsetY = startIndex * itemHeight; // 内容整体下移
return {
visibleData,
phantomHeight: data.length * itemHeight,
offsetY,
};
}
// 渲染:phantom 撑高度;list 容器 transform: translateY(offsetY)
不定高实现
当每一项的高度未知(比如包含字数不等的文本、图片)时,最难的点在于:我们无法直接通过 index * fixedHeight 算出一个元素的 top 偏移量。目前业界主流的解决方案是:预估高度 + 渲染后修正 + 缓存位置。
核心步骤解析
- 初始化缓存数组:假设所有元素都是一个“预估高度”(比如 50px),计算出每个元素的初始
top、bottom和height。 - 监听 DOM 渲染:当元素被挂载到页面上后,利用
ResizeObserver或者getBoundingClientRect获取它的真实高度。 - 修正缓存并排版:如果真实高度和预估高度不一致,更新该元素的
height和bottom,并且把它后面所有元素的top和bottom全部重新计算(整体向下或向上推平)。 - 二分查找寻找起始点:滚动时,根据
scrollTop,在缓存数组里用二分查找(因为 bottom 值是单调递增的)快速找到当前应该渲染的startIndex。
核心伪代码示例
// 1. 维护一个位置缓存数组
const positions = useRef(
listData.map((item, index) => ({
index,
height: 50, // 预估高度
top: index * 50,
bottom: (index + 1) * 50,
})),
);
// 2. 渲染后获取真实高度并修正
const measureAndUpdate = (index, domNode) => {
const rect = domNode.getBoundingClientRect();
const oldHeight = positions.current[index].height;
const dHeight = rect.height - oldHeight; // 计算高度差值
// 如果高度有变化,进行修正
if (dHeight !== 0) {
positions.current[index].height = rect.height;
positions.current[index].bottom = positions.current[index].bottom + dHeight;
// 关键点:当前元素高度变了,它后面所有的元素都要跟着往下/往上挪
for (let i = index + 1; i < positions.current.length; i++) {
positions.current[i].top = positions.current[i - 1].bottom;
positions.current[i].bottom = positions.current[i].bottom + dHeight;
}
}
};
// 3. 滚动时,利用二分查找快速寻找 startIndex
const getStartIndex = (scrollTop) => {
let left = 0;
let right = positions.current.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const bottom = positions.current[mid].bottom;
// 如果当前的 bottom 刚好等于滚动距离,说明下一个元素就是完全可见的首个元素
if (bottom === scrollTop) return mid + 1;
if (bottom < scrollTop) {
left = mid + 1;
} else {
right = mid - 1;
}
}
// 找不到精确匹配时,left 指向的就是第一个 bottom 大于 scrollTop 的元素(即当前处于视口顶部的元素)
return left;
};
常见追问
- 滚动白屏怎么办? 加上下 buffer 缓冲区;用
transform而非top减少重排。 - 和分页/无限滚动的区别? 无限滚动是「不断往 DOM 追加」,DOM 会越来越多;虚拟列表 DOM 数量恒定。二者可结合。
- 横向虚拟、表格虚拟、瀑布流? 原理相同,瀑布流需按列维护各自高度。
- 三方库:
react-window/react-virtuoso/TanStack Virtual/vue-virtual-scroller。