虚拟列表原理与实战运用场景详解

目录

[第一章 前言](#第一章 前言)

[第二章 虚拟列表](#第二章 虚拟列表)

[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 底层核心要点

  1. DOM节点复用:不频繁删除/创建DOM,只更新可视区域内DOM的内容(如文本、图片),减少DOM操作开销(DOM操作是前端性能瓶颈之一);
  2. 滚动事件节流:滚动事件触发频率极高(每秒几十次),避免频繁计算导致卡顿,使用了节流防抖;
  3. 定位优化:用transform: translateY(offsetY)定位渲染区域,利用GPU加速,避免触发浏览器重排重绘(比top/left定位性能提升50%以上);
  4. 高度缓存(不定高核心):不定高场景下,先给一个预定高度,然后滚动到可视区域是,会缓存每个列表项的实际高度,更新原来的预定高度,避免重复获取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>
  • **参数定义,**每个参数作用如下:
  1. containerRef:获取可视区域DOM,用于获取scrollTop、clientHeight;
  2. itemRefs:存储所有渲染的列表项DOM,用于后续获取真实高度;
  3. estimateHeight:预估高度(100px),初始化时临时占位,解决"一开始不知道真实高度"的问题;
  4. buffer:缓冲区(5条),可视区域上下各多渲染5条,避免快速滚动时出现白屏;
  5. scrollTop:滚动偏移量,记录可视区域滚动的距离;
  6. containerHeight:可视区域高度,初始化时获取真实容器高度(避免固定值适配问题);
  7. heightMap:高度缓存数组,存储每一条列表项的真实高度,核心作用是"只获取一次真实高度,后续复用",减少性能损耗。
  • **方法,**解析如下:
  1. getAccumulatedHeight:计算累计高度(最核心方法);作用 :计算"前index条列表项的总高度",替代定高场景的"index×itemHeight",是所有定位计算的基础。 逻辑:从heightMap(高度缓存)中截取前index条数据,累加它们的高度,得到累计高度。比如index=5,就是前5条列表项的真实高度之和。
  2. getStartIndex:计算可视区域的起始索引;作用 :确定"当前滚动位置,应该从哪一条数据开始渲染",避免渲染无关数据。 逻辑 :遍历heightMap,累加高度,直到累加高度超过scrollTop(滚动偏移量),此时的索引就是起始索引------意味着"从这条数据开始,才是用户当前能看到的内容"。 补充:如果heightMap为空(未缓存任何高度),直接返回0,从第一条开始渲染。
  3. 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
相关推荐
weixin_408099672 小时前
【实战教程】EasyClick 调用 OCR 文字识别 API(自动识别屏幕文字 + 完整示例代码)
前端·人工智能·后端·ocr·api·安卓·easyclick
Bigger2 小时前
第四章:我是如何扒开 Claude Code 记忆与上下文压缩机制的
前端·claude·源码阅读
还在忙碌的吴小二2 小时前
在 Mac 上安装并通过端口调用 Chrome DevTools MCP Server(谷歌官方 MCP 服务器)
服务器·前端·chrome·macos·chrome devtools
灵感__idea9 小时前
Hello 算法:贪心的世界
前端·javascript·算法
GreenTea11 小时前
一文搞懂Harness Engineering与Meta-Harness
前端·人工智能·后端
周末也要写八哥12 小时前
html网页设计适合新手的学习路线总结
html
killerbasd12 小时前
牧苏苏传 我不装了 4/7
前端·javascript·vue.js
吴声子夜歌13 小时前
ES6——二进制数组详解
前端·ecmascript·es6
码事漫谈13 小时前
手把手带你部署本地模型,让你Token自由(小白专属)
前端·后端