目录
[第一章 前言](#第一章 前言)
[第二章 虚拟列表](#第二章 虚拟列表)
[2.1 为什么使用虚拟列表?](#2.1 为什么使用虚拟列表?)
[2.2 虚拟列表原理](#2.2 虚拟列表原理)
[2.2.1 核心概念](#2.2.1 核心概念)
[2.2.2 底层核心要点](#2.2.2 底层核心要点)
[2.3 定高场景](#2.3 定高场景)
[2.3.1 原理](#2.3.1 原理)
[2.3.2 核心计算公式](#2.3.2 核心计算公式)
[2.3.3 实战](#2.3.3 实战)
[2.4 不定高场景](#2.4 不定高场景)
[2.4.1 原理](#2.4.1 原理)
[2.4.3 实现方案](#2.4.3 实现方案)
[第三章 虚拟列表实战运用场景](#第三章 虚拟列表实战运用场景)
[3.1 后台管理系统(定高场景)](#3.1 后台管理系统(定高场景))
[3.2 聊天应用(模拟:不定高场景)](#3.2 聊天应用(模拟:不定高场景))
[3.3 电商/内容平台(无限滚动,定高/不定高均可)](#3.3 电商/内容平台(无限滚动,定高/不定高均可))
第一章 前言
在前端开发中,我们经常会遇到长列表渲染的场景------后台管理系统的操作日志、聊天应用的消息、电商平台的商品列表,当数据量达到上万条甚至几十万条时,传统的渲染方式会直接导致页面卡顿、掉帧,甚至浏览器崩溃。而虚拟列表,就是解决这一性能瓶颈的"神器"。本文将从「原理」和「场景」两个核心维度,结合实际开发案例,搞懂虚拟列表!!
第二章 虚拟列表
2.1 为什么使用虚拟列表?
传统渲染方式(如Vue的v-for、React的map循环)会将所有数据一次性渲染成DOM节点。假设列表有10万条数据,每个列表项平均占用300字节(实际加上事件监听、样式计算会更多),光是DOM节点就会占用30MB+内存。更关键的是,浏览器需要对这些DOM进行布局计算(重排)和绘制(重绘),滚动时还要持续更新,直接导致帧率掉到10fps以下,移动端甚至会闪退。
反例:直接渲染10万条数据
javascript
<template>
<div class="list">
<div v-for="item in hugeList" :key="item.id">{{ item.text }}</div>
</div>
</template>
虚拟列表的核心思路,无论数据有多少,只渲染当前可视区域内的元素,其他元素用空白占位替代(从而模拟真实的滚动),滚动时再动态替换可视区域的内容。这样一来,DOM节点数量被控制在几十条以内,性能会得到质的提升。
2.2 虚拟列表原理
虚拟列表的本质是「可视区域渲染 + 动态更新」,核心可以概括为3件事:只渲染可视区域附近的数据、用占位容器撑起完整滚动高度、通过滚动动态计算并定位可视数据。其底层核心依赖「DOM复用」「滚动监听」「高度计算」三大核心技术,下面从核心概念、底层核心要点、定高/不定高原理+代码、简化实战实现,全方位拆解。
2.2.1 核心概念
- 可视区域(container):用户当前能看到的区域,固定高度(如500px),设置overflow: auto实现滚动,是虚拟列表的"窗口"。
- 列表项(item) :单个列表元素,分为「定高」和「不定高」两种场景(定高最常用、最易实现,不定高是实战难点)。
- 占位容器(phantom) :用于撑开滚动条,高度 = 总数据条数 × 单个列表项高度(定高)/ 所有列表项实际高度之和(不定高),让滚动条的长度符合全量数据的视觉效果,避免滚动异常。
- 缓冲区(buffer):可视区域上下额外多渲染的几条数据(如上下各5条),防止快速滚动时出现白屏,提升用户体验,是优化核心点之一。
2.2.2 底层核心要点
- DOM节点复用:不频繁删除/创建DOM,只更新可视区域内DOM的内容(如文本、图片),减少DOM操作开销(DOM操作是前端性能瓶颈之一);
- 滚动事件节流:滚动事件触发频率极高(每秒几十次),避免频繁计算导致卡顿,使用了节流防抖;
- 定位优化:用transform: translateY(offsetY)定位渲染区域,利用GPU加速,避免触发浏览器重排重绘(比top/left定位性能提升50%以上);
- 高度缓存(不定高核心):不定高场景下,先给一个预定高度,然后滚动到可视区域是,会缓存每个列表项的实际高度,更新原来的预定高度,避免重复获取DOM高度,减少性能损耗,同时保证定位精准。
2.3 定高场景
定高场景是虚拟列表的基础,也是实际开发中最常用的场景(如表格、固定高度的消息列表),原理简单、代码易落地,核心是"固定高度计算 + 滚动定位"。
2.3.1 原理
定高场景的核心是"高度固定,计算简单":提前确定单个列表项高度,**通过滚动偏移量(scrollTop)计算可视区域的起始/结束索引,**渲染可视区域数据,并用占位容器撑开滚动条,滚动时动态更新可视数据和定位。
2.3.2 核心计算公式
javascript
// 1. 可视区域内最多能显示的列表项数量
const visibleCount = Math.ceil(containerHeight / itemHeight);
// 2. 可视区域起始索引(根据滚动偏移量计算)
const startIndex = Math.floor(scrollTop / itemHeight);
// 3. 可视区域结束索引(加上缓冲区,避免滚动白屏)
const endIndex = Math.min(total - 1, startIndex + visibleCount + buffer);
// 4. 当前需要渲染的可视数据
const visibleData = data.slice(startIndex, endIndex + 1);
// 5. 占位容器总高度(撑开滚动条)
const totalHeight = total * itemHeight;
// 6. 渲染区域定位偏移量(让可视数据显示在正确位置)
const offsetY = startIndex * itemHeight;
2.3.3 实战
javascript
<template>
<!-- 1. 可视区域 -->
<div
class="virtual-container"
ref="containerRef"
@scroll="handleScroll"
>
<!-- 2. 占位容器:撑开滚动条 -->
<div class="phantom" :style="{ height: totalHeight + 'px' }"></div>
<!-- 3. 渲染区域:只渲染可视区域+缓冲区数据 -->
<div
class="render-area"
:style="{ transform: `translateY(${offsetY}px)` }"
>
<div
class="list-item"
v-for="item in visibleData"
:key="item.id"
>
{{ item.text }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue';
// 模拟数据(10万条)
const total = 100000;
const data = ref(
Array.from({ length: total }, (_, i) => ({
id: i,
text: `定高列表项 ${i + 1}`
}))
);
// 核心参数
const containerRef = ref<HTMLDivElement>(null);
const itemHeight = ref(50); // 单个列表项固定高度:50px
const buffer = ref(5); // 缓冲区:上下各5条
const scrollTop = ref(0); // 滚动偏移量
const containerHeight = ref(500); // 可视区域高度:500px
// 计算属性:动态计算核心参数
const visibleCount = computed(() => Math.ceil(containerHeight.value / itemHeight.value));
const startIndex = computed(() => Math.floor(scrollTop.value / itemHeight.value));
const endIndex = computed(() => Math.min(total - 1, startIndex.value + visibleCount.value + buffer.value));
const visibleData = computed(() => data.value.slice(startIndex.value, endIndex.value + 1));
const totalHeight = computed(() => total * itemHeight.value);
const offsetY = computed(() => startIndex.value * itemHeight.value);
// 滚动事件(节流优化)
let scrollTimer: number | null = null;
const handleScroll = () => {
if (scrollTimer) clearTimeout(scrollTimer);
scrollTimer = window.setTimeout(() => {
if (containerRef.value) {
scrollTop.value = containerRef.value.scrollTop;
}
}, 16); // 16ms节流,对应60fps
};
// 组件卸载:清除定时器,避免内存泄漏
onUnmounted(() => {
if (scrollTimer) clearTimeout(scrollTimer);
});
// 初始化:获取可视区域实际高度
onMounted(() => {
if (containerRef.value) {
containerHeight.value = containerRef.value.clientHeight;
}
});
</script>
<style scoped>
.virtual-container {
height: 500px;
overflow: auto;
position: relative;
border: 1px solid #eee;
}
.phantom {
position: absolute;
top: 0;
left: 0;
width: 100%;
opacity: 0;
}
.render-area {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.list-item {
height: 50px;
line-height: 50px;
border-bottom: 1px solid #eee;
padding: 0 16px;
}
</style>
2.4 不定高场景
定高虚拟列表的核心是"固定高度计算",而不定高场景的原理,本质是「动态高度捕获 + 实时校准定位」------由于列表项高度不固定,无法提前通过"总条数×单条高度"计算占位容器总高度,也无法直接通过scrollTop计算可视区域的起始/结束索引,因此需要额外增加"高度缓存"和"位置校准"两个核心步骤,解决定高场景的适配问题。
2.4.1 原理
- 高度缓存:首次渲染或列表项内容变化时,捕获每个列表项的实际高度,存入缓存数组(如heightMap),后续计算时直接复用缓存,避免重复获取DOM高度(减少性能损耗);
- 动态计算 :基于高度缓存数组,通过"累加计算"获取任意索引位置的累计高度,替代定高场景的"索引×固定高度",进而计算可视区域的startIndex、endIndex和占位容器总高度;
- 实时校准:滚动过程中,若发现当前渲染的列表项实际高度与缓存高度不一致(如图片加载完成后高度变化),更新高度缓存并重新计算定位,避免滚动偏移、白屏或内容重叠。
2.4.3 实现方案
"先预估、后修正"。先给列表项设置一个合理的预估高度(如100px),初始化时按预估高度计算startIndex、endIndex和占位容器高度;渲染完成后,通过getBoundingClientRect()获取列表项实际高度,更新高度缓存,再重新计算定位参数(startIndex、offsetY),校准渲染位置,弥补预估偏差。适合图片加载、文本长度不确定的场景。
html
<template>
<div
class="virtual-container"
ref="containerRef"
@scroll="handleScroll"
>
<div class="phantom" :style="{ height: totalHeight + 'px' }"></div>
<div
class="render-area"
:style="{ transform: `translateY(${offsetY}px)` }"
>
<div
class="list-item"
v-for="item in visibleData"
:key="item.id"
ref="itemRefs"
@load="handleImageLoad"
>
<img v-if="item.img" :src="item.img" alt="列表图片" class="list-img">
<div class="list-text">{{ item.text }}</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, nextTick } from 'vue';
// 模拟不定高数据(包含文本、图片,高度不固定)
const total = 10000;
const data = ref(
Array.from({ length: total }, (_, i) => ({
id: i,
text: `不定高列表项 ${i + 1},文本长度随机变化:${'测试文本'.repeat(Math.floor(Math.random() * 5))}`,
img: i % 3 === 0 ? 'https://picsum.photos/200/100?random=' + i : '' // 部分项有图片
}))
);
// 核心参数
const containerRef = ref<HTMLDivElement>(null);
const itemRefs = ref<HTMLDivElement[]>([]); // 列表项DOM引用,用于获取实际高度
const estimateHeight = ref(100); // 预估高度
const buffer = ref(5);
const scrollTop = ref(0);
const containerHeight = ref(500); // 可动态获取
const heightMap = ref<number[]>([]); // 高度缓存数组
// 计算累计高度(核心方法)
const getAccumulatedHeight = (index: number) => {
return heightMap.value.slice(0, index).reduce((total, height) => total + height, 0);
};
// 计算可视区域起始索引
const getStartIndex = () => {
let accumulatedHeight = 0;
for (let i = 0; i < heightMap.value.length; i++) {
accumulatedHeight += heightMap.value[i];
if (accumulatedHeight > scrollTop.value) {
return i;
}
}
return 0;
};
// 计算可视区域结束索引
const getEndIndex = (startIndex: number) => {
let accumulatedHeight = getAccumulatedHeight(startIndex);
let endIndex = startIndex;
while (
endIndex < total - 1 &&
accumulatedHeight < scrollTop.value + containerHeight.value + estimateHeight.value * buffer.value
) {
// 若未缓存高度,用预估高度临时计算
const height = heightMap.value[endIndex + 1] || estimateHeight.value;
accumulatedHeight += height;
endIndex++;
}
return endIndex;
};
// 动态计算核心参数
const startIndex = computed(() => getStartIndex());
const endIndex = computed(() => getEndIndex(startIndex.value));
const visibleData = computed(() => data.value.slice(startIndex.value, endIndex.value + 1));
const totalHeight = computed(() => getAccumulatedHeight(total));
const offsetY = computed(() => getAccumulatedHeight(startIndex.value));
// 滚动事件(节流)
let scrollTimer: number | null = null;
const handleScroll = () => {
if (scrollTimer) clearTimeout(scrollTimer);
scrollTimer = window.setTimeout(() => {
if (containerRef.value) {
scrollTop.value = containerRef.value.scrollTop;
}
}, 16);
};
// 图片加载完成后,校准高度(解决图片加载导致高度变化的问题)
const handleImageLoad = () => {
nextTick(() => {
updateHeightMap();
});
};
// 更新高度缓存
const updateHeightMap = () => {
if (!itemRefs.value.length) return;
visibleData.value.forEach((item, idx) => {
const realIndex = startIndex.value + idx;
const dom = itemRefs.value[idx];
if (dom) {
const realHeight = dom.offsetHeight;
// 若实际高度与缓存高度不一致,更新缓存并重新定位
if (heightMap.value[realIndex] !== realHeight) {
heightMap.value[realIndex] = realHeight;
// 重新计算滚动位置,避免偏移
if (containerRef.value) {
containerRef.value.scrollTop = scrollTop.value;
}
}
}
});
};
// 初始化:获取可视区域高度、初始化高度缓存
onMounted(() => {
if (containerRef.value) {
containerHeight.value = containerRef.value.clientHeight;
}
// 初始化高度缓存(用预估高度)
heightMap.value = Array(total).fill(estimateHeight.value);
// 首次渲染后,更新实际高度缓存
nextTick(() => {
updateHeightMap();
});
});
// 组件卸载:清除定时器
onUnmounted(() => {
if (scrollTimer) clearTimeout(scrollTimer);
});
</script>
<style scoped>
/* 样式与定高类似,取消列表项固定高度 */
.virtual-container {
height: 500px;
overflow: auto;
position: relative;
border: 1px solid #eee;
}
.phantom {
position: absolute;
top: 0;
left: 0;
width: 100%;
opacity: 0;
}
.render-area {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.list-item {
margin-bottom: 8px;
padding: 16px;
border-bottom: 1px solid #eee;
}
.list-img {
max-width: 100%;
margin-bottom: 8px;
}
</style>
- **参数定义,**每个参数作用如下:
- containerRef:获取可视区域DOM,用于获取scrollTop、clientHeight;
- itemRefs:存储所有渲染的列表项DOM,用于后续获取真实高度;
- estimateHeight:预估高度(100px),初始化时临时占位,解决"一开始不知道真实高度"的问题;
- buffer:缓冲区(5条),可视区域上下各多渲染5条,避免快速滚动时出现白屏;
- scrollTop:滚动偏移量,记录可视区域滚动的距离;
- containerHeight:可视区域高度,初始化时获取真实容器高度(避免固定值适配问题);
- heightMap:高度缓存数组,存储每一条列表项的真实高度,核心作用是"只获取一次真实高度,后续复用",减少性能损耗。
- **方法,**解析如下:
- getAccumulatedHeight:计算累计高度(最核心方法);作用 :计算"前index条列表项的总高度",替代定高场景的"index×itemHeight",是所有定位计算的基础。 逻辑:从heightMap(高度缓存)中截取前index条数据,累加它们的高度,得到累计高度。比如index=5,就是前5条列表项的真实高度之和。
- getStartIndex:计算可视区域的起始索引;作用 :确定"当前滚动位置,应该从哪一条数据开始渲染",避免渲染无关数据。 逻辑 :遍历heightMap,累加高度,直到累加高度超过scrollTop(滚动偏移量),此时的索引就是起始索引------意味着"从这条数据开始,才是用户当前能看到的内容"。 补充:如果heightMap为空(未缓存任何高度),直接返回0,从第一条开始渲染。
- getEndIndex:计算可视区域的结束索引;作用 :确定"当前需要渲染到哪一条数据",包含可视区域+缓冲区,避免快速滚动白屏。逻辑:从起始索引开始,累加高度(未缓存的高度用预估高度临时替代),直到累加高度超过"scrollTop + 可视区域高度 + 缓冲区高度",此时的索引就是结束索引------确保渲染的内容足够覆盖可视区域和缓冲区。
第三章 虚拟列表实战运用场景
3.1 后台管理系统(定高场景)
- 场景:操作日志、用户列表、订单列表、数据报表等,这类场景通常数据量庞大(上万条甚至几十万条),且需要支持滚动查看、搜索、筛选等操作,列表项高度固定。
html
<template>
<div class="order-list-container">
<el-input v-model="searchKey" placeholder="搜索订单号" class="mb-4" />
<div
class="virtual-container"
ref="containerRef"
@scroll="handleScroll"
>
<div class="phantom" :style="{ height: totalHeight + 'px' }"></div>
<div
class="render-area"
:style="{ transform: `translateY(${offsetY}px)` }"
>
<el-table
:data="visibleData"
border
size="small"
:row-height="50"
>
<el-table-column label="订单号" prop="orderNo" width="180" />
<el-table-column label="用户" prop="username" width="120" />
<el-table-column label="金额" prop="amount" width="100" />
<el-table-column label="状态" width="120">
<template #default="scope">
<el-tag :type="scope.row.status === 'success' ? 'success' : 'warning'">
{{ scope.row.status === 'success' ? '已完成' : '待支付' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="scope">
<el-button size="mini" @click="handleView(scope.row)">查看</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { ElInput, ElTable, ElTableColumn, ElTag, ElButton } from 'element-plus';
// 模拟订单数据(10万条)
const total = 100000;
const originData = ref(
Array.from({ length: total }, (_, i) => ({
id: i,
orderNo: `OD${2026000000 + i}`,
username: `用户${i + 1}`,
amount: (Math.random() * 1000).toFixed(2),
status: Math.random() > 0.5 ? 'success' : 'pending'
}))
);
// 搜索筛选
const searchKey = ref('');
const filteredData = computed(() => {
if (!searchKey.value) return originData.value;
return originData.value.filter(item => item.orderNo.includes(searchKey.value));
});
// 虚拟列表核心参数(定高,表格行高50px)
const containerRef = ref<HTMLDivElement>(null);
const itemHeight = ref(50);
const buffer = ref(5);
const scrollTop = ref(0);
const containerHeight = ref(600);
const totalFiltered = computed(() => filteredData.value.length);
// 计算核心参数
const visibleCount = computed(() => Math.ceil(containerHeight.value / itemHeight.value));
const startIndex = computed(() => Math.floor(scrollTop.value / itemHeight.value));
const endIndex = computed(() => Math.min(totalFiltered.value - 1, startIndex.value + visibleCount.value + buffer.value));
const visibleData = computed(() => filteredData.value.slice(startIndex.value, endIndex.value + 1));
const totalHeight = computed(() => totalFiltered.value * itemHeight.value);
const offsetY = computed(() => startIndex.value * itemHeight.value);
// 滚动节流
let scrollTimer: number | null = null;
const handleScroll = () => {
if (scrollTimer) clearTimeout(scrollTimer);
scrollTimer = window.setTimeout(() => {
if (containerRef.value) {
scrollTop.value = containerRef.value.scrollTop;
}
}, 16);
};
// 查看订单详情
const handleView = (row: any) => {
console.log('查看订单:', row);
// 实际业务中可打开弹窗展示详情
};
// 初始化和卸载
onMounted(() => {
if (containerRef.value) {
containerHeight.value = containerRef.value.clientHeight;
}
});
onUnmounted(() => {
if (scrollTimer) clearTimeout(scrollTimer);
});
</script>
<style scoped>
.order-list-container {
padding: 16px;
}
.virtual-container {
height: 600px;
overflow: auto;
position: relative;
border: 1px solid #eee;
}
.phantom {
position: absolute;
top: 0;
left: 0;
width: 100%;
opacity: 0;
}
.render-area {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
</style>
- 注意:后台系统多为定高列表(如表格行高固定),适合用基础版虚拟列表,可结合分页、筛选功能,实现"滚动加载+虚拟渲染"双重优化;筛选后需重新计算totalHeight和visibleData,避免滚动异常。
3.2 聊天应用(模拟:不定高场景)
- 场景:微信、企业微信等聊天应用的历史消息列表,用户可能有上万条聊天记录,需要支持向上滚动加载历史消息,且每条消息高度可能不同(文字、图片、文件),多为反向滚动。
html
<template>
<div class="chat-container">
<div
class="message-list"
ref="containerRef"
@scroll="handleScroll"
>
<div class="phantom" :style="{ height: totalHeight + 'px' }"></div>
<div
class="render-area"
:style="{ transform: `translateY(${offsetY}px)` }"
>
<div
class="message-item"
v-for="(item, idx) in visibleData"
:key="item.id"
ref="itemRefs"
:class="{ 'self': item.isSelf }"
>
<div class="avatar" :style="{ background: item.isSelf ? '#409eff' : '#909399' }">
{{ item.avatar }}
</div>
<div class="message-content">
<div class="text" v-if="item.type === 'text'">{{ item.content }}</div>
<img v-if="item.type === 'image'" :src="item.content" alt="聊天图片" class="img-content">
</div>
</div>
</div>
</div>
<div class="input-area">
<el-input v-model="message" placeholder="输入消息..." />
<el-button @click="sendMessage">发送</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue';
import { ElInput, ElButton } from 'element-plus';
// 模拟聊天消息(10000条,反向滚动:最新消息在底部)
const messageList = ref(
Array.from({ length: 10000 }, (_, i) => ({
id: i,
avatar: i % 2 === 0 ? '我' : 'TA',
type: Math.random() > 0.7 ? 'image' : 'text',
content: i % 2 === 0
? `我发送的消息 ${i + 1} ${'测试文本'.repeat(Math.floor(Math.random() * 3))}`
: `对方发送的消息 ${i + 1} ${'测试文本'.repeat(Math.floor(Math.random() * 4))}`,
isSelf: i % 2 === 0,
time: `2026-04-${Math.floor(Math.random() * 30) + 1} ${Math.floor(Math.random() * 24)}:${Math.floor(Math.random() * 60)}`
}))
);
const message = ref('');
const containerRef = ref<HTMLDivElement>(null);
const itemRefs = ref<HTMLDivElement[]>([]);
const estimateHeight = ref(60);
const buffer = ref(8);
const scrollTop = ref(0);
const containerHeight = ref(500);
const heightMap = ref<number[]>(Array(10000).fill(estimateHeight.value));
const total = ref(messageList.value.length);
// 反向滚动核心:计算累计高度(从底部开始)
const getAccumulatedHeight = (index: number) => {
return heightMap.value.slice(index).reduce((total, height) => total + height, 0);
};
// 反向滚动:起始索引(从底部往上计算)
const getStartIndex = () => {
const totalScrollHeight = getAccumulatedHeight(0);
const currentScrollBottom = totalScrollHeight - scrollTop.value;
let accumulatedHeight = 0;
for (let i = total.value - 1; i >= 0; i--) {
accumulatedHeight += heightMap.value[i];
if (accumulatedHeight > currentScrollBottom - containerHeight.value) {
return Math.max(0, i - buffer.value);
}
}
return 0;
};
// 反向滚动:结束索引
const getEndIndex = (startIndex: number) => {
let accumulatedHeight = 0;
let endIndex = startIndex;
while (
endIndex < total.value - 1 &&
accumulatedHeight < containerHeight.value + estimateHeight.value * buffer.value * 2
) {
accumulatedHeight += heightMap.value[endIndex];
endIndex++;
}
return endIndex;
};
// 动态计算核心参数
const startIndex = computed(() => getStartIndex());
const endIndex = computed(() => getEndIndex(startIndex.value));
const visibleData = computed(() => messageList.value.slice(startIndex.value, endIndex.value + 1));
const totalHeight = computed(() => getAccumulatedHeight(0));
const offsetY = computed(() => getAccumulatedHeight(startIndex.value) - (getAccumulatedHeight(0) - scrollTop.value - containerHeight.value));
// 滚动节流
let scrollTimer: number | null = null;
const handleScroll = () => {
if (scrollTimer) clearTimeout(scrollTimer);
scrollTimer = window.setTimeout(() => {
if (containerRef.value) {
scrollTop.value = containerRef.value.scrollTop;
}
}, 16);
};
// 发送消息(新增消息,更新高度缓存)
const sendMessage = () => {
if (!message.value) return;
const newMessage = {
id: total.value,
avatar: '我',
type: 'text',
content: message.value,
isSelf: true,
time: new Date().toLocaleTimeString()
};
messageList.value.push(newMessage);
heightMap.value.push(estimateHeight.value);
total.value++;
message.value = '';
// 滚动到底部
nextTick(() => {
if (containerRef.value) {
containerRef.value.scrollTop = getAccumulatedHeight(0);
}
updateHeightMap();
});
};
// 更新高度缓存
const updateHeightMap = () => {
if (!itemRefs.value.length) return;
visibleData.value.forEach((item, idx) => {
const realIndex = startIndex.value + idx;
const dom = itemRefs.value[idx];
if (dom) {
const realHeight = dom.offsetHeight;
if (heightMap.value[realIndex] !== realHeight) {
heightMap.value[realIndex] = realHeight;
}
}
});
};
// 初始化
onMounted(() => {
if (containerRef.value) {
containerHeight.value = containerRef.value.clientHeight;
// 初始滚动到底部(最新消息)
containerRef.value.scrollTop = getAccumulatedHeight(0);
}
nextTick(() => {
updateHeightMap();
});
});
onUnmounted(() => {
if (scrollTimer) clearTimeout(scrollTimer);
});
</script>
<style scoped>
.chat-container {
width: 400px;
height: 600px;
border: 1px solid #eee;
display: flex;
flex-direction: column;
}
.message-list {
flex: 1;
overflow: auto;
position: relative;
padding: 10px;
}
.phantom {
position: absolute;
top: 0;
left: 0;
width: 100%;
opacity: 0;
}
.render-area {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.message-item {
display: flex;
margin-bottom: 12px;
max-width: 70%;
}
.self {
flex-direction: row-reverse;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
margin-right: 8px;
}
.self .avatar {
margin-right: 0;
margin-left: 8px;
}
.message-content {
background: #f5f5f5;
padding: 8px 12px;
border-radius: 8px;
}
.self .message-content {
background: #409eff;
color: #fff;
}
.img-content {
max-width: 200px;
border-radius: 8px;
}
.input-area {
display: flex;
padding: 10px;
border-top: 1px solid #eee;
gap: 10px;
}
.el-input {
flex: 1;
}
</style>
- 注意:聊天列表通常是"反向滚动"(向上加载历史),需要调整startIndex和offsetY的计算逻辑,适配反向滚动场景;新增消息后需及时更新高度缓存,并滚动到底部。
3.3 电商/内容平台(无限滚动,定高/不定高均可)
- 场景:电商平台的商品列表、短视频列表、资讯列表,这类场景通常采用"无限滚动"(下拉加载更多),数据量会持续增加,若不做优化,DOM节点会不断累积,导致页面越来越卡。
html
<template>
<div class="goods-list-container">
<div
class="virtual-container"
ref="containerRef"
@scroll="handleScroll"
>
<div class="phantom" :style="{ height: totalHeight + 'px' }"></div>
<div
class="render-area"
:style="{ transform: `translateY(${offsetY}px)` }"
>
<div class="goods-item" v-for="item in visibleData" :key="item.id">
<img :src="item.img" alt="商品图片" class="goods-img">
<div class="goods-name">{{ item.name }}</div>
<div class="goods-price">¥{{ item.price.toFixed(2) }}</div>
<el-button size="mini" class="add-cart" @click="addCart(item)">加入购物车</el-button>
</div>
<div class="loading" v-if="loading">加载中...</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { ElButton } from 'element-plus';
// 商品数据(初始加载2000条,下拉加载更多)
const goodsList = ref([]);
const page = ref(1);
const pageSize = ref(2000);
const loading = ref(false);
const total = ref(0);
// 模拟接口请求,加载商品数据
const fetchGoods = async () => {
loading.value = true;
// 模拟接口延迟
await new Promise(resolve => setTimeout(resolve, 500));
const newData = Array.from({ length: pageSize.value }, (_, i) => ({
id: (page.value - 1) * pageSize.value + i,
img: `https://picsum.photos/300/300?random=${(page.value - 1) * pageSize.value + i}`,
name: `商品 ${(page.value - 1) * pageSize.value + i + 1} 商品名称描述,长度适中`,
price: Math.random() * 100 + 10
}));
goodsList.value = [...goodsList.value, ...newData];
total.value = goodsList.value.length;
page.value++;
loading.value = false;
};
// 虚拟列表核心参数(定高,商品项高度300px)
const containerRef = ref<HTMLDivElement>(null);
const itemHeight = ref(300);
const buffer = ref(5);
const scrollTop = ref(0);
const containerHeight = ref(600);
// 计算核心参数
const visibleCount = computed(() => Math.ceil(containerHeight.value / itemHeight.value));
const startIndex = computed(() => Math.floor(scrollTop.value / itemHeight.value));
const endIndex = computed(() => Math.min(total.value - 1, startIndex.value + visibleCount.value + buffer.value));
const visibleData = computed(() => goodsList.value.slice(startIndex.value, endIndex.value + 1));
const totalHeight = computed(() => total.value * itemHeight.value);
const offsetY = computed(() => startIndex.value * itemHeight.value);
// 滚动节流 + 无限加载
let scrollTimer: number | null = null;
const handleScroll = () => {
if (scrollTimer) clearTimeout(scrollTimer);
scrollTimer = window.setTimeout(() => {
if (containerRef.value) {
scrollTop.value = containerRef.value.scrollTop;
// 下拉到底部,加载更多
const { scrollTop, scrollHeight, clientHeight } = containerRef.value;
if (scrollTop + clientHeight >= scrollHeight - 100 && !loading.value) {
fetchGoods();
}
}
}, 16);
};
// 加入购物车
const addCart = (item: any) => {
console.log('加入购物车:', item);
// 实际业务中调用加入购物车接口
};
// 初始化:加载初始数据
onMounted(() => {
if (containerRef.value) {
containerHeight.value = containerRef.value.clientHeight;
}
fetchGoods();
});
onUnmounted(() => {
if (scrollTimer) clearTimeout(scrollTimer);
});
</script>
<style scoped>
.goods-list-container {
padding: 16px;
}
.virtual-container {
height: 600px;
overflow: auto;
position: relative;
}
.phantom {
position: absolute;
top: 0;
left: 0;
width: 100%;
opacity: 0;
}
.render-area {
position: absolute;
top: 0;
left: 0;
width: 100%;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
padding: 0 8px;
}
.goods-item {
height: 300px;
border: 1px solid #eee;
border-radius: 8px;
padding: 12px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.goods-img {
width: 100%;
height: 180px;
object-fit: cover;
border-radius: 4px;
margin-bottom: 8px;
}
.goods-name {
font-size: 14px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.goods