前言

在某种特殊场景下,我们需要将 大量数据 使用不分页的方式渲染到列表上,比如说渲染1000+数据时,传统的v-for渲染会遇到以下问题:

问题描述
DOM 节点爆炸1000 条数据 = 1000+ 个 DOM 节点,浏览器内存占用飙升
首屏渲染慢大量 DOM 创建导致首屏白屏时间过长(可能 > 3s)
滚动卡顿滚动时触发大量重排重绘,FPS 急剧下降
内存泄漏风险长列表中如果有事件,容易造成内存泄漏

我们怎么办呢?这时们需要 “虚拟列表”,来解决上面描述的问题。

因为虚拟列表是按需显示的一种实现,只对可见区域渲染,对非可见区域不渲染或部分渲染,从而减少性能消耗

方案

虚拟列表原理

分析真实业务场景,将全部数据渲染到列表中是无用且浪费资源的行为,只需要根据用户的视窗进行部分渲染即可

┌─────────────────────────────────────────────────────────────┐
│                     完整数据列表 (10000条)                    │
│  ┌─────────────────────────────────────────────────────┐    │
│  │ Item 0                                              │    │
│  │ Item 1                                              │    │
│  │ ...                                                 │    │
│  │ Item 50  ◄─── 可视区域起点 (startIndex)              │    │
│  ├─────────────────────────────────────────────────────┤    │
│  │ ████████████████████████████████████████████████████│◄───┼── 可视区域
│  │ ████████████████████████████████████████████████████│    │   (Viewport)
│  │ ████████████████████████████████████████████████████│    │   只渲染这部分
│  │ ████████████████████████████████████████████████████│    │
│  ├─────────────────────────────────────────────────────┤    │
│  │ Item 70  ◄─── 可视区域终点 (endIndex)                │    │
│  │ ...                                                 │    │
│  │ Item 9999                                           │    │
│  └─────────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────────┘

组件结构

虚拟列表的结构通常由三个核心层级组成,各层级分工明确、协同实现 “按需渲染 + 流畅滚动” 的效果,具体如下:

  1. 容器层(Container) :作为最外层的基础框架,需设置固定高度(如通过 props 传入 containerHeight),并配置 overflow: auto 以显示滚动条。它是整个虚拟列表的视觉边界,也是滚动事件的载体,用户通过操作该层的滚动条触发内容动态更新。
  2. 幽灵层(Phantom) :与内容层处于同一层级,核心作用是 “撑起滚动条高度”。由于虚拟列表仅渲染可视区域内容,若缺少幽灵层,容器层会因实际内容过少无法显示完整滚动条。因此,幽灵层的高度需设置为 “总数据量 × 单项预估高度”,并通过 position: absolute 和 z-index: -1 使其处于内容层下方,既不干扰视觉,又能让用户感知到 “下方有完整数据” 的滚动预期。
  3. 内容层(Content) :是实际渲染可视区域数据的核心层级,仅包含当前用户能看到的列表项(如 “可视区域起点~终点” 内的 Item)。它通过 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=2visibleCount=9bufferSize=3 时,endIndex=14,实际渲染 “第 2-14 项”,缓冲区的 3 项可让滚动更流畅。


** 4. 内容层偏移量(offset)** 作用:通过 transform: translateY(offset) 让内容层跟随滚动同步移动,模拟 “完整列表滚动” 的视觉效果。
公式

const offset = startIndex * itemHeight;

逻辑解析:内容层的偏移距离需与 “已滚出可视区域的总高度” 一致,例如 startIndex=2itemHeight=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 │            │
                    │  偏移量         │            │
                    └────────┬────────┘            │
                              │                    │
                              ▼                    ▼
                    ┌─────────────────────────────────┐
                    │          渲染更新               │
                    └─────────────────────────────────┘

实现步骤

Step 1: 创建容器结构

<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;
    }
}

Step 2: 确定组件接收参数

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
    }
})

Step 3: 计算关键参数

// 容器 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
})

Step 4: 滚动事件

/** 滚动处理 RAF */
let ticking = false
const handleScroll = (e) => {
    if (!ticking) {
        requestAnimationFrame(() => {
            scrollTop.value = e.target.scrollTop
            ticking = false
        })
        ticking = true
    }
}

Step 5:绑定事件

/** 生命周期 */
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>
本站提供的所有下载资源均来自互联网,仅提供学习交流使用,版权归原作者所有。如需商业使用,请联系原作者获得授权。 如您发现有涉嫌侵权的内容,请联系我们 邮箱:alixiixcom@163.com