以观书法
108.85M · 2026-02-05
在某种特殊场景下,我们需要将 大量数据 使用不分页的方式渲染到列表上,比如说渲染1000+数据时,传统的v-for渲染会遇到以下问题:
| 问题 | 描述 |
|---|---|
| DOM 节点爆炸 | 1000 条数据 = 1000+ 个 DOM 节点,浏览器内存占用飙升 |
| 首屏渲染慢 | 大量 DOM 创建导致首屏白屏时间过长(可能 > 3s) |
| 滚动卡顿 | 滚动时触发大量重排重绘,FPS 急剧下降 |
| 内存泄漏风险 | 长列表中如果有事件,容易造成内存泄漏 |
我们怎么办呢?这时们需要 “虚拟列表”,来解决上面描述的问题。
因为虚拟列表是按需显示的一种实现,只对可见区域渲染,对非可见区域不渲染或部分渲染,从而减少性能消耗
分析真实业务场景,将全部数据渲染到列表中是无用且浪费资源的行为,只需要根据用户的视窗进行部分渲染即可
┌─────────────────────────────────────────────────────────────┐
│ 完整数据列表 (10000条) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Item 0 │ │
│ │ Item 1 │ │
│ │ ... │ │
│ │ Item 50 ◄─── 可视区域起点 (startIndex) │ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ ████████████████████████████████████████████████████│◄───┼── 可视区域
│ │ ████████████████████████████████████████████████████│ │ (Viewport)
│ │ ████████████████████████████████████████████████████│ │ 只渲染这部分
│ │ ████████████████████████████████████████████████████│ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ Item 70 ◄─── 可视区域终点 (endIndex) │ │
│ │ ... │ │
│ │ Item 9999 │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
虚拟列表的结构通常由三个核心层级组成,各层级分工明确、协同实现 “按需渲染 + 流畅滚动” 的效果,具体如下:
containerHeight),并配置 overflow: auto 以显示滚动条。它是整个虚拟列表的视觉边界,也是滚动事件的载体,用户通过操作该层的滚动条触发内容动态更新。position: absolute 和 z-index: -1 使其处于内容层下方,既不干扰视觉,又能让用户感知到 “下方有完整数据” 的滚动预期。transform: translateY(偏移值) 实现滚动定为—— 当容器层滚动时,动态计算偏移量并更新该属性,让内容层跟随滚动同步移动,从而模拟出 “完整列表滚动” 的视觉效果,同时避免大量 DOM 节点的创建与销毁。┌────────────────────────────────────────┐
│ Container (容器层) │ ← 固定高度,overflow: auto
│ ┌──────────────────────────────────┐ │
│ │ Phantom (幽灵层) │ │ ← 高度 = 总数据量 × 单项高度
│ │ 撑起滚动条 │ │ position: absolute, z-index: -1
│ │ │ │
│ │ ┌────────────────────────────┐ │ │
│ │ │ Content (内容层) │ │ │ ← 实际渲染的 DOM
│ │ │ transform: translateY │ │ │ 通过 transform 定位
│ │ │ ┌────────────────────┐ │ │ │
│ │ │ │ Visible Item 1 │ │ │ │
│ │ │ │ Visible Item 2 │ │ │ │
│ │ │ │ Visible Item 3 │ │ │ │
│ │ │ │ ... │ │ │ │
│ │ │ └────────────────────┘ │ │ │
│ │ └────────────────────────────┘ │ │
│ └──────────────────────────────────┘ │
└────────────────────────────────────────┘
在理解虚拟列表 “容器层、幽灵层、内容层” 的三层结构后,需通过精准计算支撑各层级的动态渲染与滚动同步,以下是优化后的核心公式及逻辑解析,兼顾可读性与业务适配性:
1. 可视区域起始索引(startIndex)
作用:确定当前滚动位置下,可视区域第一条数据的索引,是筛选 “需渲染数据” 的起点。
公式:
// scrollTop:容器层已滚动的垂直距离(px);itemHeight:列表项预估高度(px)
const startIndex = Math.floor(scrollTop / itemHeight);
逻辑解析:通过 “滚动距离 ÷ 单项高度” 得到 “已滚动过的列表项数量”,用 Math.floor 取整(避免半项数据的无效索引),例如滚动 120px 且单项高度 50px 时,startIndex = 2(表示前 2 项已滚出可视区域)。
2. 可视区域数据数量(visibleCount)
作用:计算当前可视区域最多能容纳的列表项数量,确保渲染数据覆盖完整可视范围。
公式:
// containerHeight:容器层固定高度(px);itemHeight:列表项预估高度(px)
const visibleCount = Math.ceil(containerHeight / itemHeight);
逻辑解析:用 “容器高度 ÷ 单项高度” 得到理论容纳量,通过 Math.ceil 向上取整(避免因小数导致 “最后一项显示不全”)。例如容器高度 420px、单项高度 50px 时,visibleCount = 9(8 项仅占 400px,需多渲染 1 项覆盖剩余 20px 区域)。
3. 可视区域结束索引(endIndex,含缓冲区)
作用:确定需渲染数据的终点,额外添加 “缓冲区(bufferSize)” 避免滚动时因数据加载延迟出现 “白屏”。
公式:
// totalCount:列表总数据量;bufferSize:缓冲区数量(通常设 3-5 项)
const endIndex = Math.min(startIndex + visibleCount + bufferSize, totalCount);
逻辑解析:在 “起始索引 + 可视数量” 的基础上增加缓冲区,再用 Math.min 限制最大值为总数据量(避免索引超出数据范围)。例如 startIndex=2、visibleCount=9、bufferSize=3 时,endIndex=14,实际渲染 “第 2-14 项”,缓冲区的 3 项可让滚动更流畅。
** 4. 内容层偏移量(offset)**
作用:通过 transform: translateY(offset) 让内容层跟随滚动同步移动,模拟 “完整列表滚动” 的视觉效果。
公式:
const offset = startIndex * itemHeight;
逻辑解析:内容层的偏移距离需与 “已滚出可视区域的总高度” 一致,例如 startIndex=2、itemHeight=50px 时,offset=100px,内容层向上偏移 100px,确保当前 startIndex 对应的项刚好顶在可视区域顶部。
5. 幽灵层总高度(phantomHeight)
作用:撑起容器层的滚动条,让用户感知 “完整数据的滚动范围”(避免因仅渲染可视数据导致滚动条异常)。
公式:
// totalCount:列表总数据量
const phantomHeight = totalCount * itemHeight;
逻辑解析:幽灵层高度需等于 “全部数据渲染后的总高度”,例如 1000 条数据、单项高度 50px 时,phantomHeight=50000px,容器层滚动条会按 “50000px” 的总高度计算滚动比例,与真实长列表体验一致。
┌─────────────────────────────────────────────────────────────┐
│ 初始化流程 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────┐
│ 接收数据源 │
│ listData[] │
└────────┬────────┘
│
▼
┌─────────────────┐
│ 计算容器高度 │
│ containerHeight│
└────────┬────────┘
│
▼
┌─────────────────┐
│ 计算单项高度 │
│ itemHeight │
└────────┬────────┘
│
▼
┌─────────────────┐
│ 计算幽灵层高度 │
│ phantomHeight │
└────────┬────────┘
│
▼
┌─────────────────┐
│ 计算初始可见项 │
│ visibleData │
└────────┬────────┘
│
▼
┌─────────────────┐
│ 渲染初始视图 │
└─────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 滚动更新流程 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────┐
│ scroll │
│ 事件 │
└────────┬────────┘
│
▼
┌─────────────────┐
│ 获取 scrollTop │
└────────┬────────┘
│
▼
┌─────────────────┐
│ 计算新的 │
│ startIndex │
└────────┬────────┘
│
▼
┌─────────────────┐
│ startIndex │──── 否 ────┐
│ 是否变化? │ │
└────────┬────────┘ │
│ 是 │
▼ │
┌─────────────────┐ │
│ 重新计算 │ │
│ visibleData │ │
└────────┬────────┘ │
│ │
▼ │
┌─────────────────┐ │
│ 更新 transform │ │
│ 偏移量 │ │
└────────┬────────┘ │
│ │
▼ ▼
┌─────────────────────────────────┐
│ 渲染更新 │
└─────────────────────────────────┘
<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" class="list-item"
:style="{ height: itemHeight + 'px' }">
<slot :item="item" :index="startIndex + index">
{{ item }}
</slot>
</div>
</div>
</div>
</template>
.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;
&:last-child {
border-bottom: none;
}
}
const props = defineProps({
// 数据源
listData: {
type: Array,
default: () => []
},
// 单项高度
itemHeight: {
type: Number,
default: 50
},
// 容器高度
containerHeight: {
type: Number,
default: 400
},
//容器宽度
containerWidth: {
type: Number,
default: 300
},
// 缓冲区大小
bufferSize: {
type: Number,
default: 5
}
})
// 容器 ref
const containerRef = ref(null);
// 当前滚动位置
const scrollTop = ref(0);
// 计算:幽灵层高度(撑起滚动条)
const phantomHeight = computed(() => {
return listData.value.length * itemHeight.value
})
//计算:开始索引
const startIndex = computed(() => {
return Math.floor(scrollTop.value / itemHeight.value)
})
//计算: 可见数量
const visibleCount = computed(() => {
return Math.ceil(containerHeight.value / itemHeight.value) + bufferSize.value
})
//计算:结束索引
const endIndex = computed(() => {
return Math.min(startIndex.value + visibleCount.value, listData.value.length)
})
//计算:可见数据
const visibleData = computed(() => {
return listData.value.slice(startIndex.value, endIndex.value)
})
//计算:内容偏移量
const contentOffset = computed(() => {
return startIndex.value * itemHeight.value
})
/** 滚动处理 RAF */
let ticking = false
const handleScroll = (e) => {
if (!ticking) {
requestAnimationFrame(() => {
scrollTop.value = e.target.scrollTop
ticking = false
})
ticking = true
}
}
/** 生命周期 */
onMounted(() => {
containerRef.value?.addEventListener('scroll', handleScroll, { passive: true })
})
onUnmounted(() => {
containerRef.value?.removeEventListener('scroll', handleScroll)
})
<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" class="list-item"
:style="{ height: itemHeight + 'px' }">
<slot :item="item" :index="startIndex + index">
{{ item }}
</slot>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, toRefs } from 'vue';
const props = defineProps({
// 数据源
listData: {
type: Array,
default: () => []
},
// 单项高度
itemHeight: {
type: Number,
default: 50
},
// 容器高度
containerHeight: {
type: Number,
default: 400
},
//容器宽度
containerWidth: {
type: Number,
default: 300
},
// 缓冲区大小
bufferSize: {
type: Number,
default: 5
}
})
const { listData, itemHeight, containerHeight, bufferSize } = toRefs(props)
// 容器 ref
const containerRef = ref(null);
// 当前滚动位置
const scrollTop = ref(0);
// 计算:幽灵层高度(撑起滚动条)
const phantomHeight = computed(() => {
return listData.value.length * itemHeight.value
})
//计算:开始索引
const startIndex = computed(() => {
return Math.floor(scrollTop.value / itemHeight.value)
})
//计算: 可见数量
const visibleCount = computed(() => {
return Math.ceil(containerHeight.value / itemHeight.value) + bufferSize.value
})
//计算:结束索引
const endIndex = computed(() => {
return Math.min(startIndex.value + visibleCount.value, listData.value.length)
})
//计算:可见数据
const visibleData = computed(() => {
return listData.value.slice(startIndex.value, endIndex.value)
})
//计算:内容偏移量
const contentOffset = computed(() => {
return startIndex.value * itemHeight.value
})
/** 滚动处理 RAF */
let ticking = false
const handleScroll = (e) => {
if (!ticking) {
requestAnimationFrame(() => {
scrollTop.value = e.target.scrollTop
ticking = false
})
ticking = true
}
}
/** 生命周期 */
onMounted(() => {
containerRef.value?.addEventListener('scroll', handleScroll, { passive: true })
})
onUnmounted(() => {
containerRef.value?.removeEventListener('scroll', handleScroll)
})
</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;
&:last-child {
border-bottom: none;
}
}
</style>