一、前言:为什么业务必须用「不定高虚拟列表」?
在中后台管理系统、大数据可视化、聊天会话列表、动态商品流、消息通知等场景中,我们经常需要渲染上千甚至上万条长列表数据。如果采用传统全量渲染方式,会一次性生成大量 DOM 节点,进而引发页面卡顿、滚动掉帧、白屏卡顿、内存占用过高等性能问题。
虚拟列表是前端长列表性能优化的最优解决方案,核心思想非常清晰:只渲染当前可视区域的 DOM 节点,销毁/缓存非可视区域内容,让页面始终维持极少 DOM 数量,从根源解决大数据渲染卡顿问题。
目前网上大部分开源虚拟列表方案,均基于固定列表项高度 实现,算法简单但业务适配性极差。真实业务中,文本自适应换行、内容长度不统一、自定义插槽、动态卡片布局,都会导致列表项高度不固定。
因此,自适应不定高虚拟列表才是覆盖绝大多数业务场景的通用方案。本文基于 Vue3 组合式 Hooks 思想封装,实现逻辑与视图完全解耦、全局可复用、自动缓存节点高度、滚动丝滑无抖动、无错位空白。
二、定高 vs 不定高 虚拟列表核心差异对比
| 方案类型 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 定高虚拟列表 | 算法简单、计算开销小、滚动响应丝滑 | 高度固定写死,无法适配动态内容,业务兼容性差 | 每行高度完全统一的简单结构化列表 |
| 不定高虚拟列表 | 自适应任意动态高度、无需手动传入高度、业务适配性极强 | 需要动态计算真实高度+缓存管理,算法复杂度略高 | 聊天记录、富文本列表、商品卡片、动态内容不规则列表 |
三、不定高虚拟列表核心实现原理
不同于定高列表通过「索引 × 固定高度」快速计算位置,不定高虚拟列表的核心难点在于:未知高度、动态渲染、需要精准校正滚动位置。整套方案依赖「动态高度缓存 + 可视区间计算 + 偏移量实时修正」三大核心能力:
- 维护条目高度缓存:记录每一条列表项的真实渲染高度,避免重复重排计算,大幅提升滚动性能
- 累计高度动态计算:实时累加条目高度,计算滚动容器整体高度与每一项的顶部偏移量
- 动态截取可视区间:根据当前滚动位置,实时推算需要渲染的起始索引、结束索引,精准截取可视数据
- 占位容器模拟滚动区域:通过累计总高度撑开外层滚动条,保证滚动条比例、滑动范围与完整列表一致
- 实时偏移量校正:动态更新可视区域位移,解决不定高布局常见的滚动抖动、空白、错位、回弹问题
将所有虚拟列表核心逻辑抽离为独立 TS Hooks,放置在 composables/useVirtualList.ts,通过泛型约束数据类型、完整类型推导,实现逻辑与视图完全解耦、全局高复用,适配所有 Vue3 + TS 项目。
四、全局通用 Hooks 封装(TS 生产完整版)
四、全局通用 Hooks 封装(TS 生产完整版)
ini
import { ref, computed, watch, type Ref } from 'vue'
/** 虚拟列表配置项 */
interface VirtualListOptions {
pageSize?: number
estimateHeight?: number
}
/**
* 不定高虚拟列表 Hooks(TS 完整版)
* 自动缓存高度、动态计算可视区域、支持刷新/分页/筛选
*/
export default function useVirtualList<T extends Record<string, any>>(
listData: Ref<T[]>,
options: VirtualListOptions = {}
) {
const { pageSize = 10, estimateHeight = 80 } = options
// 滚动容器 DOM
const wrapperRef = ref<HTMLDivElement | null>(null)
// 可视区域起始/结束索引
const startIndex = ref(0)
const endIndex = ref(pageSize)
// 滚动偏移量
const scrollOffset = ref(0)
// 高度缓存:key=索引,value=对应条目高度
const itemHeightCache = ref<Record<number, number>>({})
// 获取单条条目真实高度(有缓存用缓存,无缓存用预估高度)
const getItemHeight = (index: number): number => {
return itemHeightCache.value[index] ?? estimateHeight
}
// 计算指定索引对应的顶部累积高度
const getItemTop = (index: number): number => {
let top = 0
for (let i = 0; i < index; i++) {
top += getItemHeight(i)
}
return top
}
// 列表整体总高度(用于撑开滚动条)
const totalHeight = computed(() => {
let total = 0
listData.value.forEach((_, index) => {
total += getItemHeight(index)
})
return total
})
// 当前可视区域渲染的数据
const visibleList = computed<T[]>(() => {
return listData.value.slice(startIndex.value, endIndex.value)
})
// 滚动事件:动态计算可视区间
const handleScroll = () => {
if (!wrapperRef.value) return
const scrollTop = wrapperRef.value.scrollTop
scrollOffset.value = scrollTop
let heightSum = 0
let start = 0
const dataLen = listData.value.length
for (let i = 0; i < dataLen; i++) {
heightSum += getItemHeight(i)
if (heightSum >= scrollTop) {
start = i
break
}
}
startIndex.value = start
endIndex.value = start + pageSize
}
// 更新并缓存单条高度
const updateItemHeight = (index: number, height: number) => {
if (height && itemHeightCache.value[index] !== height) {
itemHeightCache.value[index] = height
}
}
// 重置虚拟列表(刷新、筛选、重置场景)
const resetVirtualList = () => {
startIndex.value = 0
endIndex.value = pageSize
scrollOffset.value = 0
itemHeightCache.value = {}
if (wrapperRef.value) {
wrapperRef.value.scrollTop = 0
}
}
// 监听数据源全量变更,自动重置
watch(
listData,
() => {
resetVirtualList()
},
{ deep: true }
)
return {
wrapperRef,
visibleList,
totalHeight,
scrollOffset,
startIndex,
endIndex,
handleScroll,
updateItemHeight,
resetVirtualList
}
}
ini
import { ref, computed, watch, type Ref } from 'vue'
/** 虚拟列表配置参数 */
export interface VirtualListOptions {
/** 单次渲染可视条数 */
pageSize?: number
/** 条目预估兜底高度 */
estimateHeight?: number
}
/**
* Vue3 不定高虚拟列表 Hooks(TS 生产版)
* 支持自动高度缓存、动态可视区间计算、滚动偏移校正、数据刷新重置
* 适配任意动态高度列表、分页加载、搜索筛选、增删改场景
*/
export default function useVirtualList<T extends Record<string, unknown>>(
listData: Ref<T[]>,
options: VirtualListOptions = {}
) {
// 解构配置项,设置默认值
const { pageSize = 10, estimateHeight = 80 } = options
// 滚动容器 DOM 实例
const wrapperRef = ref<HTMLDivElement | null>(null)
// 可视区域起始索引
const startIndex = ref<number>(0)
// 可视区域结束索引
const endIndex = ref<number>(pageSize)
// 当前滚动偏移量
const scrollOffset = ref<number>(0)
// 条目高度缓存:key=数据索引,value=对应真实 DOM 高度
const itemHeightCache = ref<Record<number, number>>({})
/**
* 获取单条条目真实高度
* 有缓存优先读缓存,无缓存使用预估高度
*/
const getItemHeight = (index: number): number => {
return itemHeightCache.value[index] ?? estimateHeight
}
/**
* 计算指定索引条目顶部累积偏移高度
*/
const getItemTop = (index: number): number => {
let top = 0
for (let i = 0; i < index; i++) {
top += getItemHeight(i)
}
return top
}
/**
* 计算列表整体总高度(用于撑开滚动条,保证滚动范围正确)
*/
const totalHeight = computed<number>(() => {
let total = 0
listData.value.forEach((_, index) => {
total += getItemHeight(index)
})
return total
})
/**
* 当前可视区域渲染数据
*/
const visibleList = computed<T[]>(() => {
return listData.value.slice(startIndex.value, endIndex.value)
})
/**
* 滚动监听核心逻辑:动态计算可视区间、更新偏移量
*/
const handleScroll = (): void => {
if (!wrapperRef.value) return
const scrollTop = wrapperRef.value.scrollTop
scrollOffset.value = scrollTop
let heightSum = 0
let start = 0
const dataLen = listData.value.length
// 根据滚动高度匹配当前起始渲染索引
for (let i = 0; i < dataLen; i++) {
heightSum += getItemHeight(i)
if (heightSum >= scrollTop) {
start = i
break
}
}
startIndex.value = start
endIndex.value = start + pageSize
}
/**
* 更新并缓存单条条目真实 DOM 高度
* @param index 数据全局索引
* @param height 条目真实高度
*/
const updateItemHeight = (index: number, height: number): void => {
if (height && itemHeightCache.value[index] !== height) {
itemHeightCache.value[index] = height
}
}
/**
* 重置虚拟列表所有状态
* 适用于:数据刷新、搜索筛选、页面重置、弹窗重载
*/
const resetVirtualList = (): void => {
startIndex.value = 0
endIndex.value = pageSize
scrollOffset.value = 0
itemHeightCache.value = {}
if (wrapperRef.value) {
wrapperRef.value.scrollTop = 0
}
}
/**
* 监听数据源全量变更,自动重置列表状态
* 适配搜索、筛选、刷新场景,自动清空过期高度缓存
*/
watch(
listData,
() => {
resetVirtualList()
},
{ deep: true }
)
return {
wrapperRef,
visibleList,
totalHeight,
scrollOffset,
startIndex,
endIndex,
handleScroll,
updateItemHeight,
resetVirtualList
}
}
五、TS 业务组件完整使用示例(开箱即用)
视图层完全适配 TS 语法,自带类型推导、无类型报错,可直接在 Vue3 + TS 项目中使用,结构简洁、无需冗余类型重复定义。
视图层只需关注页面结构与样式,无需处理复杂计算逻辑,直接引入 Hooks 即可快速实现不定高虚拟列表。
xml
<template>
<!-- 虚拟列表外层滚动容器 -->
<div class="virtual-wrapper" ref="wrapperRef" @scroll="handleScroll">
<!-- 占位层:用于撑开完整滚动高度,保证滚动条正常显示、比例正确 -->
<div :style="{ height: totalHeight + 'px' }"></div>
<!-- 可视内容层:通过 translateY 动态修正滚动偏移,解决不定高错位问题 -->
<div class="virtual-content" :style="{ transform: `translateY(${scrollOffset}px)` }">
<div
class="list-item"
v-for="(item, index) in visibleList"
:key="item.id"
ref="elRefs"
@afterEnter="() => updateItemHeight(startIndex + index, elRefs[index]?.offsetHeight)"
>
<!-- 自定义不定高业务内容 -->
<div class="title">{{ item.title }}</div>
<div class="desc">{{ item.desc }}</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, type Ref } from 'vue'
import useVirtualList from '@/composables/useVirtualList'
// 定义列表数据类型
interface ListItem {
id: number
title: string
desc: string
}
// 模拟 2000 条不定高测试数据
const listData: Ref<ListItem[]> = ref(
Array.from({ length: 2000 }, (_, i) => ({
id: i + 1,
title: `动态列表条目 ${i + 1}`,
desc: i % 3 === 0
? '短文本内容'
: i % 3 === 1
? '超长文本内容超长文本内容超长文本内容超长文本内容'
: '中等长度描述文本'
}))
)
// 初始化虚拟列表 Hooks,可自定义渲染条数、预估高度
const {
wrapperRef,
visibleList,
totalHeight,
scrollOffset,
startIndex,
handleScroll,
updateItemHeight
} = useVirtualList(listData, {
pageSize: 12,
estimateHeight: 80
})
// 收集列表 DOM 节点,用于采集真实高度
const elRefs: Ref<HTMLDivElement[]> = ref([])
</script>
<style scoped>
.virtual-wrapper {
height: 500px;
overflow-y: auto;
position: relative;
}
.virtual-content {
position: absolute;
left: 0;
top: 0;
width: 100%;
}
.list-item {
padding: 12px 16px;
border-bottom: 1px solid #eee;
}
.title {
font-size: 14px;
font-weight: 500;
}
.desc {
font-size: 12px;
color: #666;
margin-top: 4px;
line-height: 1.6;
}
</style>
六、核心亮点与关键优化细节
1. 智能高度缓存策略
首次渲染采用预估高度占位,避免空白闪烁;节点渲染完成后自动采集真实 DOM 高度并缓存,后续滚动直接复用缓存数据,无需重复重排计算,从根源解决不定高列表常见的滚动回弹、抖动、错位问题。
2. 逻辑视图完全解耦,复用性极强
所有复杂计算、区间判断、缓存管理、状态重置逻辑全部封装在 Hooks 内部。视图层仅负责结构渲染,任意业务页面、任意自定义列表结构,只需传入数据源即可快速接入虚拟滚动能力,零冗余重复代码。
3. 数据源监听自动重置机制
深度监听列表数据源变化,在数据刷新、搜索筛选、分页切换时,自动清空历史高度缓存、重置滚动位置与可视区间,彻底避免新旧数据叠加、高度残留、页面错乱等问题。
4. 精准位移偏移量修正
通过动态计算 translateY 偏移量实时校正可视区域位置,完美适配高度不固定的动态布局,根治传统虚拟列表普遍存在的滚动空白、界面跳跃、定位不准等顽疾。
七、通用业务场景适配扩展
1. 完美适配分页加载
实现上拉加载下一页时,只需直接拼接数据源,Hooks 会自动增量缓存新增条目的高度,无需额外处理逻辑,滚动体验连贯自然。
2. 支持搜索筛选实时刷新
关键词筛选、条件过滤后数据源变更,自动触发重置逻辑,清空旧缓存、重新计算高度,适配全新列表布局。
3. 兼容增删改操作
列表置顶、删除条目、编辑内容等操作,只需更新数据源数组,虚拟列表会自动同步更新高度缓存与可视区域,状态实时同步。
八、分页增量加载(TS 生产级完整版)
原生封装的 Hooks 默认监听数据源全量变更会重置缓存,适配搜索、筛选、刷新 场景,但传统分页「下拉加载更多」属于数据增量场景,不能清空历史高度缓存,否则会出现滚动抖动、历史条目高度丢失问题。
这里优化适配生产级分页逻辑,区分「全量刷新」和「增量加载」,实现增量缓存高度、滚动不回弹、加载丝滑无错乱。
8.1 核心优化思路
- 关闭默认全量监听重置逻辑,手动区分数据操作类型
- 分页加载更多:仅拼接数据、增量缓存新条目高度,保留旧缓存
- 筛选/刷新/重置:全量替换数据、清空缓存、重置滚动位置
8.2 改造 Hooks(支持增量/全量双模式)
微调 Hooks,暴露手动重置方法,移除默认全局深度监听,交由业务层手动控制重置逻辑,适配分页增量场景。
ini
import { ref, computed, type Ref } from 'vue'
/** 虚拟列表配置参数 */
export interface VirtualListOptions {
/** 单次渲染可视条数 */
pageSize?: number
/** 条目预估兜底高度 */
estimateHeight?: number
}
/**
* Vue3 不定高虚拟列表 Hooks(TS 增强分页版)
* 支持:全量刷新重置 + 分页增量加载缓存保留
*/
export default function useVirtualList<T extends Record<string, unknown>>(
listData: Ref<T[]>,
options: VirtualListOptions = {}
) {
// 解构配置项,设置默认值
const { pageSize = 10, estimateHeight = 80 } = options
// 滚动容器 DOM 实例
const wrapperRef = ref<HTMLDivElement | null>(null)
// 可视区域起始索引
const startIndex = ref<number>(0)
// 可视区域结束索引
const endIndex = ref<number>(pageSize)
// 当前滚动偏移量
const scrollOffset = ref<number>(0)
// 条目高度缓存:key=数据索引,value=对应真实 DOM 高度
const itemHeightCache = ref<Record<number, number>>({})
/**
* 获取单条条目真实高度
* 有缓存优先读缓存,无缓存使用预估高度
*/
const getItemHeight = (index: number): number => {
return itemHeightCache.value[index] ?? estimateHeight
}
/**
* 计算指定索引条目顶部累积偏移高度
*/
const getItemTop = (index: number): number => {
let top = 0
for (let i = 0; i < index; i++) {
top += getItemHeight(i)
}
return top
}
/**
* 计算列表整体总高度(用于撑开滚动条,保证滚动范围正确)
*/
const totalHeight = computed<number>(() => {
let total = 0
listData.value.forEach((_, index) => {
total += getItemHeight(index)
})
return total
})
/**
* 当前可视区域渲染数据
*/
const visibleList = computed<T[]>(() => {
return listData.value.slice(startIndex.value, endIndex.value)
})
/**
* 滚动监听核心逻辑:动态计算可视区间、更新偏移量
*/
const handleScroll = (): void => {
if (!wrapperRef.value) return
const scrollTop = wrapperRef.value.scrollTop
scrollOffset.value = scrollTop
let heightSum = 0
let start = 0
const dataLen = listData.value.length
// 根据滚动高度匹配当前起始渲染索引
for (let i = 0; i < dataLen; i++) {
heightSum += getItemHeight(i)
if (heightSum >= scrollTop) {
start = i
break
}
}
startIndex.value = start
endIndex.value = start + pageSize
}
/**
* 更新并缓存单条条目真实 DOM 高度
* @param index 数据全局索引
* @param height 条目真实高度
*/
const updateItemHeight = (index: number, height: number): void => {
if (height && itemHeightCache.value[index] !== height) {
itemHeightCache.value[index] = height
}
}
/**
* 全量重置虚拟列表状态
* 适用于:搜索筛选、列表刷新、页面重置、弹窗重载
*/
const resetVirtualList = (): void => {
startIndex.value = 0
endIndex.value = pageSize
scrollOffset.value = 0
itemHeightCache.value = {}
if (wrapperRef.value) {
wrapperRef.value.scrollTop = 0
}
}
/**
* 增量更新列表数据(分页加载专用)
* 只拼接数据,保留历史高度缓存,不重置滚动位置
*/
const appendListData = (newData: T[]): void => {
listData.value = [...listData.value, ...newData]
}
return {
wrapperRef,
visibleList,
totalHeight,
scrollOffset,
startIndex,
endIndex,
handleScroll,
updateItemHeight,
resetVirtualList,
appendListData
}
}
8.3 分页业务组件 TS 完整案例
实现上拉加载更多、分页防重、无数据兜底、刷新重置全套生产逻辑,完美适配虚拟列表增量机制。
ini
<template>
<div class="virtual-wrapper" ref="wrapperRef" @scroll="handleScroll">
<div :style="{ height: totalHeight + 'px' }"></div>
<div class="virtual-content" :style="{ transform: `translateY(${scrollOffset}px)` }">
<div
class="list-item"
v-for="(item, index) in visibleList"
:key="item.id"
ref="elRefs"
@afterEnter="() => updateItemHeight(startIndex + index, elRefs[index]?.offsetHeight)"
>
<div class="title">{{ item.title }}</div>
<div class="desc">{{ item.desc }}</div>
</div>
</div>
<!-- 加载状态兜底 -->
<div class="load-more" v-if="loading">加载中...</div>
<div class="load-more" v-if="noMore">没有更多数据了</div>
</div>
</template>
<script setup lang="ts">
import { ref, type Ref } from 'vue'
import useVirtualList from '@/composables/useVirtualList'
// 定义列表数据类型
interface ListItem {
id: number
title: string
desc: string
}
// 分页状态
const page = ref(1)
const pageSize = 20
const loading = ref(false)
const noMore = ref(false)
// 列表数据源
const listData: Ref<ListItem[]> = ref([])
// 初始化虚拟列表
const {
wrapperRef,
visibleList,
totalHeight,
scrollOffset,
startIndex,
handleScroll,
updateItemHeight,
resetVirtualList,
appendListData
} = useVirtualList(listData, {
pageSize: 12,
estimateHeight: 80
})
// DOM收集
const elRefs: Ref<HTMLDivElement[]> = ref([])
// 模拟接口请求数据
const getListData = async (isRefresh = false) => {
if (loading.value) return
loading.value = true
// 模拟接口延迟
await new Promise(resolve => setTimeout(resolve, 600))
// 模拟分页数据
const start = (page.value - 1) * pageSize
const newData: ListItem[] = Array.from({ length: pageSize }, (_, i) => {
const idx = start + i
return {
id: idx + 1,
title: `动态列表条目 ${idx + 1}`,
desc: idx % 3 === 0
? '短文本内容'
: idx % 3 === 1
? '超长文本内容超长文本内容超长文本内容超长文本内容'
: '中等长度描述文本'
}
})
// 模拟总共5页数据
if (page.value >= 5) {
noMore.value = true
}
// 区分:刷新重置 / 增量加载
if (isRefresh) {
listData.value = newData
resetVirtualList()
noMore.value = false
} else {
appendListData(newData)
}
loading.value = false
}
// 上拉加载更多
const handleScrollBottom = () => {
if (noMore.value || loading.value) return
// 判断滚动到底部阈值
const { scrollTop, scrollHeight, clientHeight } = wrapperRef.value!
if (scrollTop + clientHeight >= scrollHeight - 50) {
page.value++
getListData()
}
}
// 监听滚动触底
const originScroll = handleScroll
const newHandleScroll = () => {
originScroll()
handleScrollBottom()
}
// 首次加载
getListData(true)
<style scoped>
.virtual-wrapper {
height: 500px;
overflow-y: auto;
position: relative;
}
.virtual-content {
position: absolute;
left: 0;
top: 0;
width: 100%;
}
.list-item {
padding: 12px 16px;
border-bottom: 1px solid #eee;
}
.title {
font-size: 14px;
font-weight: 500;
}
.desc {
font-size: 12px;
color: #666;
margin-top: 4px;
line-height: 1.6;
}
.load-more {
text-align: center;
padding: 16px;
color: #999;
font-size: 12px;
}
</style>
8.4 分页核心优势
- 增量缓存不丢失:分页加载只拼接数据、保留历史高度缓存,滚动不回弹、无抖动
- 场景精准区分:刷新/筛选全量重置、分页增量叠加,双场景完美适配
- 防重加载机制:自带 loading 锁,避免快速滚动重复请求接口
- 完备兜底状态:包含加载中、无更多数据兜底,交互完整
九、总结
这套Vue3 不定高虚拟列表通用 Hooks ,彻底解决了传统定高虚拟列表适配性差、无法应对动态内容的痛点,同时补齐了生产必备的分页增量加载能力,真正实现了业务通用的高性能长列表解决方案。
方案核心优势总结:
- TS 强类型约束:通过泛型+接口约束数据类型,全程类型校验,杜绝隐式类型错误,适配大型工程项目
- 解耦复用、零业务侵入:纯逻辑 Hooks 封装,视图只负责渲染,任意列表结构可快速接入
- 智能高度缓存机制:预估高度兜底+真实高度缓存,兼顾首屏体验与滚动精度
- 双模式数据更新:支持全量重置(刷新/筛选)+增量加载(分页),覆盖全部业务场景
- 解决行业痛点:彻底根治虚拟列表滚动空白、错位、抖动、回弹、错乱等常见问题
十、生产落地注意事项 & 高频避坑指南
本套不定高虚拟列表 Hooks 虽通用性极强,但在实际项目落地中,存在部分容易忽略的细节与隐性坑点,这里统一整理生产级规范与避坑方案,保证项目稳定运行。
1. 列表项 Key 禁止使用索引(高频BUG,附正反案例)
示例代码初期为简化演示,未严格规范 Key 取值。正式业务绝对禁止使用 index 作为列表 key。当列表出现删除、新增、排序、筛选操作时,数组索引会重新排布,导致「高度缓存索引」与「真实数据」错位,出现高度缓存匹配错误、DOM 复用错乱、滚动内容漂移等严重问题。
错误写法(会 BUG)
ini
<div
class="list-item"
v-for="(item, index) in visibleList"
:key="index"
>
{{ item.title }}
</div>
正确写法(生产规范)
ini
<div
class="list-item"
v-for="(item, index) in visibleList"
:key="item.id"
>
{{ item.title }}
</div>
核心规范:必须使用数据唯一 ID(id/uuid)作为 Key,让「数据、DOM、高度缓存」三者永久绑定,杜绝错乱。
示例代码初期为简化演示,未严格规范 Key 取值。正式业务绝对禁止使用 index 作为列表 key。当列表出现删除、新增、排序、筛选操作时,数组索引会重新排布,导致「高度缓存索引」与「真实数据」错位,出现高度缓存匹配错误、DOM 复用错乱、滚动内容漂移等严重问题。
错误写法(会 BUG)
ini
<div
class="list-item"
v-for="(item, index) in visibleList"
:key="index"
>
{{ item.title }}
</div>
正确写法(生产规范)
ini
<div
class="list-item"
v-for="(item, index) in visibleList"
:key="item.id"
>
{{ item.title }}
</div>
核心规范:必须使用数据唯一 ID(id/uuid)作为 Key,让「数据、DOM、高度缓存」三者永久绑定,杜绝错乱。
2. 滚动容器必须固定有效高度(附错误/正确代码)
虚拟列表的可视区间计算、滚动监听、偏移校正全部依赖滚动容器的可视尺寸。若容器高度自适应、高度塌陷、百分比高度失效,会导致滚动计算逻辑彻底失效,出现大面积空白、滚动无反应问题。
错误写法(高度塌陷/无效)
arduino
/* 无高度 / 自适应高度,虚拟列表失效 */
.virtual-wrapper {
overflow-y: auto;
position: relative;
}
正确写法(生产标准)
css
.virtual-wrapper {
height: 500px; /* 固定像素高度 */
/* height: calc(100vh - 180px); 动态计算高度也可 */
overflow-y: auto;
position: relative;
}
落地规范 :容器必须具备明确计算后高度 + overflow-y: auto,缺一不可。
虚拟列表的可视区间计算、滚动监听、偏移校正全部依赖滚动容器的可视尺寸。若容器高度自适应、高度塌陷、百分比高度失效,会导致滚动计算逻辑彻底失效,出现大面积空白、滚动无反应问题。
错误写法(高度塌陷/无效)
arduino
/* 无高度 / 自适应高度,虚拟列表失效 */
.virtual-wrapper {
overflow-y: auto;
position: relative;
}
正确写法(生产标准)
css
.virtual-wrapper {
height: 500px; /* 固定像素高度 */
/* height: calc(100vh - 180px); 动态计算高度也可 */
overflow-y: auto;
position: relative;
}
落地规范 :容器必须具备明确计算后高度 + overflow-y: auto,缺一不可。
落地规范 :滚动容器必须设置明确高度(固定 px / 计算后 vh / 动态计算赋值),同时开启 overflow-y: auto。
3. 列表异步内容渲染问题(图片/异步数据,附修复代码)
若列表内部包含图片、异步接口渲染内容、延时 DOM,会出现「先采集高度、后渲染内容」的情况,导致缓存高度偏小、滚动抖动、内容被截断。
问题场景:列表内图片未设置宽高,图片加载完成后列表项高度变大,但缓存仍是旧高度。
xml
<div class="list-item" v-for="item in visibleList" :key="item.id">
<!-- 图片异步加载,高度会变化 -->
<img :src="item.imgUrl" alt="" />
<p>{{ item.desc }}</p>
</div>
解决方案:异步资源加载完毕后,手动重置虚拟列表缓存。
ini
<img
:src="item.imgUrl"
alt=""
@load="resetVirtualList()"
/>
批量接口场景可在数据二次更新完成后 调用 resetVirtualList() 刷新高度缓存,保证高度采集精准。
若列表内部包含图片、异步接口渲染内容、延时 DOM,会出现「先采集高度、后渲染内容」的情况,导致缓存高度偏小、滚动抖动、内容被截断。
问题场景:列表内图片未设置宽高,图片加载完成后列表项高度变大,但缓存仍是旧高度。
xml
<div class="list-item" v-for="item in visibleList" :key="item.id">
<!-- 图片异步加载,高度会变化 -->
<img :src="item.imgUrl" alt="" />
<p>{{ item.desc }}</p>
</div>
解决方案:异步资源加载完毕后,手动重置虚拟列表缓存。
ini
<img
:src="item.imgUrl"
alt=""
@load="resetVirtualList()"
/>
批量接口场景可在数据二次更新完成后 调用 resetVirtualList() 刷新高度缓存。
4. 预估高度精准配置(附配置代码)
默认 estimateHeight: 80 为通用兜底值,若业务列表平均高度偏差过大,会出现首屏空白、初次滚动跳跃问题。
优化方案:根据业务 UI 平均高度自定义传入预估高度
php
// 短文本列表:设置更小预估高度
const { ... } = useVirtualList(listData, {
pageSize: 12,
estimateHeight: 60
})
// 卡片/大图列表:设置更大预估高度
// estimateHeight: 120
预估高度越贴近真实平均高度,首屏渲染与初次滚动体验越丝滑,能有效规避首次滚动偏移、空白闪烁问题。
默认 estimateHeight: 80 为通用兜底值,若业务列表平均高度偏差过大,会出现首屏空白、初次滚动跳跃问题。
优化方案:根据业务 UI 平均高度自定义传入预估高度
php
// 短文本列表:设置更小预估高度
const { ... } = useVirtualList(listData, {
pageSize: 12,
estimateHeight: 60
})
// 卡片/大图列表:设置更大预估高度
// estimateHeight: 120
预估高度越贴近真实平均高度,首屏渲染与初次滚动体验越丝滑。
解决方案 :异步资源加载完成后,手动调用 resetVirtualList() 重置高度缓存,重新采集真实 DOM 高度。
代码中默认预估高度为 80px,若业务列表普遍高度远大于或远小于该值,会出现初始滚动偏差、首屏空白问题。
优化规范 :根据业务列表平均高度自定义 estimateHeight,贴合真实布局,大幅提升初次滚动体验。
5. 分页增量 & 筛选刷新 双场景区分(核心代码案例)
全量重置和增量加载必须严格区分,混用会导致分页滚动回弹、高度缓存清空错乱。Hooks 原始的全局数据监听会自动重置缓存,仅适用于筛选、刷新场景,分页增量加载必须手动控制,禁止清空历史缓存。
标准业务代码规范
csharp
// 1. 搜索、筛选、下拉刷新、重置 → 全量覆盖 + 清空缓存
const refreshList = async () => {
page.value = 1
const res = await api.getList({ page: 1 })
listData.value = res.data
resetVirtualList() // 必须重置
noMore.value = false
}
// 2. 上拉加载更多 → 增量拼接 + 保留历史高度缓存
const loadMore = async () => {
page.value++
const res = await api.getList({ page: page.value })
appendListData(res.data) // 增量API,不重置缓存
}
业务核心区分规则:刷新/筛选场景重置缓存、分页增量加载场景保留旧缓存,彻底解决分页滚动跳动问题。
全量重置和增量加载必须严格区分,混用会导致分页滚动回弹、高度缓存清空错乱。
标准业务代码规范
csharp
// 1. 搜索、筛选、下拉刷新、重置 → 全量覆盖 + 清空缓存
const refreshList = async () => {
page.value = 1
const res = await api.getList({ page: 1 })
listData.value = res.data
resetVirtualList() // 必须重置
noMore.value = false
}
// 2. 上拉加载更多 → 增量拼接 + 保留历史高度缓存
const loadMore = async () => {
page.value++
const res = await api.getList({ page: page.value })
appendListData(res.data) // 增量API,不重置缓存
}
业务区分:刷新/筛选 → 重置缓存;分页加载 → 增量拼接、保留旧缓存。
6. 弹窗/抽屉内虚拟列表(延迟初始化代码)
弹窗、抽屉默认 display: none,DOM 未实际渲染,元素offsetHeight = 0,初始化采集的高度全部失效,会导致滚动空白、高度错乱、内容错位。
解决方案:弹窗打开、DOM 渲染完成后,手动重置列表状态
xml
<template>
<el-dialog v-model="visible" title="弹窗虚拟列表">
<div class="virtual-wrapper" ref="wrapperRef" @scroll="handleScroll">
<!-- 虚拟列表内容 -->
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { nextTick } from 'vue'
// 弹窗打开后重置
const handleOpen = () => {
// 等待DOM渲染完成后再初始化高度
nextTick(() => {
resetVirtualList()
})
}
</script>
通过延迟重置的方式,等待弹窗 DOM 挂载完成后采集真实高度,彻底修复隐藏容器内虚拟列表失效问题。
弹窗、抽屉默认 display: none,DOM 元素 offsetHeight = 0,初始化采集高度全部失效,导致滚动空白、高度错乱。
解决方案:弹窗打开动画结束后,手动重置列表
xml
<template>
<el-dialog v-model="visible" title="弹窗虚拟列表">
<div class="virtual-wrapper" ref="wrapperRef" @scroll="handleScroll">
<!-- 虚拟列表内容 -->
</div>
</el-dialog>
</template>
<script setup lang="ts">
// 弹窗打开后重置
const handleOpen = () => {
// 等待DOM渲染完成
nextTick(() => {
resetVirtualList()
})
}
</script>
解决方案:在弹窗打开、DOM 渲染完成后再初始化虚拟列表,或打开弹窗后手动重置一次列表状态。
7. 禁止列表项脱离文档流(正反样式代码)
列表项内部元素使用浮动、绝对定位脱离文档流,会导致父容器高度塌陷,采集的 offsetHeight 数值不准,造成高度缓存错乱、滚动错位。
错误样式(高度塌陷)
css
.list-item .left {
float: left;
}
.list-item .abs-box {
position: absolute;
right: 0;
}
正确样式(稳定可采集)
css
.list-item {
display: flex;
flex-direction: column;
padding: 12px 16px;
border-bottom: 1px solid #eee;
}
规范要求:列表项内部统一使用 flex、grid 或标准文档流布局,禁止元素脱离文档流,保证 DOM 高度可正常采集、缓存精准有效。
列表项内部元素使用浮动、绝对定位脱离文档流,会导致父容器高度塌陷,采集的 offsetHeight 不准,缓存错乱。
错误样式(高度塌陷)
css
.list-item .left {
float: left;
}
.list-item .abs-box {
position: absolute;
right: 0;
}
正确样式(稳定可采集)
css
.list-item {
display: flex;
flex-direction: column;
padding: 12px 16px;
border-bottom: 1px solid #eee;
}
使用 flex/grid 标准文档流布局,保证节点高度可被正常采集缓存。
规范要求:列表项内部布局使用标准文档流、flex、grid 布局,保证 DOM 高度可正常采集。
8. 小数据量禁用虚拟列表(代码判断案例)
虚拟列表存在一定计算开销,属于大数据量性能优化方案。小数据量场景下,直接全量渲染性能更优,过度优化反而会增加代码冗余、提升维护成本。
业务判断代码
xml
<template>
<!-- 阈值判断:少于100条直接原生渲染,大于100条启用虚拟列表 -->
<template v-if="listData.length < 100">
<div class="list-item" v-for="item in listData" :key="item.id">
{{ item.title }}
{{ item.desc }}
</div>
</template>
<template v-else>
<!-- 虚拟列表组件 -->
<div class="virtual-wrapper" ref="wrapperRef" @scroll="handleScroll">
<div :style="{ height: totalHeight + 'px' }"></div>
<div class="virtual-content" :style="{ transform: `translateY(${scrollOffset}px)` }">
<div
class="list-item"
v-for="(item, index) in visibleList"
:key="item.id"
ref="elRefs"
@afterEnter="() => updateItemHeight(startIndex + index, elRefs[index]?.offsetHeight)"
>
{{ item.title }}
{{ item.desc }}
</div>
</div>
</div>
</template>
使用阈值规范:常规业务列表数据超过 100 条、出现滚动卡顿、页面掉帧问题时,再启用虚拟列表优化。
虚拟列表存在计算开销,小数据量直接全量渲染性能更优,无需过度优化。
业务判断代码
使用阈值:常规列表数据超过 100 条、存在滚动卡顿问题时,再启用虚拟列表。