虚拟列表核心思路
面试题
讲一讲虚拟列表的核心思路,并手写固定高度虚拟列表的关键逻辑
面试官视角
虚拟列表不是单纯考代码,而是考性能优化思路:
- 为什么大列表会卡
- 如何只渲染可视区域
- 如何让滚动条高度看起来仍然完整
- 固定高度和动态高度分别怎么处理
- Vue/React 中状态更新会不会造成频繁重渲染
核心结论
虚拟列表的核心是:只渲染视口附近的数据,但用一个占位容器撑开完整滚动高度
页面上真正存在的 DOM 数量大概是:
可视区域条数 + 缓冲区条数而不是全部数据量
固定高度虚拟列表
假设:
- 总数据
list.length = 10000 - 每项高度
itemHeight = 50 - 容器高度
containerHeight = 500
那么可视区域只需要渲染:
visibleCount = Math.ceil(containerHeight / itemHeight)滚动时根据 scrollTop 算出开始下标:
startIndex = Math.floor(scrollTop / itemHeight)结束下标:
endIndex = startIndex + visibleCount实现步骤
- 外层容器设置固定高度和
overflow: auto,负责滚动 - 内层占位元素高度设置为
list.length * itemHeight,撑开完整滚动条 - 监听外层容器的
scroll,记录scrollTop - 根据
scrollTop计算startIndex和endIndex - 从原数组中截取当前需要渲染的数据
- 可视列表使用
transform: translateY(offsetY)移动到正确位置 - 增加缓冲区
buffer,减少快速滚动时的白屏
原生 JS 核心代码
<div id="container" class="container">
<div id="phantom" class="phantom">
<div id="content" class="content"></div>
</div>
</div>.container {
height: 500px;
overflow: auto;
position: relative;
border: 1px solid #ddd;
}
.phantom {
position: relative;
}
.content {
position: absolute;
top: 0;
left: 0;
right: 0;
}
.item {
height: 50px;
line-height: 50px;
border-bottom: 1px solid #eee;
box-sizing: border-box;
}const list = Array.from({ length: 10000 }, (_, index) => ({
id: index,
text: `item ${index}`
}))
const itemHeight = 50
const containerHeight = 500
const buffer = 5
const container = document.querySelector('#container')
const phantom = document.querySelector('#phantom')
const content = document.querySelector('#content')
phantom.style.height = `${list.length * itemHeight}px`
function render() {
const scrollTop = container.scrollTop
const visibleCount = Math.ceil(containerHeight / itemHeight)
const startIndex = Math.max(
0,
Math.floor(scrollTop / itemHeight) - buffer
)
const endIndex = Math.min(
list.length,
startIndex + visibleCount + buffer * 2
)
const offsetY = startIndex * itemHeight
const visibleList = list.slice(startIndex, endIndex)
content.style.transform = `translateY(${offsetY}px)`
content.innerHTML = visibleList
.map((item) => `<div class="item">${item.text}</div>`)
.join('')
}
container.addEventListener('scroll', render)
render()Vue 版本核心逻辑
<template>
<div ref="containerRef" class="container" @scroll="handleScroll">
<div class="phantom" :style="{ height: totalHeight + 'px' }">
<div
class="content"
:style="{ transform: `translateY(${offsetY}px)` }"
>
<div
v-for="item in visibleList"
:key="item.id"
class="item"
>
{{ item.text }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
const props = defineProps({
list: {
type: Array,
default: () => []
},
itemHeight: {
type: Number,
default: 50
},
containerHeight: {
type: Number,
default: 500
},
buffer: {
type: Number,
default: 5
}
})
const scrollTop = ref(0)
const visibleCount = computed(() => {
return Math.ceil(props.containerHeight / props.itemHeight)
})
const startIndex = computed(() => {
return Math.max(
0,
Math.floor(scrollTop.value / props.itemHeight) - props.buffer
)
})
const endIndex = computed(() => {
return Math.min(
props.list.length,
startIndex.value + visibleCount.value + props.buffer * 2
)
})
const visibleList = computed(() => {
return props.list.slice(startIndex.value, endIndex.value)
})
const offsetY = computed(() => {
return startIndex.value * props.itemHeight
})
const totalHeight = computed(() => {
return props.list.length * props.itemHeight
})
function handleScroll(event) {
scrollTop.value = event.target.scrollTop
}
</script>动态高度怎么做
固定高度可以直接用除法算下标。动态高度不能直接算,需要维护每一项的位置缓存
常见做法:
- 先给一个预估高度
estimatedItemHeight - 维护
positions数组,记录每一项的top、bottom、height - 元素真实渲染后测量高度,更新对应
height - 根据
scrollTop用二分查找找到第一个bottom > scrollTop的元素 - 后续元素的位置根据新高度重新修正
位置结构:
const positions = [
{ index: 0, top: 0, bottom: 50, height: 50 },
{ index: 1, top: 50, bottom: 120, height: 70 }
]性能细节
| 优化 | 说明 |
|---|---|
| 缓冲区 | 多渲染几条,避免快速滚动白屏 |
| transform | 用 translateY 移动内容,减少布局影响 |
| 节流/RAF | 滚动事件频繁,可以用 requestAnimationFrame 合并更新 |
| key 稳定 | 避免 DOM 复用错误 |
| 动态高度缓存 | 避免每次滚动都重新测量全部元素 |
面试回答模板
虚拟列表的核心是减少 DOM 数量。外层滚动容器正常滚动,里面用一个总高度占位元素撑开滚动条;真正渲染的只是一小段可视数据。固定高度时,可以用 scrollTop / itemHeight 算出开始下标,再根据容器高度算出可视数量。动态高度时不能直接除,需要维护每个元素的位置缓存,并通过二分查找定位当前可视起点
易错点
- 只截取数据还不够,必须用总高度元素撑开滚动条
- 可视内容要根据
startIndex * itemHeight下移到正确位置 - 没有缓冲区时快速滚动容易白屏
- 动态高度不能用固定高度公式,需要测量和缓存
- 滚动事件频繁,复杂场景下要用节流或
requestAnimationFrame
