📚 系列文章:本文是虚拟列表系列的第二篇,建议先阅读 固定高度虚拟列表实现
🎯 本文你将学到
- ✅ 理解动态高度虚拟列表的核心难点
- ✅ 掌握"预估高度 + 渲染后修正"的解决方案
- ✅ 完整实现一个生产级别的动态高度虚拟列表组件
- ✅ 学会使用懒加载优化虚拟列表性能
为什么需要动态高度虚拟列表
在实际业务开发中,我们经常会遇到这些场景:
| 场景 | 特点 | 示例 |
|---|---|---|
| 💬 消息列表 | 文本长度不固定 | 微信聊天、钉钉消息 |
| 📝 评论/回复 | 嵌套层级不同 | 掘金评论区、知乎回答 |
| 📰 Feed 流 | 图文混排 | 微博、小红书 |
| 📋 数据表格 | 单元格内容不定 | 后台管理系统 |
这些场景的共同特点是:列表项高度不固定,无法提前预知。
核心难点分析
固定高度 vs 动态高度:复杂度对比
让我们先对比一下两种实现的核心区别:
┌─────────────────────────────────────────────────────────────────┐
│ 固定高度:所有计算都是 O(1) │
├─────────────────────────────────────────────────────────────────┤
│ Item 0 │ height: 50px │ scrollTop = 250 │
│ Item 1 │ height: 50px │ startIndex = 250 / 50 = 5 ✅ │
│ Item 2 │ height: 50px │ offset = 5 * 50 = 250px ✅ │
│ Item 3 │ height: 50px │ totalHeight = n * 50 ✅ │
│ ... │ ... │ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 动态高度:需要遍历累加 │
├─────────────────────────────────────────────────────────────────┤
│ Item 0 │ height: 80px │ scrollTop = 250 │
│ Item 1 │ height: 120px │ startIndex = ??? │
│ Item 2 │ height: 60px │ 需要遍历:80+120+60 = 260 > 250 │
│ Item 3 │ height: 200px │ 所以 startIndex = 2 │
│ ... │ ... │ │
└─────────────────────────────────────────────────────────────────┘
🐔🥚 "鸡生蛋"问题
动态高度虚拟列表面临一个经典的循环依赖问题:
┌──────────────────────────────────────────────────────────────┐
│ 动态高度的"鸡生蛋"问题 │
└──────────────────────────────────────────────────────────────┘
问题链条:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 需要知道高度 │ ──► │ 才能计算位置 │ ──► │ 才能决定渲染 │
└──────────────┘ └──────────────┘ └──────────────┘
▲ │
│ │
└──────────────────────────────────────────┘
但高度需要渲染后才知道!
核心问题清单
| 问题 | 固定高度方案 | 动态高度难点 |
|---|---|---|
| 计算 startIndex | scrollTop / itemHeight | ❌ 无法直接计算 |
| 计算总高度 | totalCount * itemHeight | ❌ 需要累加所有高度 |
| 计算偏移量 | startIndex * itemHeight | ❌ 需要累加前面所有高度 |
| 获取元素高度 | 固定值 | ❌ 渲染后才能获取 |
解决方案设计
💡 核心思路:预估高度 + 渲染后修正
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 使用预估高度 │ ──► │ 先渲染一批 │ ──► │ 获取真实高度 │
└──────────────┘ └──────────────┘ └──────────────┘
│
┌────────────────────────────────────────┘
▼
┌──────────────┐ ┌──────────────┐
│ 更新位置缓存 │ ──► │ 修正后续位置 │
└──────────────┘ └──────────────┘
数据结构设计:positionCache
为了解决动态高度的计算问题,我们需要维护一个位置缓存表:
// 存储每个元素的位置信息
const positionCache = [
{ index: 0, top: 0, bottom: 80, height: 80 },
{ index: 1, top: 80, bottom: 200, height: 120 },
{ index: 2, top: 200, bottom: 260, height: 60 },
{ index: 3, top: 260, bottom: 460, height: 200 },
// ...
]
🤔 为什么需要 top 和 bottom?
你可能会问:只存 index 和 height 不就够了吗?
答案是不够! 让我们看看为什么:
// ❌ 只有 height 时,计算 startIndex 需要这样:
let accumulatedHeight = 0
let startIndex = 0
for (let i = 0; i < positionCache.length; i++) {
accumulatedHeight += positionCache[i].height
if (accumulatedHeight > scrollTop) {
startIndex = i
break
}
}
// 时间复杂度:O(n)
// ✅ 有 top/bottom 时,可以直接查找:
const startIndex = positionCache.findIndex(item => item.bottom > scrollTop)
// 甚至可以用二分查找优化到 O(log n)
positionCache 结构详解
positionCache = [
{
index: 0, // 元素索引
top: 0, // 元素顶部距离列表顶部的距离
bottom: 80, // 元素底部距离列表顶部的距离
height: 80 // 元素高度 (bottom - top)
},
{
index: 1,
top: 80, // = 上一项的 bottom(保证连续性)
bottom: 200, // = top + height
height: 120
},
// ...
]
关键约束条件:
top[i] = bottom[i-1](连续性)bottom[i] = top[i] + height[i]height[i]初始为预估值,渲染后更新为真实值
整体架构图
┌─────────────────────────────────────────────────────────────────────────┐
│ Container(容器) │
│ height: 400px, overflow: auto │
│ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ Phantom(幽灵层) │ │
│ │ height: positionCache[last].bottom │ │
│ │ 作用:撑起滚动条,让用户感觉有完整列表 │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Content(内容层) │ │ │
│ │ │ transform: translateY(contentOffset) │ │ │
│ │ │ │ │ │
│ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │
│ │ │ │ Item [startIndex] height: 80px │ │ │ │
│ │ │ ├─────────────────────────────────────────────────────┤ │ │ │
│ │ │ │ Item [startIndex+1] height: 120px │ │ │ │
│ │ │ ├─────────────────────────────────────────────────────┤ │ │ │
│ │ │ │ Item [startIndex+2] height: 60px │ │ │ │
│ │ │ ├─────────────────────────────────────────────────────┤ │ │ │
│ │ │ │ ... 只渲染可见区域 + 缓冲区 │ │ │ │
│ │ │ └─────────────────────────────────────────────────────┘ │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
代码实现
Step 1:初始化位置缓存
首先,我们需要用预估高度初始化所有元素的位置信息:
/** 初始化位置缓存 */
const initPositionCache = (listData, estimatedHeight) => {
positionCache.value = listData.map((item, index) => ({
index,
top: index * estimatedHeight, // 0, 50, 100, 150, ...
bottom: (index + 1) * estimatedHeight, // 50, 100, 150, 200, ...
height: estimatedHeight // 50, 50, 50, 50, ...
}))
}
初始化后的数据结构(假设 estimatedHeight = 50):
Index │ Top │ Bottom │ Height │ 状态
──────┼──────┼────────┼────────┼──────────
0 │ 0 │ 50 │ 50 │ 预估值
1 │ 50 │ 100 │ 50 │ 预估值
2 │ 100 │ 150 │ 50 │ 预估值
3 │ 150 │ 200 │ 50 │ 预估值
4 │ 200 │ 250 │ 50 │ 预估值
... │ ... │ ... │ ... │ ...
Step 2:计算可见区域
// 计算:幽灵层高度(撑起滚动条)
const phantomHeight = computed(() => {
const cache = positionCache.value
if (!cache.length) return 0
return cache[cache.length - 1]?.bottom || 0
})
// 计算:可见数量(容器能显示的数量 + 缓冲区)
const visibleCount = computed(() => {
return Math.ceil(containerHeight.value / estimatedHeight.value) + bufferSize.value
})
// 计算:结束索引
const endIndex = computed(() => {
return Math.min(startIndex.value + visibleCount.value, props.listData.length)
})
// 计算:可见数据
const visibleData = computed(() => {
return listData.value.slice(startIndex.value, endIndex.value)
})
Step 3:获取开始索引和偏移量
/** 获取开始索引 - 找到第一个 bottom > scrollTop 的元素 */
const getStartIndex = (scrollTop) => {
if (!positionCache.value.length) return 0
const item = positionCache.value.find(item => item && item.bottom > scrollTop)
return item ? item.index : 0
}
/** 获取内容偏移量 - 使用当前项的 top 值 */
const getContentOffset = () => {
if (startIndex.value === 0) return 0
const cache = positionCache.value
return cache[startIndex.value]?.top || 0
}
Step 4:渲染后更新真实高度
这是动态高度虚拟列表的核心逻辑:
/** 更新高度信息 - 渲染后获取真实高度并修正位置缓存 */
const updatePositions = () => {
// 如果组件未激活(keep-alive 缓存中),不更新
if (!isActive.value) return
const nodes = itemRefs.value
if (!nodes.length) return
let needUpdate = false
nodes.forEach((node, idx) => {
if (!node) return
const realIndex = startIndex.value + idx
const rect = node.getBoundingClientRect()
const realHeight = rect.height
// 如果高度为 0,说明元素不可见,跳过
if (realHeight === 0) return
const cachedItem = positionCache.value[realIndex]
if (!cachedItem) return
const oldHeight = cachedItem.height
const heightDiff = realHeight - oldHeight
// 允许 0.5px 误差,避免浮点数精度问题
if (Math.abs(heightDiff) > 0.5) {
needUpdate = true
// 1️⃣ 更新当前项
cachedItem.height = realHeight
cachedItem.bottom = cachedItem.top + realHeight
// 2️⃣ 级联更新后续所有项的位置
for (let i = realIndex + 1; i < positionCache.value.length; i++) {
const prevItem = positionCache.value[i - 1]
const currItem = positionCache.value[i]
currItem.top = prevItem.bottom
currItem.bottom = currItem.top + currItem.height
}
}
})
// 如果有更新,重新计算偏移量
if (needUpdate) {
contentOffset.value = getContentOffset()
}
}
更新流程图解
渲染后更新流程
─────────────────────────────────────────────────────────────
DOM 渲染完成 (onUpdated + nextTick)
│
▼
┌─────────────────────────────────────────┐
│ 遍历所有渲染的 DOM 节点 │
│ itemRefs.forEach(node => ...) │
└────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 获取真实高度 │
│ const { height } = getBoundingClientRect() │
└────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 计算高度差 │
│ heightDiff = realHeight - oldHeight │
│ │
│ 例如:oldHeight=50, realHeight=80 │
│ heightDiff = 80 - 50 = 30 │
└────────┬────────────────────────────────┘
│
▼
heightDiff !== 0 ?
│
┌────┴────┐
│ 是 │ 否
▼ ▼
┌─────────┐ ┌─────────┐
│ 更新缓存 │ │ 跳过 │
└────┬────┘ └─────────┘
│
▼
┌─────────────────────────────────────────┐
│ 1. 更新当前项的 height 和 bottom │
│ 2. 级联更新后续所有项的 top 和 bottom │
└─────────────────────────────────────────┘
Step 5:滚动处理
/** 滚动处理 - 使用 RAF 优化性能 */
const ticking = ref(false)
const handleScroll = (e) => {
if (!ticking.value) {
requestAnimationFrame(() => {
// 检查组件是否还存在
if (!containerRef.value) {
ticking.value = false
return
}
const newScrollTop = e.target.scrollTop
scrollTop.value = newScrollTop
// 计算新的起始索引
const newStartIndex = getStartIndex(newScrollTop)
// 只有索引变化时才更新,减少不必要的渲染
if (newStartIndex !== startIndex.value) {
startIndex.value = newStartIndex
contentOffset.value = getContentOffset()
}
ticking.value = false
})
ticking.value = true
}
}
Step 6:模板结构
<template>
<!-- 容器 -->
<div
ref="containerRef"
class="container"
:style="{ height: containerHeight + 'px', width: containerWidth + 'px' }"
>
<!-- 幽灵层:撑起滚动条 -->
<div
ref="phantomRef"
class="phantom"
:style="{ height: phantomHeight + 'px' }"
/>
<!-- 内容层:通过 transform 定位 -->
<div
ref="contentRef"
class="content"
:style="{ transform: `translateY(${contentOffset}px)` }"
>
<div
v-for="(item, index) in visibleData"
:key="startIndex + index"
:ref="el => setItemRef(el, index)"
class="list-item"
>
<slot :item="item" :index="startIndex + index">
{{ item }}
</slot>
</div>
</div>
</div>
</template>
效果
完整代码
📦 完整组件代码
<!-- 动态高度list -->
<template>
<!-- 容器 -->
<div ref="containerRef" class="container" :style="{ height: containerHeight + 'px', width: containerWidth + 'px' }">
<!-- 幽灵高度 -->
<div ref="phantomRef" class="phantom" :style="{ height: phantomHeight + 'px' }">
</div>
<!-- 内容区域 -->
<div ref="contentRef" class="content" :style="{ transform: `translateY(${contentOffset}px)` }">
<div v-for="(item, index) in visibleData" :key="startIndex + index" :ref="el => setItemRef(el, index)"
class="list-item">
<slot :item="item" :index="startIndex + index">
{{ item }}
</slot>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, onActivated, onDeactivated, toRefs, onUpdated, nextTick, watch } from 'vue';
const props = defineProps({
// 数据源
listData: {
type: Array,
default: () => []
},
// 预估高度
estimatedHeight: {
type: Number,
default: 50
},
// 容器高度
containerHeight: {
type: Number,
default: 400
},
//容器宽度
containerWidth: {
type: Number,
default: 300
},
// 缓冲区大小
bufferSize: {
type: Number,
default: 5
}
})
const { listData, estimatedHeight, containerHeight, bufferSize } = toRefs(props)
// 容器 ref
const containerRef = ref(null);
//列表项ref
const itemRefs = ref([])
// 组件是否激活(用于 keep-alive)
const isActive = ref(true)
/** 位置缓存表 */
const positionCache = ref([])
// 当前滚动位置
const scrollTop = ref(0);
// 开始索引
const startIndex = ref(0)
//内容偏移量
const contentOffset = ref(0)
// 计算:幽灵层高度(撑起滚动条)
const phantomHeight = computed(() => {
const cache = positionCache.value
if (!cache.length) return 0
return cache[cache.length - 1]?.bottom || 0
})
//计算: 可见数量
const visibleCount = computed(() => {
return Math.ceil(containerHeight.value / estimatedHeight.value) + bufferSize.value
})
//计算: 结束索引
const endIndex = computed(() => {
return Math.min(startIndex.value + visibleCount.value, props.listData.length)
})
//计算:可见数据
const visibleData = computed(() => {
return listData.value.slice(startIndex.value, endIndex.value)
})
/** 初始化位置 */
const initPositionCache = (listData, estimatedHeight) => {
positionCache.value = listData.map((item, index) => {
return {
index,
top: index * estimatedHeight,
bottom: (index + 1) * estimatedHeight,
height: estimatedHeight
}
})
}
/** 获取开始索引 */
const getStartIndex = (scrollTop) => {
if (!positionCache.value.length) return 0
const item = positionCache.value.find(item => item && item.bottom > scrollTop)
return item ? item.index : 0
}
/** 获取偏移量 */
const getContentOffset = () => {
if (startIndex.value === 0) return 0
const cache = positionCache.value
return cache[startIndex.value]?.top || 0
}
/** 设置列表项 ref(函数形式) */
const setItemRef = (el, index) => {
if (el) {
itemRefs.value[index] = el
}
}
/** 更新高度信息 */
const updatePositions = () => {
// 如果组件未激活(keep-alive 缓存中),不更新
if (!isActive.value) return
const nodes = itemRefs.value
if (!nodes.length) return
let needUpdate = false
nodes.forEach((node, idx) => {
if (!node) return
const realIndex = startIndex.value + idx
const rect = node.getBoundingClientRect()
const realHeight = rect.height
// 如果高度为 0,说明元素不可见,跳过
if (realHeight === 0) return
const cachedItem = positionCache.value[realIndex]
if (!cachedItem) return
const oldHeight = cachedItem.height
const heightDiff = realHeight - oldHeight
if (Math.abs(heightDiff) > 0.5) { // 允许 0.5px 误差
needUpdate = true
// 更新当前项
cachedItem.height = realHeight
cachedItem.bottom = cachedItem.top + realHeight
// 更新后续所有项的位置
for (let i = realIndex + 1; i < positionCache.value.length; i++) {
const prevItem = positionCache.value[i - 1]
const currItem = positionCache.value[i]
currItem.top = prevItem.bottom
currItem.bottom = currItem.top + currItem.height
}
}
})
// 如果有更新,重新计算偏移量
if (needUpdate) {
contentOffset.value = getContentOffset()
}
}
/** 滚动处理 RAF */
const ticking = ref(false)
const handleScroll = (e) => {
if (!ticking.value) {
requestAnimationFrame(() => {
// 检查组件是否还存在
if (!containerRef.value) {
ticking.value = false
return
}
const newScrollTop = e.target.scrollTop
scrollTop.value = newScrollTop
// 计算新的起始索引
const newStartIndex = getStartIndex(newScrollTop)
// 只有索引变化时才更新
if (newStartIndex !== startIndex.value) {
startIndex.value = newStartIndex
contentOffset.value = getContentOffset()
}
ticking.value = false
})
ticking.value = true
}
}
/** 生命周期 */
onMounted(() => {
// 绑定滚动事件(初始化移到 watch 中处理)
containerRef.value?.addEventListener('scroll', handleScroll, { passive: true })
})
onUpdated(() => {
nextTick(() => {
updatePositions()
})
})
onUnmounted(() => {
containerRef.value?.removeEventListener('scroll', handleScroll)
// 清空状态,防止内存泄漏和影响其他页面
itemRefs.value = []
positionCache.value = []
ticking.value = false
isActive.value = false
})
// keep-alive 激活时
onActivated(() => {
isActive.value = true
// 重新绑定滚动事件(以防万一)
containerRef.value?.addEventListener('scroll', handleScroll, { passive: true })
})
// keep-alive 停用时
onDeactivated(() => {
isActive.value = false
// 移除滚动事件,防止在后台触发
containerRef.value?.removeEventListener('scroll', handleScroll)
})
// 监听数据变化,重新初始化位置缓存
watch(
() => props.listData,
(newData) => {
if (newData && newData.length > 0) {
// 清空旧的 refs
itemRefs.value = []
// 重新初始化位置缓存
initPositionCache(newData, estimatedHeight.value)
// 重置滚动状态
scrollTop.value = 0
startIndex.value = 0
contentOffset.value = 0
// 重置容器滚动位置
if (containerRef.value) {
containerRef.value.scrollTop = 0
}
}
},
{
immediate: true, // 立即执行一次(处理初始数据)
deep: false // 不需要深度监听,只监听数组引用变化
}
)
</script>
<style lang="less" scoped>
.container {
position: relative;
overflow-y: auto;
border: 1px solid #e4e7ed;
border-radius: 4px;
// 优化滚动性能
will-change: transform;
-webkit-overflow-scrolling: touch;
}
.phantom {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: -1;
}
.content {
position: absolute;
top: 0;
left: 0;
right: 0;
will-change: transform;
}
.list-item {
display: flex;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
box-sizing: border-box;
word-break: break-word;
&:last-child {
border-bottom: none;
}
}
</style>
🎉 结语
动态高度虚拟列表是前端性能优化中的重要技术,掌握它可以让你轻松应对各种长列表场景。希望这篇文章能帮助你深入理解其原理并应用到实际项目中!
如果觉得有帮助,欢迎点赞收藏 👍
下期预告:虚拟列表系列(3)—— 优化性能