解决大数据渲染卡顿:Vue3 虚拟列表组件的完整实现方案

文章简介

本文介绍的是 vue3 中虚表组件的实现方式。当需要展示的数据量达到几百上千条时就需要使用虚表,否则大量组件的渲染会导致页面卡顿甚至卡死。 备注:本文介绍的虚表只支持固定且高度相同的数据元素。

实现原理

text 复制代码
		滚动容器
┌─────────────────────────────┐
│                             │
│    ~~~~~~~~~~~~~~~~~~~      │
│    ~  未渲染的虚拟行  ~       │
│    ~~~~~~~~~~~~~~~~~~~      │
│  ┌─────────────────────┐    │
│  │  实际渲染区域	     │    │
│  │  (visibleRows)      │    │
│  └─────────────────────┘    │
│    ~~~~~~~~~~~~~~~~~~~      │
│    ~  未渲染的虚拟行  ~       │
│    ~~~~~~~~~~~~~~~~~~~      │
│                             │
└─────────────────────────────┘
  • 虚表由 3 个元素组成,分别为有固定高度的根元素(滚动容器)提供数据滚动能力、用于撑开根容器的占位元素、用于展示信息的区域渲染元素。
  • 渲染区域在根元素内部使用绝对定位 position: absolute; 脱离文档流。
    • 实时计算需要渲染的元素行。
    • 备注:当需要渲染的元素发生变化时,通过 transform: translateY(100px); 属性对渲染区域进行偏移,确保渲染连续。
  • 根元素使用相对定位 position: relative; 使渲染元素在根元素内部定位、滚动。
  • 占位元素只用来撑开根元素内部空间,让根元素提供滚动能力。
    • 备注:占位元素的高度计算方式:数据量 * 数据展示元素高度。

外部属性定义

  • items: 使用虚表的父组件传入的所有要展示数据源。
  • itemHeight:每个数据元素的展示行高
  • width、height:可由父组件传入固定数值,默认撑满父组件。
  • space:展示元素之间的间距
  • bufferSize:渲染区域上下缓冲区大小
ts 复制代码
const props = defineProps({
  // 数据源 (必须)
  items: {
    type: Array,
    required: true,
  },
  // 行高 (必须,单位px)
  itemHeight: {
    type: Number,
    required: true,
  },
  // 容器宽度 (可选,未指定则撑满父元素)
  width: {
    type: [String, Number],
    default: "100%",
  },
  // 容器高度 (可选,未指定则撑满父元素)
  height: {
    type: [String, Number],
    default: "100%",
  },
  // item 间距 (可选,默认5px)
  space: {
    type: Number,
    default: 8,
  },
  // 上下缓冲区行数,避免快速滚动白屏 (默认5)
  bufferSize: {
    type: Number,
    default: 5,
  },
});

插槽定义

主要用于定义数据展示元素插槽的数据类型,否则使用虚表的父组件在定义数据展示元素时会飘红

ts 复制代码
defineSlots<{
  item(props: { item: any; index: number }): void;
}>();

html 部分

  • viewportRef 绑定根元素对象,用于获取实际视口高度,视口高度会用来计算可展示元素数量
  • containerStyle: 用于设置父组件传递的根容器宽高,或设置默认值
  • virtual-viewport:根元素 css 属性
  • virtual-phantom:占位块 css 属性
  • totalHeight:虚表需要展示的总数据占位高度
  • virtual-content:渲染区 css 属性
  • offsetY:渲染区偏移量
  • visibleRows:实际渲染元素
  • itemHeight:插槽定义的数据展示元素高度,由使用虚表的父组件通过属性传入
  • itemActualHeight:渲染元素实际高度 = 插槽定义的数据展示元素高度(itemHeight) + 元素间隔(space)
html 复制代码
<template>
  <!-- 虚拟滚动视口 -->
  <div
    ref="viewportRef"
    class="virtual-viewport"
    :style="containerStyle"
    @scroll="onScroll"
  >
    <!-- 占位块,撑开滚动空间 -->
    <div class="virtual-phantom" :style="{ height: totalHeight + 'px' }" />

    <!-- 渲染内容区,绝对定位跟随滚动 -->
    <div
      class="virtual-content"
      :style="{ transform: `translateY(${offsetY}px)` }"
    >
      <div
        v-for="row in visibleRows"
        :key="row.index"
        :style="{ height: itemActualHeight + 'px' }"
      >
        <!-- 通过插槽让外部自定义每一项的渲染内容 -->
        <slot
          name="item"
          :item="row.data"
          :index="row.index"
          :style="{ height: itemHeight + 'px' }"
        >
          <!-- 默认渲染,当外部没有提供自定义插槽时使用 -->
          <div>
            <span>#{{ row.index + 1 }}</span>
            <span>{{ row.data }}</span>
          </div>
        </slot>
      </div>
    </div>
  </div>
</template>

css 部分

css 复制代码
<style scoped>
.virtual-viewport {
  position: relative;
  display: flex;
  flex-direction: column;
  overflow-y: auto;
}

.virtual-phantom {
  width: 100%;
  pointer-events: none;
}

.virtual-content {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  will-change: transform;
}
</style>

实时计算变量实现

核心逻辑

  1. 实时计算父组件设置的根元素宽高
  2. 虚表组件挂载后获得视口高度,并订阅根元素的大小变化。
  3. 监听展示数据的变化,超出滚动范围时修正滚动范围。
  4. 实时计算每项元素实际高度
  5. 实时计算占位元素总高度
  6. 实时计算起始结束索引
  7. 实时计算实际渲染的数据行
  8. 实时计算偏移量
  • 实时计算使用 vue3 的 computed() 方法
ts 复制代码
// 引用
const viewportRef = ref<any>(null);
// 实际容器高度 (动态计算)
const viewportHeight = ref(0);
// 滚动位置
const scrollTop = ref(0);
// 滚动事件
const emit = defineEmits<{
  (e: "scroll", scrollTop: number): void;
}>();
const onScroll = (e: any) => {
  scrollTop.value = e.target.scrollTop;
  emit("scroll", scrollTop.value);
};

// 容器样式
const containerStyle = computed(() => {
  const style: any = {};
  style.width =
    typeof props.width === "number" ? `${props.width}px` : props.width;
  style.height =
    typeof props.height === "number" ? `${props.height}px` : props.height;
  return style;
});
// 更新容器高度
const updateViewportHeight = () => {
  if (viewportRef.value) {
    const height = viewportRef.value.clientHeight;
    if (viewportHeight.value !== height) {
      viewportHeight.value = height;
    }
  }
};
// 使用ResizeObserver监听视口尺寸变化
let resizeObserver: any = null;
onMounted(() => {
  // 初始化容器高度
  updateViewportHeight();
  // 监听容器大小变化
  resizeObserver = new ResizeObserver(() => {
    updateViewportHeight();
  });
  if (viewportRef.value) {
    resizeObserver.observe(viewportRef.value);
  }
});
onBeforeUnmount(() => {
  if (resizeObserver) {
    resizeObserver.disconnect();
    resizeObserver = null;
  }
});
// 监听数据变化,确保滚动位置有效
watch(
  () => props.items,
  () => {
    // 如果数据变化后总高度变小,且当前滚动位置超出范围,修正滚动
    nextTick(() => {
      if (viewportRef.value) {
        const maxScroll = Math.max(
          0,
          totalHeight.value - viewportRef.value.clientHeight,
        );
        if (viewportRef.value.scrollTop > maxScroll) {
          viewportRef.value.scrollTop = maxScroll;
        }
      }
    });
  },
);

// 每项实际高度
const itemActualHeight = computed(() => {
  return props.itemHeight + props.space;
});

// 计算总数据量
const totalItems = computed(() => props.items.length);

// 总高度
const totalHeight = computed(() => totalItems.value * itemActualHeight.value);

// 计算起始索引 (基于scrollTop)
const startIndex = computed(() => {
  const rawStart = Math.floor(scrollTop.value / itemActualHeight.value);
  return Math.max(0, rawStart - props.bufferSize);
});

// 计算结束索引
const endIndex = computed(() => {
  const rawEnd = Math.ceil(
    (scrollTop.value + viewportHeight.value) / itemActualHeight.value,
  );
  return Math.min(totalItems.value - 1, rawEnd + props.bufferSize);
});

// 实际需要渲染的行数据
const visibleRows = computed(() => {
  const start = startIndex.value;
  const end = endIndex.value;
  return props.items
    .map((item, index) => ({
      index: start + index,
      data: item,
    }))
    .slice(start, end + 1);
});
// 偏移量
const offsetY = computed(() => startIndex.value * itemActualHeight.value);

对外暴露滚动事件、滚动距离

ts 复制代码
// 滚动位置
const scrollTop = ref(0);
// 滚动事件
const emit = defineEmits<{
  (e: "scroll", scrollTop: number): void;
}>();
const onScroll = (e: any) => {
  scrollTop.value = e.target.scrollTop;
  emit("scroll", scrollTop.value);
};

对外暴露根容器

ts 复制代码
defineExpose({
  $el: viewportRef,
});

使用虚表的父组件可以通过 ref 绑定虚表的根元素。

假设父组件通过 ref="parent" 绑定虚表根元素,通过父组件控制虚表滚动的方法为

ts 复制代码
parent.value.$el.scrollTop = 100;

父组件使用

html 复制代码
<virtual-table
  ref="parent"
  :items="data"
  :space="8"
  :itemHeight="150"
  @scroll="(value: number) => (scrollTop = value)"
>
  <template #item="{ item, index }">
    <div>序号:{{ index }}</div>
    <div>内容:{{ item }}</div>
  </template>
</virtual-table>

附源码

vue3 复制代码
<script setup lang="ts">
import {
  ref,
  computed,
  onMounted,
  onBeforeUnmount,
  watch,
  nextTick,
} from "vue";

const props = defineProps({
  // 数据源 (必须)
  items: {
    type: Array,
    required: true,
  },
  // 行高 (必须,单位px)
  itemHeight: {
    type: Number,
    required: true,
  },
  // 容器宽度 (可选,未指定则撑满父元素)
  width: {
    type: [String, Number],
    default: "100%",
  },
  // 容器高度 (可选,未指定则撑满父元素)
  height: {
    type: [String, Number],
    default: "100%",
  },
  // item 间距 (可选,默认8px)
  space: {
    type: Number,
    default: 8,
  },
  // 上下缓冲区行数,避免快速滚动白屏 (默认5)
  bufferSize: {
    type: Number,
    default: 5,
  },
});
defineSlots<{
  item(props: { item: any; index: number }): void;
}>();

// 引用
const viewportRef = ref<any>(null);
// 实际容器高度 (动态计算)
const viewportHeight = ref(0);
// 滚动位置
const scrollTop = ref(0);
// 滚动事件
const emit = defineEmits<{
  (e: "scroll", scrollTop: number): void;
}>();
const onScroll = (e: any) => {
  scrollTop.value = e.target.scrollTop;
  emit("scroll", scrollTop.value);
};

// 容器样式
const containerStyle = computed(() => {
  const style: any = {};
  style.width =
    typeof props.width === "number" ? `${props.width}px` : props.width;
  style.height =
    typeof props.height === "number" ? `${props.height}px` : props.height;
  return style;
});
// 更新容器高度
const updateViewportHeight = () => {
  if (viewportRef.value) {
    const height = viewportRef.value.clientHeight;
    if (viewportHeight.value !== height) {
      viewportHeight.value = height;
    }
  }
};
// 使用ResizeObserver监听视口尺寸变化
let resizeObserver: any = null;
onMounted(() => {
  // 初始化容器高度
  updateViewportHeight();
  // 监听容器大小变化
  resizeObserver = new ResizeObserver(() => {
    updateViewportHeight();
  });
  if (viewportRef.value) {
    resizeObserver.observe(viewportRef.value);
  }
});
onBeforeUnmount(() => {
  if (resizeObserver) {
    resizeObserver.disconnect();
    resizeObserver = null;
  }
});
// 监听数据变化,确保滚动位置有效
watch(
  () => props.items,
  () => {
    // 如果数据变化后总高度变小,且当前滚动位置超出范围,修正滚动
    nextTick(() => {
      if (viewportRef.value) {
        const maxScroll = Math.max(
          0,
          totalHeight.value - viewportRef.value.clientHeight,
        );
        if (viewportRef.value.scrollTop > maxScroll) {
          viewportRef.value.scrollTop = maxScroll;
        }
      }
    });
  },
);

// 每项实际高度
const itemActualHeight = computed(() => {
  return props.itemHeight + props.space;
});

// 计算总数据量
const totalItems = computed(() => props.items.length);

// 总高度
const totalHeight = computed(() => totalItems.value * itemActualHeight.value);

// 计算起始索引 (基于scrollTop)
const startIndex = computed(() => {
  const rawStart = Math.floor(scrollTop.value / itemActualHeight.value);
  return Math.max(0, rawStart - props.bufferSize);
});

// 计算结束索引
const endIndex = computed(() => {
  const rawEnd = Math.ceil(
    (scrollTop.value + viewportHeight.value) / itemActualHeight.value,
  );
  return Math.min(totalItems.value - 1, rawEnd + props.bufferSize);
});

// 实际需要渲染的行数据
const visibleRows = computed(() => {
  const start = startIndex.value;
  const end = endIndex.value;
  return props.items
    .map((item, index) => ({
      index: start + index,
      data: item,
    }))
    .slice(start, end + 1);
});
// 偏移量
const offsetY = computed(() => startIndex.value * itemActualHeight.value);

defineExpose({
  $el: viewportRef,
});
</script>

<template>
  <!-- 虚拟滚动视口 -->
  <div
    ref="viewportRef"
    class="virtual-viewport"
    :style="containerStyle"
    @scroll="onScroll"
  >
    <!-- 占位块,撑开滚动空间 -->
    <div class="virtual-phantom" :style="{ height: totalHeight + 'px' }" />

    <!-- 渲染内容区,绝对定位跟随滚动 -->
    <div
      class="virtual-content"
      :style="{ transform: `translateY(${offsetY}px)` }"
    >
      <div
        v-for="row in visibleRows"
        :key="row.index"
        :style="{ height: itemActualHeight + 'px' }"
      >
        <!-- 通过插槽让外部自定义每一项的渲染内容 -->
        <slot
          name="item"
          :item="row.data"
          :index="row.index"
          :style="{ height: itemHeight + 'px' }"
        >
          <!-- 默认渲染,当外部没有提供自定义插槽时使用 -->
          <div>
            <span>#{{ row.index + 1 }}</span>
            <span>{{ row.data }}</span>
          </div>
        </slot>
      </div>
    </div>
  </div>
</template>

<style scoped>
.virtual-viewport {
  position: relative;
  display: flex;
  flex-direction: column;
  overflow-y: auto;
}

.virtual-phantom {
  width: 100%;
  pointer-events: none;
}

.virtual-content {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  will-change: transform;
}
</style>
相关推荐
前端fun2 小时前
React如何远程加载组件
前端·react.js
代码煮茶2 小时前
Vue3 路由实战 | Vue Router 从 0 到 1 搭建权限管理系统
前端·javascript·vue.js
gaozhiyong08132 小时前
深度技术拆解:豆包2 Pro vs Gemini 3—国产工程派与海外原生派的巅峰对决
前端·spring boot·mysql
JosieBook3 小时前
【C#】C# 访问修饰符与类修饰符总结大全
前端·javascript·c#
遨游建站3 小时前
谷歌SEO之网站内部优化策略
前端·搜索引擎
华洛3 小时前
聊聊我逃离前端开发前的思考
前端·javascript·vue.js
小码哥_常3 小时前
解锁Android权限申请新姿势:与前置说明弹窗共舞
前端
紫_龙3 小时前
最新版vue3+TypeScript开发入门到实战教程之路由详解三
前端·javascript·typescript
-SOLO-3 小时前
使用Cursor操控正在打开的Chrome
前端·chrome