虚拟列表:从定高到动态高度的 Vue 3 & React 满分实现

前言

在处理海量数据渲染(如万级甚至十万级列表)时,直接操作 DOM 会导致严重的页面卡顿甚至崩溃。虚拟列表(Virtual List) 作为前端性能优化的"核武器",通过"只渲染可视区"的策略,能将渲染性能提升数个量级。本文将带你从零实现一个支持动态高度的通用虚拟列表。

一、 核心原理解析

虚拟列表本质上是一个"障眼法",其结构通常分为三层:

  1. 外层容器(Container) :固定高度,设置 overflow: auto,负责监听滚动事件。
  2. 占位背景(Placeholder) :高度等于"总数据量 × 列表项高度",用于撑开滚动条,模拟真实滚动的视觉效果。
  3. 渲染内容区(Content Area) :绝对定位,根据滚动距离动态计算起始索引,并通过 translateY 偏移到当前可视区域。

二、 定高虚拟列表

1. 设计思路

  • 可视项数计算Math.ceil(容器高度 / 固定高度) ± 缓冲区 (BUFFER)
  • 起始索引Math.floor(滚动距离 / 固定高度)
  • 偏移量起始索引 * 固定高度

2. Vue 3 + TailwindCSS实现

vue 复制代码
<template>
  <div
    class="min-h-screen bg-gradient-to-br from-indigo-600 to-purple-600 py-10 px-5"
  >
    <div class="bg-white mt-20 h-[calc(100vh-200px)] rounded-xl">
      <!-- 滚动容器 -->
      <div
        ref="virtualListRef"
        class="h-full overflow-auto relative"
        @scroll="handleScroll"
      >
        <!-- 占位容器:用于撑开滚动条,高度 = 总数据量 * 每项高度 -->
        <div :style="{ height: `${totalHeight}px` }"></div>

        <!-- 可视区域列表:通过 transform 定位到滚动位置 -->
        <div
          class="absolute top-0 left-0 right-0"
          :style="{ transform: `translateY(${offsetY}px)` }"
        >
          <div
            v-for="item in visibleList"
            :key="item.id"
            class="py-2 px-4 border-b border-gray-200"
            :class="{
              'bg-pink-200 h-[100px]': item.id % 2 !== 0,
              'bg-green-200 h-[100px]': item.id % 2 === 0,
            }"
          >
            {{ item.name }}
          </div>
        </div>
      </div>
    </div>
    <div
      class="fixed top-2 left-24 -translate-x-1/2 px-8 py-3 bg-white text-indigo-600 rounded-full text-base font-semibold cursor-pointer shadow-lg transition-all duration-300 hover:-translate-x-1/2 hover:-translate-y-0.5 hover:shadow-2xl"
      @click="goBack"
    >
      ← 返回首页
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';

const router = useRouter();

const ITEM_HEIGHT = 100; // 列表项固定高度(与样式中的 h-[100px] 一致)
const BUFFER = 5; // 缓冲区数量,避免滚动时出现空白

const virtualListRef = ref<HTMLDivElement | null>(null);

const ListData = ref<any[]>([]); // 完整列表数据
const scrollTop = ref(0); // 滚动容器的滚动距离

// 总列表高度(撑开滚动条用)
const totalHeight = computed(() => ListData.value.length * ITEM_HEIGHT);

// 可视区域高度(滚动容器的高度)
const viewportHeight = computed(() => {
  return virtualListRef.value?.clientHeight || 0;
});

// 可视区域可显示的列表项数量(向上取整 + 缓冲区)
const visibleCount = computed(() => {
  return Math.ceil(viewportHeight.value / ITEM_HEIGHT) + BUFFER;
});

// 当前显示的起始索引
const startIndex = computed(() => {
  // 滚动距离 / 每项高度 = 跳过的项数(向下取整)
  const index = Math.floor(scrollTop.value / ITEM_HEIGHT);
  // 防止索引为负数
  return Math.max(0, index);
});

// 当前显示的结束索引
const endIndex = computed(() => {
  const end = startIndex.value + visibleCount.value;
  // 防止超出总数据长度
  return Math.min(end, ListData.value.length);
});

// 可视区域需要渲染的列表数据
const visibleList = computed(() => {
  return ListData.value.slice(startIndex.value, endIndex.value);
});

// 可视区域的偏移量(让列表项定位到正确位置)
const offsetY = computed(() => {
  return startIndex.value * ITEM_HEIGHT;
});

// 处理滚动事件
const handleScroll = () => {
  if (virtualListRef.value) {
    scrollTop.value = virtualListRef.value.scrollTop;
  }
};

// 返回首页
const goBack = () => {
  router.push('/home');
};

// 初始化
onMounted(() => {
  // 生成模拟数据
  ListData.value = Array.from({ length: 1000 }, (_, index) => ({
    id: index,
    name: `Item ${index}`,
  }));
});
</script>

3. 实现效果图


三、 进阶:不定高(动态高度)虚拟列表

在实际业务(如社交动态、聊天记录)中,每个 Item 的高度往往是不固定的。

1. 核心改进思路

  • 高度映射表(Map) :记录每一个 Item 渲染后的真实高度。
  • 累计高度数组(Cumulative Heights) :存储每一项相对于顶部的偏移位置。
  • ResizeObserver:利用该 API 监听子组件高度变化,实时更新映射表,解决图片加载或文本折行导致的位移。

2. Vue 3 + tailwindCSS 实现(子组件抽离)

子组件: 负责上报真实高度:

vue 复制代码
<template>
  <div
    ref="itemRef"
    class="py-2 px-4 border-b border-gray-200"
    :class="{
      'bg-pink-200': item.id % 2 !== 0,
      'bg-green-200': item.id % 2 === 0,
    }"
    :style="{ height: item.id % 2 === 0 ? '150px' : '100px' }"
  >
    {{ item.name }}
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUpdated, onUnmounted, watch, nextTick } from 'vue';

// 定义props:接收父组件传递的item数据
const props = defineProps<{
  item: {
    id: number;
    name: string;
  };
}>();

// 定义emit:向父组件传递高度更新事件
const emit = defineEmits<{
  (e: 'update-height', id: number, height: number): void;
}>();

const itemRef = ref<HTMLDivElement | null>(null);
let resizeObserver: ResizeObserver | null = null;

// 计算并发送当前组件的高度
const sendItemHeight = () => {
  if (!itemRef.value) return;
  const realHeight = itemRef.value.offsetHeight;
  emit('update-height', props.item.id, realHeight);
};

// 监听组件挂载:首次发送高度 + 监听高度变化
onMounted(() => {
  // 首次渲染完成后发送高度
  nextTick(() => {
    sendItemHeight();
  });

  // 监听元素高度变化(适配动态内容导致的高度变化)
  if (window.ResizeObserver) {
    resizeObserver = new ResizeObserver(() => {
      sendItemHeight();
    });
    if (itemRef.value) {
      resizeObserver.observe(itemRef.value);
    }
  }
});

// 组件更新后重新发送高度(比如内容变化)
onUpdated(() => {
  nextTick(() => {
    sendItemHeight();
  });
});

// 组件卸载:清理监听
onUnmounted(() => {
  if (resizeObserver) {
    resizeObserver.disconnect();
    resizeObserver = null;
  }
});

// 监听item变化:如果item替换,重新计算高度
watch(
  () => props.item.id,
  () => {
    nextTick(() => {
      sendItemHeight();
    });
  }
);
</script>

父组件:核心逻辑

vue 复制代码
<template>
  <div
    class="min-h-screen bg-gradient-to-br from-indigo-600 to-purple-600 py-10 px-5"
  >
    <div class="bg-white mt-20 h-[calc(100vh-200px)] rounded-xl">
      <!-- 滚动容器 -->
      <div
        ref="virtualListRef"
        class="h-full overflow-auto relative"
        @scroll="handleScroll"
      >
        <!-- 占位容器:撑开滚动条 -->
        <div :style="{ height: `${totalHeight}px` }"></div>

        <!-- 可视区域列表 -->
        <div
          class="absolute top-0 left-0 right-0"
          :style="{ transform: `translateY(${offsetY}px)` }"
        >
          <!-- 渲染子组件,监听高度更新事件 -->
          <VirtualListItem
            v-for="item in visibleList"
            :key="item.id"
            :item="item"
            @update-height="handleItemHeightUpdate"
          />
        </div>
      </div>
    </div>
    <div
      class="fixed top-2 left-24 -translate-x-1/2 px-8 py-3 bg-white text-indigo-600 rounded-full text-base font-semibold cursor-pointer shadow-lg transition-all duration-300 hover:-translate-x-1/2 hover:-translate-y-0.5 hover:shadow-2xl"
      @click="goBack"
    >
      ← 返回首页
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, computed, onUnmounted, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import VirtualListItem from './listItem.vue'; // 引入子组件

const router = useRouter();

const MIN_ITEM_HEIGHT = 100; // 子项预设的最小高度
const BUFFER = 5; //上下缓冲区数目
const virtualListRef = ref<HTMLDivElement | null>(null); // 滚动容器引用

const ListData = ref<any[]>([]); // 完整列表数据
const scrollTop = ref(0); // 滚动距离
const itemHeights = ref<Map<number, number>>(new Map()); // 子组件高度映射表
const cumulativeHeights = ref<number[]>([0]); // 累计高度数组
const scrollTimer = ref<number | null>(null); // 滚动节流定时器
const isUpdatingCumulative = ref(false); // 累计高度更新防抖

// 初始化位置数据
const initPositionData = () => {
  // 初始化高度映射表(默认最小高度)
  const heightMap = new Map<number, number>();
  ListData.value.forEach((item) => {
    heightMap.set(item.id, MIN_ITEM_HEIGHT);
  });
  // 初始化累计高度
  updateCumulativeHeights();
};

// 更新累计高度(核心)
const updateCumulativeHeights = () => {
  if (isUpdatingCumulative.value) return;
  isUpdatingCumulative.value = true;

  const itemCount = ListData.value.length;
  const cumulative = [0];
  let sum = 0;

  for (let i = 0; i < itemCount; i++) {
    const itemId = ListData.value[i].id;
    sum += itemHeights.value.get(itemId) || MIN_ITEM_HEIGHT;
    cumulative.push(sum);
  }

  cumulativeHeights.value = cumulative;
  isUpdatingCumulative.value = false;
};

// 处理子组件的高度更新事件
const handleItemHeightUpdate = (id: number, height: number) => {
  // 高度未变化则跳过
  if (itemHeights.value.get(id) === height) return;

  // 更新高度映射表
  itemHeights.value.set(id, height);

  // 异步更新累计高度(避免同步更新导致的性能问题)
  nextTick(() => {
    updateCumulativeHeights();
  });
};

// 总高度,根据统计高度数组最后一个值计算得出
const totalHeight = computed(() => {
  return cumulativeHeights.value[cumulativeHeights.value.length - 1] || 0;
});

// 列表可视区域高度
const viewportHeight = computed(() => {
  return virtualListRef.value?.clientHeight || MIN_ITEM_HEIGHT * 5;
});

// 计算起始索引
const startIndex = computed(() => {
  const totalItemCount = ListData.value.length;
  if (totalItemCount === 0) return 0;
  if (scrollTop.value <= 0) return 0;

  let baseStartIndex = 0;
  // 反向遍历找起始索引
  for (let i = cumulativeHeights.value.length - 1; i >= 0; i--) {
    if (cumulativeHeights.value[i] <= scrollTop.value) {
      baseStartIndex = i;
      break;
    }
  }
  const finalIndex = Math.max(0, baseStartIndex - BUFFER); // 确保不小于0
  return Math.min(finalIndex, totalItemCount - 1);
});

// 计算结束索引
const endIndex = computed(() => {
  const totalItemCount = ListData.value.length;
  const viewportHeightVal = viewportHeight.value;
  if (totalItemCount === 0) return 0;

  const targetScrollBottom = scrollTop.value + viewportHeightVal; // 目标滚动到底部位置
  let baseEndIndex = totalItemCount - 1;
  for (let i = 0; i < cumulativeHeights.value.length; i++) {
    if (cumulativeHeights.value[i] > targetScrollBottom) {
      baseEndIndex = i - 1;
      break;
    }
  }
  const finalEndIndex = Math.min(baseEndIndex + BUFFER, totalItemCount - 1); // 确保不大于总项数-1
  return finalEndIndex;
});

// 可见列表
const visibleList = computed(() => {
  const start = startIndex.value;
  const end = endIndex.value;
  return start <= end ? ListData.value.slice(start, end + 1) : [];
});

const offsetY = computed(() => {
  return cumulativeHeights.value[startIndex.value] || 0;
});

// 滚动节流处理
const handleScroll = () => {
  if (!virtualListRef.value) return;

  if (scrollTimer.value) clearTimeout(scrollTimer.value);
  scrollTimer.value = window.setTimeout(() => {
    scrollTop.value = virtualListRef.value!.scrollTop;
  }, 20);
};

const handleResize = () => {
  if (virtualListRef.value) {
    scrollTop.value = virtualListRef.value.scrollTop;
  }
};

const goBack = () => {
  router.push('/home');
};

// 生命周期
onMounted(() => {
  // 生成模拟数据
  ListData.value = Array.from({ length: 1000 }, (_, index) => ({
    id: index,
    name: `Item ${index}`,
  }));
  initPositionData();
  window.addEventListener('resize', handleResize); // 监听窗口大小变化
});

onUnmounted(() => {
  window.removeEventListener('resize', handleResize);
  if (scrollTimer.value) clearTimeout(scrollTimer.value);
  isUpdatingCumulative.value = false;
  itemHeights.value.clear();
});
</script>

3. React + tailwindCSS 实现(子组件抽离)

子组件:

tsx 复制代码
import React, { useEffect, useRef, useState, useCallback } from 'react';

interface VirtualListItemProps {
  item: {
    id: number;
    name: string;
  };
  onUpdateHeight: (id: number, height: number) => void; // 替代 Vue 的 emit
}

const VirtualListItem: React.FC<VirtualListItemProps> = ({
  item,
  onUpdateHeight,
}) => {
  const itemRef = useRef<HTMLDivElement>(null);
  // 存储 ResizeObserver 实例(避免重复创建)
  const resizeObserverRef = useRef<ResizeObserver | null>(null);

  // 计算并上报高度
  const sendItemHeight = useCallback(() => {
    if (!itemRef.current) return;
    const realHeight = itemRef.current.offsetHeight;
    onUpdateHeight(item.id, realHeight);
  }, [item.id, onUpdateHeight]);

  useEffect(() => {
    const timer = setTimeout(() => {
      sendItemHeight();
    }, 0);

    // 初始化 ResizeObserver 监听高度变化
    if (window.ResizeObserver) {
      resizeObserverRef.current = new ResizeObserver(() => {
        sendItemHeight();
      });
      if (itemRef.current) {
        resizeObserverRef.current.observe(itemRef.current);
      }
    }

    // 清理定时器(对应 Vue 的 onUnmounted 部分)
    return () => {
      clearTimeout(timer);
      if (resizeObserverRef.current) {
        resizeObserverRef.current.disconnect();
        resizeObserverRef.current = null;
      }
    };
  }, [sendItemHeight]); // 仅首次挂载执行

  //监听 item 变化重新计算高度
  useEffect(() => {
    const timer = setTimeout(() => {
      sendItemHeight();
    }, 0);
    return () => clearTimeout(timer);
  }, [item.id, sendItemHeight]); // item.id 变化时执行

  const itemClass = `py-2 px-4 border-b border-gray-200 ${
    item.id % 2 !== 0 ? 'bg-pink-200' : 'bg-green-200'
  }`;

  const itemStyle: React.CSSProperties = {
    height: item.id % 2 === 0 ? '150px' : '100px',
  };

  return (
    <div ref={itemRef} className={itemClass} style={itemStyle}>
      {item.name}
    </div>
  );
};

export default VirtualListItem;

父组件:

tsx 复制代码
import React, {
  useEffect,
  useRef,
  useState,
  useCallback,
  useMemo,
} from 'react';
import VirtualListItem from './listItem';

const VirtualList: React.FC = () => {
  const MIN_ITEM_HEIGHT = 100; // 最小项高度
  const BUFFER = 5; // 缓冲区项数

  const virtualListRef = useRef<HTMLDivElement>(null); // 虚拟列表容器引用

  const [listData, setListData] = useState<Array<{ id: number; name: string }>>(
    []
  ); // 列表数据
  const [scrollTop, setScrollTop] = useState(0); // 滚动位置
  const [itemHeights, setItemHeights] = useState<Map<number, number>>(
    new Map()
  ); // 高度映射表(Map 结构)
  const [cumulativeHeights, setCumulativeHeights] = useState<number[]>([0]); // 累计高度数组
  const scrollTimerRef = useRef<number | null>(null); // 滚动节流定时器

  // 初始化模拟数据
  const initData = () => {
    const mockData = Array.from({ length: 1000 }, (_, index) => ({
      id: index,
      name: `Item ${index}`,
    }));
    setListData(mockData);
    // 初始化高度映射表(默认最小高度)
    const initHeightMap = new Map<number, number>();
    mockData.forEach((item) => {
      initHeightMap.set(item.id, MIN_ITEM_HEIGHT);
    });
    setItemHeights(initHeightMap);
    // 初始化累计高度
    updateCumulativeHeights(initHeightMap, mockData);
  };

  useEffect(() => {
    initData();
    // 监听窗口大小变化
    const handleResize = () => {
      if (virtualListRef.current) {
        setScrollTop(virtualListRef.current.scrollTop);
      }
    };
    window.addEventListener('resize', handleResize);

    // 清理监听
    return () => {
      window.removeEventListener('resize', handleResize);
      if (scrollTimerRef.current) {
        clearTimeout(scrollTimerRef.current);
      }
      itemHeights.clear(); // 清空 Map 释放内存
    };
  }, []);

  // 更新累计高度(核心函数)
  const updateCumulativeHeights = useCallback(
    (heightMap: Map<number, number>, data: typeof listData) => {
      const cumulative = [0];
      let sum = 0;
      for (let i = 0; i < data.length; i++) {
        const itemId = data[i].id;
        sum += heightMap.get(itemId) || MIN_ITEM_HEIGHT;
        cumulative.push(sum);
      }
      setCumulativeHeights(cumulative);
    },
    [MIN_ITEM_HEIGHT]
  );

  // 处理子组件的高度更新事件(对应 Vue 的 handleItemHeightUpdate)
  const handleItemHeightUpdate = useCallback(
    (id: number, height: number) => {
      // 高度未变化则跳过
      if (itemHeights.get(id) === height) return;

      // 更新高度映射表
      const newHeightMap = new Map(itemHeights);
      newHeightMap.set(id, height);
      setItemHeights(newHeightMap);

      // 异步更新累计高度
      setTimeout(() => {
        updateCumulativeHeights(newHeightMap, listData);
      }, 0);
    },
    [itemHeights, listData, updateCumulativeHeights]
  );

  // 滚动节流处理
  const handleScroll = useCallback(() => {
    if (!virtualListRef.current) return;

    // 节流:20ms 内只更新一次 scrollTop
    if (scrollTimerRef.current) {
      clearTimeout(scrollTimerRef.current);
    }
    scrollTimerRef.current = setTimeout(() => {
      setScrollTop(virtualListRef.current!.scrollTop);
    }, 20);
  }, []);

  // 可视区域高度
  const viewportHeight = useMemo(() => {
    return virtualListRef.current?.clientHeight || MIN_ITEM_HEIGHT * 5;
  }, []);

  //  总列表高度
  const totalHeight = useMemo(() => {
    return cumulativeHeights[cumulativeHeights.length - 1] || 0;
  }, [cumulativeHeights]);

  // 起始索引
  const startIndex = useMemo(() => {
    const totalItemCount = listData.length;
    if (totalItemCount === 0) return 0;
    if (scrollTop <= 0) return 0;

    // 反向遍历找起始索引
    let baseStartIndex = 0;
    for (let i = cumulativeHeights.length - 1; i >= 0; i--) {
      if (cumulativeHeights[i] <= scrollTop) {
        baseStartIndex = i;
        break;
      }
    }

    const finalIndex = Math.max(0, baseStartIndex - BUFFER);
    return Math.min(finalIndex, totalItemCount - 1);
  }, [
    scrollTop,
    viewportHeight,
    totalHeight,
    cumulativeHeights,
    listData.length,
  ]);

  // 结束索引
  const endIndex = useMemo(() => {
    const totalItemCount = listData.length;
    if (totalItemCount === 0) return 0;

    const targetScrollBottom = scrollTop + viewportHeight;
    let baseEndIndex = totalItemCount - 1;

    for (let i = 0; i < cumulativeHeights.length; i++) {
      if (cumulativeHeights[i] > targetScrollBottom) {
        baseEndIndex = i - 1;
        break;
      }
    }

    let finalEndIndex = baseEndIndex + BUFFER;
    finalEndIndex = Math.min(finalEndIndex, totalItemCount - 1);
    return finalEndIndex;
  }, [scrollTop, viewportHeight, cumulativeHeights, listData.length]);

  // 可视区列表
  const visibleList = useMemo(() => {
    return startIndex <= endIndex
      ? listData.slice(startIndex, endIndex + 1)
      : [];
  }, [startIndex, endIndex, listData]);

  // 偏移量
  const offsetY = useMemo(() => {
    return cumulativeHeights[startIndex] || 0;
  }, [startIndex, cumulativeHeights]);

  return (
    <div className="h-full bg-gradient-to-br from-indigo-600 to-purple-600 py-10 px-5">
      <div className="bg-white mt-10 h-[calc(100vh-200px)] rounded-xl">
        {/* 滚动容器 */}
        <div
          ref={virtualListRef}
          className="h-full overflow-auto relative"
          onScroll={handleScroll}
        >
          {/* 占位容器:撑开滚动条 */}
          <div style={{ height: `${totalHeight}px` }}></div>

          {/* 可视区域列表:transform 偏移 */}
          <div
            className="absolute top-0 left-0 right-0"
            style={{ transform: `translateY(${offsetY}px)` }}
          >
            {visibleList.map((item) => (
              <VirtualListItem
                key={item.id}
                item={item}
                onUpdateHeight={handleItemHeightUpdate}
              />
            ))}
          </div>
        </div>
      </div>
    </div>
  );
};

export default VirtualList;

4. 实现效果图


四、 总结与避坑指南

1. 为什么需要缓冲区(BUFFER)?

如果只渲染可见部分,用户快速滚动时,异步渲染可能会导致瞬间的"白屏"。设置上下缓冲区可以预加载部分 DOM,让滑动更顺滑。

2. 性能进一步优化

  • 滚动节流(Throttle) :虽然滚动监听很快,但在 handleScroll 中加入 requestAnimationFrame 或 20ms 的节流,能有效减轻主线程压力。
  • Key 的选择 :在虚拟列表中,key 必须是唯一的 id,绝对不能使用 index,否则在滚动重用 DOM 时会出现状态错乱。

3. 注意事项

  • 定高:逻辑简单,性能极高。
  • 不定高 :依赖 ResizeObserver,需注意频繁重排对性能的影响,建议对 updateCumulativeHeights 做异步批处理。
相关推荐
css趣多多2 小时前
add组件增删改的表单处理
java·服务器·前端
证榜样呀2 小时前
2026 大专计算机专业必考证书推荐什么
大数据·前端
蓝帆傲亦2 小时前
前端性能极速优化完全指南:从加载秒开体验到丝滑交互
前端·交互
鱼毓屿御2 小时前
如何给用户添加权限
前端·javascript·vue.js
JustHappy2 小时前
「web extensions🛠️」有关浏览器扩展,开发前你需要知道一些......
前端·javascript·开源
何中应2 小时前
nvm安装使用
前端·node.js·开发工具
Java新手村2 小时前
基于 Vue 3 + Spring Boot 3 的 AI 面试辅助系统:实时语音识别 + 大模型智能回答
vue.js·人工智能·spring boot
全栈探索者2 小时前
列表渲染不用 map,用 ForEach!—— React 开发者的鸿蒙入门指南(第 4 期)
react.js·harmonyos·arkts·foreach·列表渲染
雯0609~2 小时前
hiprint:实现项目部署与打印3-vue版本-独立出模板设计与模板打印页面
前端·vue.js·arcgis