阅读视图

发现新文章,点击刷新页面。

几种虚拟列表技术方案调研

背景

什么是虚拟列表?

虚拟列表(Virtual List)是一种优化长列表渲染性能的技术。其核心思想是只渲染可见区域的列表项,而不是渲染所有数据。当用户滚动时,动态地创建和销毁 DOM 元素,从而大幅减少 DOM 节点数量,提升渲染性能。

本篇文章中,笔者从Vue3的技术框架来讲解虚拟列表不同场景中的用法,不涉及虚拟列表的原理。社区里有很多讲解虚拟列表的原理的文章,笔者不再赘述。笔者从虚拟列表的分类上统筹每一种虚拟列表技术方案,并给出参考示例。

为什么使用虚拟列表?

传统列表的性能瓶颈:

// 渲染 10000 条数据
const items = Array.from({ length: 10000 }, (_, i) => i)

// ❌ 传统方式:创建 10000 个 DOM 节点
<div v-for="item in items" :key="item">{{ item }}</div>
// 结果:首次渲染缓慢、滚动卡顿、内存占用高

虚拟列表的优势:

// ✅ 虚拟列表:只渲染可见的 ~10-20 个节点
// 即使有 10000 条数据,DOM 中只有可见区域的节点
// 结果:快速渲染、流畅滚动、低内存占用

@vueuse/core 简介

@vueuse/core 是一个强大的 Vue 3 组合式 API 工具集,其中 useVirtualList 提供了开箱即用的虚拟列表解决方案。后续所有的示例代码均使用 useVirtualList 实现

快速开始

npm install @vueuse/core
# 或
pnpm add @vueuse/core

不得不说,Vue的技术生态中,很多第三方依赖开箱即用,这一点非常棒!

useVirtualList API 详解

基本用法

import { useVirtualList } from '@vueuse/core'

const { list, containerProps, wrapperProps } = useVirtualList(
  items,    // 数据源
  options   // 配置选项
)

参数说明

items (必需)

类型: MaybeRef<T[]>
说明: 要渲染的数据数组,可以是响应式引用或普通数组

// 方式 1: 响应式引用
const items = ref([1, 2, 3, ...])

// 方式 2: 普通数组
const items = [1, 2, 3, ...]

// 方式 3: computed
const items = computed(() => originalData.filter(...))

options (必需)

类型: UseVirtualListOptions

选项 类型 默认值 说明
itemHeight number | (index: number) => number 必需 项目高度,可以是固定值或计算函数
overscan number 5 预渲染的额外项目数量,提升滚动流畅度

返回值说明

list

类型: ComputedRef<ListItem<T>[]>
说明: 当前应该渲染的列表项数组

interface ListItem<T> {
  index: number    // 在原数组中的索引
  data: T         // 原始数据
}

使用示例:

<div v-for="item in list" :key="item.index">
  <div>索引: {{ item.index }}</div>
  <div>数据: {{ item.data }}</div>
</div>

containerProps (非常重要)

类型: Object
说明: 需要绑定到容器元素的属性对象

包含属性:

  • ref: 容器的引用
  • onScroll: 滚动事件处理函数
  • style: 容器样式(可能包含 overflow 等)

使用方式:

<div v-bind="containerProps" style="height: 400px; overflow-y: auto;">
  <!-- 内容 -->
</div>

wrapperProps (非常重要)

类型: Object
说明: 需要绑定到包装器元素的属性对象

包含属性:

  • style: 包装器样式,包含计算的总高度

作用:

  • 设置内部容器的总高度(基于所有项目的总高度)
  • 创建正确的滚动区域
  • 支持虚拟化定位

使用方式:

<div v-bind="containerProps">
  <div v-bind="wrapperProps">
    <!-- 列表项 -->
  </div>
</div>

工作原理

image.png

关键点:

  1. 容器(Container): 固定高度,overflow 可滚动
  2. 包装器(Wrapper): 高度 = 所有项目的总高度
  3. 列表项: 只渲染可见区域的项目 (useVirtualList返回的 listitems)

四种虚拟列表实现方案

经过笔者的调研,主要有四种虚拟列表技术方案

固定项高度 + 固定容器高度

📌 方案特点

  • 项目高度: 固定(如 50px)
  • 容器高度: 固定(如 400px)
  • 适用场景: 标准列表、表格、聊天记录等统一高度的场景

🎯 适用场景

场景 说明
数据列表 表格数据、商品列表、用户列表
日志查看器 系统日志、操作记录
聊天记录 简单文本消息列表
文件浏览器 文件/文件夹列表

💻 完整实现代码

组件代码: FixedHeightVirtualList.vue

<script setup>
import { ref } from "vue";
import { useVirtualList } from "@vueuse/core";

const props = defineProps({
  items: {
    type: Array,
    default: () => [],
    required: true,
  },
  itemHeight: {
    type: Number,
    default: 50,
  },
  containerHeight: {
    type: Number,
    default: 400,
  },
});

const container = ref(null);

// 🔑 关键配置:itemHeight 为固定数值
const { list, containerProps, wrapperProps } = useVirtualList(props.items, {
  itemHeight: props.itemHeight,  // 固定高度
  overscan: 5,
});
</script>

<template>
  <div
    ref="container"
    class="virtual-list-container"
    :style="{ height: `${containerHeight}px` }"  <!-- 固定容器高度 -->
    v-bind="containerProps"
  >
    <div class="virtual-list-wrapper" v-bind="wrapperProps">
      <div
        v-for="item in list"
        :key="item.index"
        class="virtual-list-item"
        :style="{ height: `${itemHeight}px` }"  <!-- 固定项目高度 -->
      >
        <div class="item-content">
          <span class="item-index">{{ item.data.id }}</span>
          <span class="item-text">{{ item.data.name }}</span>
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.virtual-list-container {
  overflow: hidden;  /* 🔑 关键:overflow hidden */
  border: 1px solid #e8e8e8;
  border-radius: 8px;
}

.virtual-list-item {
  display: flex;
  align-items: center;
  border-bottom: 1px solid #f0f0f0;
}
</style>

📝 使用示例

<script setup>
import { ref } from 'vue'
import FixedHeightVirtualList from './components/FixedHeightVirtualList.vue'

const items = ref(
  Array.from({ length: 1000 }, (_, index) => ({
    id: index + 1,
    name: `项目 ${index + 1}`
  }))
)
</script>

<template>
  <FixedHeightVirtualList
    :items="items"
    :item-height="50"
    :container-height="400"
  />
</template>

✅ 优点

  • 实现简单,性能最优
  • 滚动最流畅
  • 计算开销最小

❌ 缺点

  • 所有项目必须高度一致
  • 容器高度固定,不够灵活

固定项高度 + 动态容器高度

🌊 方案特点

  • 项目高度: 固定(如 50px)
  • 容器高度: 动态自适应(max-height)
  • 适用场景: 响应式布局、下拉菜单、弹窗列表

🎯 适用场景

场景 说明
下拉选择器 数据量不确定的下拉列表
搜索建议 搜索结果列表(数量动态变化)
弹窗列表 Modal 中的列表内容
侧边栏菜单 响应式侧边导航
响应式布局 需要适应不同屏幕尺寸

💻 完整实现代码

组件代码: DynamicHeightVirtualList.vue

<script setup>
import { ref } from "vue";
import { useVirtualList } from "@vueuse/core";

const props = defineProps({
  items: {
    type: Array,
    default: () => [],
    required: true,
  },
  itemHeight: {
    type: Number,
    default: 50,
  },
  maxHeight: {
    type: Number,
    default: 500,
  },
});

const container = ref(null);

// 🔑 关键配置:itemHeight 为固定数值
const { list, containerProps, wrapperProps } = useVirtualList(props.items, {
  itemHeight: props.itemHeight,
  overscan: 5,
});
</script>

<template>
  <div
    ref="container"
    class="virtual-list-container"
    :style="{ maxHeight: `${maxHeight}px` }"  <!-- 🔑 max-height 实现动态 -->
    v-bind="containerProps"
  >
    <div class="virtual-list-wrapper" v-bind="wrapperProps">
      <div
        v-for="item in list"
        :key="item.index"
        class="virtual-list-item"
        :style="{ height: `${itemHeight}px` }"
      >
        <div class="item-content">
          <span class="item-index">{{ item.data.id }}</span>
          <span class="item-text">{{ item.data.name }}</span>
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.virtual-list-container {
  overflow-y: auto;  /* 🔑 关键:overflow-y auto */
  border: 1px solid #e8e8e8;
  border-radius: 8px;
}
</style>

📝 使用示例

<script setup>
import { ref } from 'vue'
import DynamicHeightVirtualList from './components/DynamicHeightVirtualList.vue'

// 数据量少
const fewItems = ref(
  Array.from({ length: 4 }, (_, index) => ({
    id: index + 1,
    name: `项目 ${index + 1}`
  }))
)

// 数据量多
const manyItems = ref(
  Array.from({ length: 1000 }, (_, index) => ({
    id: index + 1,
    name: `项目 ${index + 1}`
  }))
)
</script>

<template>
  <!-- 数据少时,容器高度 = 4 * 50 = 200px -->
  <DynamicHeightVirtualList
    :items="fewItems"
    :item-height="50"
    :max-height="400"
  />

  <!-- 数据多时,容器高度 = max-height = 400px,出现滚动条 -->
  <DynamicHeightVirtualList
    :items="manyItems"
    :item-height="50"
    :max-height="400"
  />
</template>

🔄 关键差异对比

属性 固定容器 动态容器
容器样式 height: 400px max-height: 400px
overflow overflow: hidden overflow-y: auto
数据少时 仍占据 400px 自动缩小
数据多时 显示滚动条 显示滚动条

✅ 优点

  • 容器高度自适应,更灵活
  • 数据少时不浪费空间
  • 性能依然优秀

❌ 缺点

  • 需要正确处理 overflow-y
  • 布局可能因高度变化而跳动

动态项高度 + 固定容器高度

🎨 方案特点

  • 项目高度: 动态计算(通过函数)
  • 容器高度: 固定(如 450px)
  • 适用场景: 多样化内容、卡片列表、复杂布局

🎯 适用场景

场景 说明
新闻列表 标题长度不一,内容预览不同
卡片流 不同类型的卡片高度不同
评论列表 评论内容长度不一
商品展示 不同商品信息复杂度不同
邮件列表 带附件、标签等额外信息

💻 完整实现代码

组件代码: VariableHeightVirtualList.vue

<script setup>
import { ref } from "vue";
import { useVirtualList } from "@vueuse/core";

const props = defineProps({
  items: {
    type: Array,
    default: () => [],
    required: true,
  },
  containerHeight: {
    type: Number,
    default: 400,
  },
});

const container = ref(null);

// 🔑 关键:动态计算每个项的高度
const getItemHeight = (index) => {
  // 方式 1: 根据索引模式
  const heightPattern = index % 3;
  if (heightPattern === 0) return 60;  // 小项
  if (heightPattern === 1) return 80;  // 中项
  return 100;                          // 大项
  
  // 方式 2: 根据数据内容
  // const item = props.items[index];
  // return item.type === 'large' ? 100 : 50;
  
  // 方式 3: 根据文本长度
  // const textLength = props.items[index].text.length;
  // return Math.max(50, Math.ceil(textLength / 20) * 30);
};

// 🔑 关键配置:itemHeight 为函数
const { list, containerProps, wrapperProps } = useVirtualList(props.items, {
  itemHeight: getItemHeight,  // 函数,不是数值!
  overscan: 5,
});
</script>

<template>
  <div
    ref="container"
    class="virtual-list-container"
    :style="{ height: `${containerHeight}px` }"
    v-bind="containerProps"
  >
    <div class="virtual-list-wrapper" v-bind="wrapperProps">
      <div
        v-for="item in list"
        :key="item.index"
        class="virtual-list-item"
        :style="{ height: `${getItemHeight(item.index)}px` }"  <!-- 动态高度 -->
        :data-height="getItemHeight(item.index)"
      >
        <div class="item-content">
          <span class="item-index">{{ item.data.id }}</span>
          <div class="item-info">
            <span class="item-text">{{ item.data.name }}</span>
            <span class="item-height-tag">高度: {{ getItemHeight(item.index) }}px</span>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.virtual-list-container {
  overflow: hidden;
}

/* 根据高度添加不同的视觉样式 */
.virtual-list-item[data-height="60"] {
  background: linear-gradient(135deg, #fff9e6 0%, #ffffff 100%);
}

.virtual-list-item[data-height="80"] {
  background: linear-gradient(135deg, #e6f7ff 0%, #ffffff 100%);
}

.virtual-list-item[data-height="100"] {
  background: linear-gradient(135deg, #f0f9ff 0%, #ffffff 100%);
}
</style>

📝 使用示例

<script setup>
import { ref } from 'vue'
import VariableHeightVirtualList from './components/VariableHeightVirtualList.vue'

const items = ref(
  Array.from({ length: 400 }, (_, index) => ({
    id: index + 1,
    name: `项目 ${index + 1}`
  }))
)
</script>

<template>
  <VariableHeightVirtualList
    :items="items"
    :container-height="450"
  />
</template>

🎨 高度计算函数示例

// 示例 1: 基于索引的规律模式
const getItemHeight = (index) => {
  return 50 + (index % 3) * 25;  // 50, 75, 100 循环
};

// 示例 2: 基于数据类型
const getItemHeight = (index) => {
  const item = props.items[index];
  switch(item.type) {
    case 'header': return 80;
    case 'content': return 120;
    case 'footer': return 60;
    default: return 50;
  }
};

// 示例 3: 基于内容长度
const getItemHeight = (index) => {
  const item = props.items[index];
  const textLength = item.description?.length || 0;
  const lines = Math.ceil(textLength / 40);  // 假设每行 40 字符
  return 50 + (lines - 1) * 20;  // 基础高度 + 额外行高度
};

// 示例 4: 基于属性组合
const getItemHeight = (index) => {
  const item = props.items[index];
  let height = 60;  // 基础高度
  if (item.hasImage) height += 200;
  if (item.hasTags) height += 30;
  if (item.hasActions) height += 40;
  return height;
};

⚠️ 重要注意事项

  1. 高度必须可预测: getItemHeight(index) 对同一个 index 必须始终返回相同的值
  2. 性能考虑: 函数会被频繁调用,避免复杂计算
  3. 实际高度匹配: CSS 实际高度必须与计算高度一致
// ❌ 错误:随机高度(不可预测)
const getItemHeight = (index) => {
  return 50 + Math.random() * 50;  // 每次调用结果不同!
};

// ❌ 错误:异步计算
const getItemHeight = async (index) => {
  const data = await fetchData(index);  // 不支持异步!
  return data.height;
};

// ✅ 正确:可预测、同步
const getItemHeight = (index) => {
  return props.items[index].preCalculatedHeight;  // 提前计算好的高度
};

✅ 优点

  • 支持不同高度的项目
  • 容器高度固定,布局稳定
  • 视觉效果更丰富

❌ 缺点

  • 高度必须提前可计算
  • 不支持真正的动态内容(如图片加载后才知道高度)
  • 计算开销略高于固定高度

动态项高度 + 动态容器高度

🌈 方案特点

  • 项目高度: 动态计算(通过函数)
  • 容器高度: 动态自适应(max-height)
  • 适用场景: 最灵活的场景,结合前三种优势

🎯 适用场景

场景 说明
复杂弹窗 弹窗内的复杂列表,高度不确定
搜索结果 不同类型的搜索结果混合
通知中心 不同类型通知,数量动态变化
购物车 不同商品信息,数量动态
活动页面 不同类型的活动卡片

💻 完整实现代码

组件代码: FullyDynamicVirtualList.vue

<script setup>
import { ref } from "vue";
import { useVirtualList } from "@vueuse/core";

const props = defineProps({
  items: {
    type: Array,
    default: () => [],
    required: true,
  },
  maxHeight: {
    type: Number,
    default: 500,
  },
});

const container = ref(null);

// 🔑 关键:动态计算每个项的高度
const getItemHeight = (index) => {
  const heightPattern = index % 3;
  if (heightPattern === 0) return 60;
  if (heightPattern === 1) return 80;
  return 100;
};

// 🔑 关键配置:itemHeight 为函数 + 容器使用 max-height
const { list, containerProps, wrapperProps } = useVirtualList(props.items, {
  itemHeight: getItemHeight,
  overscan: 5,
});
</script>

<template>
  <div
    ref="container"
    class="virtual-list-container"
    :style="{ maxHeight: `${maxHeight}px` }"  <!-- 🔑 max-height 动态容器 -->
    v-bind="containerProps"
  >
    <div class="virtual-list-wrapper" v-bind="wrapperProps">
      <div
        v-for="item in list"
        :key="item.index"
        class="virtual-list-item"
        :style="{ height: `${getItemHeight(item.index)}px` }"
        :data-height="getItemHeight(item.index)"
      >
        <div class="item-content">
          <span class="item-index">{{ item.data.id }}</span>
          <div class="item-info">
            <span class="item-text">{{ item.data.name }}</span>
            <span class="item-height-tag">高度: {{ getItemHeight(item.index) }}px</span>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.virtual-list-container {
  overflow-y: auto;  /* 🔑 关键:overflow-y auto */
  border: 1px solid #e8e8e8;
  border-radius: 8px;
}
</style>

📝 使用示例

<script setup>
import { ref } from 'vue'
import FullyDynamicVirtualList from './components/FullyDynamicVirtualList.vue'

const items = ref(
  Array.from({ length: 200 }, (_, index) => ({
    id: index + 1,
    name: `项目 ${index + 1}`
  }))
)
</script>

<template>
  <FullyDynamicVirtualList
    :items="items"
    :max-height="600"
  />
</template>

🎯 适配不同数据量

<template>
  <!-- 数据少:容器自动缩小 -->
  <FullyDynamicVirtualList
    :items="[1, 2, 3]"
    :max-height="400"
  />
  <!-- 实际高度约: 60 + 80 + 100 = 240px -->

  <!-- 数据多:容器达到最大高度,显示滚动条 -->
  <FullyDynamicVirtualList
    :items="Array(200)"
    :max-height="400"
  />
  <!-- 实际高度: 400px (max-height) -->
</template>

✅ 优点

  • 最大的灵活性
  • 项目高度可变
  • 容器高度自适应
  • 适应各种复杂场景

❌ 缺点

  • 实现相对复杂
  • 需要同时处理两种动态性
  • 性能略低于前三种方案

性能对比与最佳实践

性能对比

方案 性能 灵活性 实现难度 适用场景
固定+固定 ⭐⭐⭐⭐⭐ ⭐⭐ 标准列表
固定+动态 ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐ 响应式布局
动态+固定 ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ 多样化内容
动态+动态 ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ 复杂场景

渲染效率对比

测试条件: 10,000 条数据

方案 DOM 节点数 首次渲染时间 滚动 FPS 内存占用
传统列表 10,000 ~2000ms <30 FPS ~50MB
虚拟列表(固定+固定) ~15 ~50ms 60 FPS ~5MB
虚拟列表(固定+动态) ~15 ~50ms 60 FPS ~5MB
虚拟列表(动态+固定) ~15 ~60ms 55-60 FPS ~5MB
虚拟列表(动态+动态) ~15 ~60ms 55-60 FPS ~5MB

方案选择决策树

开始
  │
  ├─ 所有项目高度是否相同?
  │   ├─ 是 ──> 容器高度是否固定?
  │   │         ├─ 是 ──> 【方案1: 固定+固定】最优性能 ⭐⭐⭐⭐⭐
  │   │         └─ 否 ──> 【方案2: 固定+动态】响应式布局 ⭐⭐⭐⭐⭐
  │   │
  │   └─ 否 ──> 容器高度是否固定?
  │             ├─ 是 ──> 【方案3: 动态+固定】多样化内容 ⭐⭐⭐⭐
  │             └─ 否 ──> 【方案4: 动态+动态】最大灵活性 ⭐⭐⭐⭐

最佳实践

overscan 参数调优

// 基础配置
overscan: 5  // 默认值,适合大多数场景

// 快速滚动场景
overscan: 10  // 增加预渲染,防止白屏

// 性能敏感场景
overscan: 3  // 减少预渲染,降低开销

// 移动端
overscan: 8  // 移动端滚动更快,建议增加

高度计算缓存

// ❌ 不推荐:每次都计算
const getItemHeight = (index) => {
  const item = props.items[index];
  return calculateComplexHeight(item);  // 复杂计算
};

// ✅ 推荐:缓存计算结果
const heightCache = new Map();

const getItemHeight = (index) => {
  if (heightCache.has(index)) {
    return heightCache.get(index);
  }
  
  const item = props.items[index];
  const height = calculateComplexHeight(item);
  heightCache.set(index, height);
  return height;
};

// 数据变化时清除缓存
watch(() => props.items, () => {
  heightCache.clear();
}, { deep: true });

响应式优化

// ❌ 不推荐:直接传入响应式对象
const items = reactive([...]);

// ✅ 推荐:使用 ref
const items = ref([...]);

// ✅ 更好:使用 shallowRef(大数据量)
const items = shallowRef([...]);

大数据量优化

// 数据量 > 50,000 时的优化策略

// 1. 使用 shallowRef
const items = shallowRef(largeDataArray);

// 2. 增加 overscan
overscan: 15

// 3. 防抖滚动事件(如果需要自定义处理)
const handleScroll = useDebounceFn(() => {
  // 处理滚动
}, 16);  // 约 60fps

// 4. 虚拟滚动条(可选)
// 使用自定义滚动条替代原生滚动条

避免常见错误

// ❌ 错误 1:在模板中直接访问原数组
<template>
  <div v-for="item in items">  <!-- 错误!应该用 list -->
    {{ item }}
  </div>
</template>

// ✅ 正确:使用 list
<template>
  <div v-for="item in list">  <!-- 正确!-->
    {{ item.data }}
  </div>
</template>

// ❌ 错误 2:忘记绑定 props
<div class="container">  <!-- 错误!缺少 v-bind -->
  <div class="wrapper">  <!-- 错误!缺少 v-bind -->

// ✅ 正确:绑定 containerProps 和 wrapperProps
<div v-bind="containerProps">
  <div v-bind="wrapperProps">

// ❌ 错误 3:高度计算不准确
.item {
  height: 50px;
  padding: 10px;  /* 实际高度 = 50 + 20 = 70px */
}

// ✅ 正确:确保计算高度包含 padding/border
itemHeight: 70  // 或使用 box-sizing: border-box

常见问题与解决方案

滚动位置跳动

问题: 滚动时列表位置跳动

原因: 计算高度与实际高度不匹配

解决方案:

// 1. 使用 box-sizing: border-box
.virtual-list-item {
  box-sizing: border-box;
  height: 50px;  /* 包含 padding 和 border */
  padding: 10px;
}

// 2. 确保高度计算准确
const getItemHeight = (index) => {
  const baseHeight = 50;
  const padding = 20;  // top + bottom
  const border = 2;    // top + bottom
  return baseHeight + padding + border;
};

首次渲染白屏

问题: 首次加载时短暂白屏

原因: overscan 太小或数据加载慢

解决方案:

// 1. 增加 overscan
overscan: 10

// 2. 添加骨架屏
<template>
  <div v-if="loading">
    <SkeletonItem v-for="i in 10" :key="i" />
  </div>
  <div v-else v-bind="containerProps">
    <!-- 虚拟列表 -->
  </div>
</template>

// 3. 预加载数据
onMounted(async () => {
  loading.value = true;
  items.value = await fetchData();
  loading.value = false;
});

滚动到指定位置

问题: 如何滚动到指定索引的项目

解决方案:

// 方法 1: 计算滚动位置(固定高度)
const scrollToIndex = (index) => {
  const scrollTop = index * itemHeight;
  container.value.scrollTop = scrollTop;
};

// 方法 2: 计算滚动位置(动态高度)
const scrollToIndex = (index) => {
  let scrollTop = 0;
  for (let i = 0; i < index; i++) {
    scrollTop += getItemHeight(i);
  }
  container.value.scrollTop = scrollTop;
};

// 方法 3: 使用 scrollIntoView
const scrollToIndex = (index) => {
  // 需要在 list 中找到对应的元素
  const element = document.querySelector(`[data-index="${index}"]`);
  element?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};

数据更新后滚动位置重置

问题: 数据更新后滚动位置跳回顶部

原因: items 引用变化导致重新计算

解决方案:

// ❌ 错误:直接赋值新数组
items.value = newData;  // 引用变化,滚动位置重置

// ✅ 正确:保持引用,修改内容
// 方法 1: 使用 splice
items.value.splice(0, items.value.length, ...newData);

// 方法 2: 逐项更新
newData.forEach((item, index) => {
  items.value[index] = item;
});

// 方法 3: 记录并恢复滚动位置
const scrollTop = container.value.scrollTop;
items.value = newData;
nextTick(() => {
  container.value.scrollTop = scrollTop;
});

在 TypeScript 中使用

import { ref, Ref } from 'vue'
import { useVirtualList, UseVirtualListOptions } from '@vueuse/core'

interface ListItem {
  id: number
  name: string
  description?: string
}

const items: Ref<ListItem[]> = ref([
  { id: 1, name: 'Item 1' },
  { id: 2, name: 'Item 2' }
])

const options: UseVirtualListOptions = {
  itemHeight: 50,
  overscan: 5
}

const { list, containerProps, wrapperProps } = useVirtualList(items, options)

// list 的类型是 ComputedRef<{ index: number; data: ListItem }[]>

服务端渲染 (SSR) 支持

// 在 SSR 环境中,需要注意以下几点:

// 1. 容器高度必须明确
<div :style="{ height: '400px' }">  <!-- SSR 需要明确高度 -->

// 2. 避免在服务端计算滚动
import { onMounted } from 'vue'

const setupVirtualList = () => {
  if (typeof window === 'undefined') return  // SSR 环境跳过
  
  const { list, containerProps, wrapperProps } = useVirtualList(...)
  return { list, containerProps, wrapperProps }
}

// 3. 使用 ClientOnly 组件(Nuxt.js)
<ClientOnly>
  <VirtualList :items="items" />
</ClientOnly>

总结与对比表

四种方案完整对比

特性 固定+固定 固定+动态 动态+固定 动态+动态
项目高度 固定值 固定值 函数计算 函数计算
容器高度 height max-height height max-height
overflow hidden auto hidden auto
itemHeight 50 50 (i) => ... (i) => ...
性能评分 5/5 5/5 4/5 4/5
灵活性评分 2/5 3/5 4/5 5/5
实现难度 简单 简单 中等 中等
典型应用 表格 下拉菜单 卡片列表 复杂弹窗
数据量建议 无限制 无限制 < 100,000 < 100,000

API 参数速查表

// useVirtualList 配置
const { list, containerProps, wrapperProps } = useVirtualList(items, {
  itemHeight: 50 | ((index: number) => number),  // 必需
  overscan: 5                                     // 可选,默认 5
})

关键代码片段速查

固定高度配置

itemHeight: 50

动态高度配置

itemHeight: (index) => {
  return index % 2 === 0 ? 60 : 80
}

固定容器

<div :style="{ height: '400px' }" v-bind="containerProps">

动态容器

<div :style="{ maxHeight: '400px' }" v-bind="containerProps">

完整模板结构

<div v-bind="containerProps" :style="{ height: '400px' }">
  <div v-bind="wrapperProps">
    <div v-for="item in list" :key="item.index">
      {{ item.data }}
    </div>
  </div>
</div>

参考资源

完整项目结构

vue3-sample/
├── src/
│   ├── components/
│   │   ├── FixedHeightVirtualList.vue       # 固定+固定
│   │   ├── DynamicHeightVirtualList.vue     # 固定+动态
│   │   ├── VariableHeightVirtualList.vue    # 动态+固定
│   │   └── FullyDynamicVirtualList.vue      # 动态+动态
│   ├── App.vue                               # 使用示例
│   └── main.js
├── docs/
│   └── virtual-list-guide.md                 # 本文档
├── package.json
└── README.md

快速开始

# 1. 克隆项目
git clone https://github.com/ilcherry/virtuallist-examples

# 2. 安装依赖
cd vue3-sample
pnpm install

# 3. 运行开发服务器
pnpm dev

# 4. 访问
# 打开浏览器访问 http://localhost:5173

localhost_5173_.png

一份实用的Vue3技术栈代码评审指南

CSS

优先使用 **scoped** 

防止样式污染全局,每个组件样式必须局部化

错误示例:无作用域

<style>
.button {
  color: red;
}
</style>

 不加 scoped 会影响全局所有 .button

正确示例:使用 scoped

<style scoped>
.button {
  color: red;
}
</style>

限制嵌套层级 ≤ 3 层

嵌套超过 3 层说明选择器设计有问题,建议拆分样式或使用 BEM。

错误示例:嵌套过深(5 层)

.card {
  .header {
    .title {
      .icon {
        span {
          color: red;
        }
      }
    }
  }
}

正确方式是进行合理拆分

避免使用 !important

!important 会带来样式权重混乱,除非必要不推荐使用 !important

错误示例

.button {
  color: red !important;
}

.alert {
  display: none !important;
}

正确示例:提升选择器权重

/* 通过增加父级选择器权重覆盖 */
.container .button {
  color: red;
}

合理使用 v-deep 

在 Vue3 中,如果要覆盖子组件或第三方库的内部样式,必须使用 ::v-deep。禁止使用老版的 /deep/ 或 >>>,因为它们已废弃。同时要避免滥用 ::v-deep,只在必要时使用,并保持选择器短小。

 错误示例

<style scoped>
.child-component .btn {
  color: red;
}
</style>

正确示例

<style scoped>
::v-deep(.btn) {
  color: red;
}
</style>

优先使用 UnoCSS

因为项目中引入 UnoCSS,首选使用 UnoCSS。

错误示例

使用了传统的 CSS 类名来定义样式,而不是利用 UnoCSS 的原子化类。这违背了优先使用 UnoCSS 的原则。

<template>
  <div class="my-button">
    点击我
  </div>
</template>

<style scoped>
.my-button {
  background-color: #007bff;
  color: white;
  padding: 10px 20px;
  border-radius: 5px;
  cursor: pointer;
}
</style>

正确示例

充分利用了 UnoCSS 的原子化类来定义相同的样式

<template>
  <div class="bg-blue-500 text-white p-x-5 p-y-2 rounded-md cursor-pointer">
    点击我
  </div>
</template>

<style scoped>
/* 无需额外的 style 标签,因为样式已通过 UnoCSS 类名定义 */
</style>

JavaScript

变量与方法应采用统一的命名规范

命名应遵循语义清晰、风格一致、可读性高的原则,变量名体现数据类型/用途,方法名体现行为。团队建议统一小驼峰(camelCase) 风格,并避免无意义缩写或混用语言。

错误示例:变量命名不语义化

let a = true;
let b = [];
let c = "http://api.example.com";

正确示例如下

  1. 语义化命名

    let isActive = true;
    let userList: User[ ] = [ ];
    const API_BASE_URL = "http://api.example.com";
    
  2. 方法名包含动词

    function fetchData() { ... }
    function saveUser() { ... }
    function deleteUser() { ... }
    
  3. 布尔值变量以 is/has 开头

const isVisible = false;
const hasError = true;

 > 1. 布尔值遵循语法准确是前提 2 推荐用 has 开头

dev.to/michi/tips-…  参考写布尔值的工具

使用可选链

当访问对象的深层次属性时,如果中间某一级可能为 null 或 undefined,    (?.) 替代传统的逐层判断,代码更简洁且避免运行时异常。

错误示例

if (
  response &&
  response.data &&
  response.data.user &&
  response.data.user.profile
) {
  console.log(response.data.user.profile.name);
}

上面的代码示例中判断条件冗长且可读性差、容易漏掉某一级判断和难以维护

正确示例

const name = response?.data?.user?.profile?.name;
if (name) {
  console.log(name);
}

函数参数超过 3 个应封装成对象

当函数参数 超过 3 个 或存在多个相同类型的参数时,推荐将这些参数封装为一个对象。这样可以提升代码可读性、维护性,并支持命名参数调用,避免顺序错误。

错误示例:多个参数直接传递

function createUser(
  name: string,
  age: number,
  role: string,
  isActive: boolean,
  department: string
) {
  // 创建用户逻辑
}

createUser("Alice", 28, "admin", true, "Engineering");

正确示例

interface CreateUserOptions {
  name: string;
  age: number;
  role: string;
  isActive?: boolean;
  department?: string;
}

function createUser(options: CreateUserOptions) {
  const { name, age, role, isActive = true, department = "General" } = options;
  // 创建用户逻辑
}

createUser({
  name: "Alice",
  age: 28,
  role: "admin",
  department: "Engineering",
});

使用 ResizeObserver 替代 onResize

window.onresize 只能监听浏览器窗口尺寸变化,无法感知单个 DOM 元素尺寸变化。Vue3 项目应使用 ResizeObserver 监听任意 DOM 元素的尺寸变化,支持多元素、精准触发、性能更优。

错误示例

<script setup lang="ts">
const width = ref(0);

onMounted(() => {
  window.onresize = () => {
    const el = document.getElementById("container");
    width.value = el?.offsetWidth || 0;
  };
  window.onresize(); // 初始化
});
</script>

<template>
  <div id="container" style="width: 50%;">宽度:{{ width }}px</div>
</template>

问题

  • 无法感知父容器/内容变化,只能在窗口尺寸变化时触发。

  • 多组件绑定 window.onresize 时,回调容易互相覆盖。

  • 卸载时忘记移除监听,可能导致内存泄漏。

正确示例

<script setup lang="ts">
const elRef = ref<HTMLDivElement>();
const size = ref({ width: 0, height: 0 });

onMounted(() => {
  const observer = new ResizeObserver((entries) => {
    const rect = entries[0].contentRect;
    size.value.width = rect.width;
    size.value.height = rect.height;
  });
  observer.observe(elRef.value!);

  onUnmounted(() => observer.disconnect());
});
</script>

<template>
  <div ref="elRef" style="width: 50%;">
    宽度:{{ size.width }}px,高度:{{ size.height }}px
  </div>
</template>

TypeScript

避免在组件/逻辑中使用 any

在 Vite + TS 项目里,一旦滥用 any,类型检查形同虚设。要尽量用明确类型或 unknown(再做类型收窄)。

错误示例

function parseData(data: any) {
  return JSON.parse(data);
}

const user: any = getUser();
console.log(user.name);

正确示例

function parseData(data: unknown): Record<string, unknown> {
  if (typeof data === "string") {
    return JSON.parse(data);
  }
  throw new Error("Invalid data type");
}

interface User {
  name: string;
  age: number;
}
const user: User = getUser();
console.log(user.name);

目前有两种情况,

  1. stores 中没有写类型 (旧的不补类型,新的 stores 补类型) 新接口,新枚举,新常量

使用 enum 避免硬编码

所有固定集合值(角色、状态、方向等)必须使用 TypeScript 的 enum 定义,禁止使用字符串字面量或硬编码。

错误示例:硬编码字符串

if (user.role === 'admin') { ... }
if (status === 'PENDING') { ... }

正确示例:使用 enum

enum UserRole {
  Admin = 'admin',
  User = 'user',
  Guest = 'guest'
}

enum OrderStatus {
  Pending = 'PENDING',
  Shipped = 'SHIPPED',
  Delivered = 'DELIVERED'
}

if (user.role === UserRole.Admin) { ... }
if (status === OrderStatus.Pending) { ... }

Props、Emits 必须类型化

在 Vue3 的 SFC 中,defineProps 和 defineEmits 必须声明类型。

错误示例

defineProps(['title', 'count'])
defineEmits(['update'])

正确示例

interface Props {
  title: string
  count: number
}

interface Emits {
  (e: 'update', value: number): void
}

const props = defineProps<Props>()
const emit = defineEmits<Emits>()

3.3 以上有另一种方式

泛型必须具备边界约束

使用泛型时必须加上约束,防止过宽的类型导致不安全操作。

错误示例

function getValue<T>(obj: T, key: string) {
  return obj[key]
}

正确示例

function getValue<T extends object, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key]
}

组合式 API 必须有返回值类型

composables API 应该明确返回值类型,方便调用处类型推断。

错误示例

export function useUser() {
  const user = ref<User>()
  return { user }
}

正确示例

export function useUser(): { user: Ref<User> } {
  const user = ref<User>()
  return { user }
}

Vue3

不要在 defineProps() 里混用类型和 runtime 校验

Vue3 允许 defineProps() 使用 runtime 声明和类型声明,但二者混用易出 bug。推荐统一使用 泛型声明类型。

错误示例

<script setup lang="ts">
defineProps({
  title: String,
});
interface Props {
  title: string;
}
</script>

正确示例

<script setup lang="ts">
interface Props {
  title: string;
}
const props = defineProps<Props>();
</script>

类型声明统一放在 types 文件夹或模块中

全局类型或接口建议集中管理,避免散落在组件里难以维护。

错误示例

// 在多个组件里重复定义 interface User { name: string; age: number }

正确示例

src/types/user.d.ts
export interface User { name: string age: number }

在模板中使用类型提示

通过 defineExpose 和 defineEmits 的泛型参数在模板中获得类型提示。

错误示例

<template>
  <button @click="emit('save', 123)">Save</button>
</template>

<script setup lang="ts">
const emit = defineEmits(["save"]);
</script>

正确示例

<script setup lang="ts">
const emit = defineEmits<{
  (e: "save", id: number): void;
}>();
</script>

优先使用 <script setup> 而不是 defineComponent

Vue 3 的 <script setup> 更简洁、性能更好(编译优化),避免不必要的模板变量暴露。

错误示例

<script lang="ts">
import { defineComponent, ref } from "vue";

export default defineComponent({
  setup() {
    const count = ref(0);
    return { count };
  },
});
</script>

正确示例

<script setup lang="ts">
import { ref } from "vue";

const count = ref(0);
</script>

在模板中避免复杂逻辑表达式

模板里只做展示,不要做复杂逻辑,逻辑应移到计算属性或方法。

错误示例

<template>
  <div>
    {{
      users
        .filter((u) => u.age > 18)
        .map((u) => u.name)
        .join(", ")
    }}
  </div>
</template>

正确示例

<script setup lang="ts">
const adultNames = computed(() =>
  users.value
    .filter((u) => u.age > 18)
    .map((u) => u.name)
    .join(", ")
);
</script>

<template>
  <div>{{ adultNames }}</div>
</template>

事件名统一使用 kebab-case

Vue 3 推荐自定义事件名用 kebab-case,避免与 DOM 属性冲突。

错误示例

<ChildComponent @saveData="handleSave" />

正确示例

<ChildComponent @save-data="handleSave" />

组件通信避免滥用 $emit,优先使用 props + v-model

小型数据通信用 props/v-model,大型数据或频繁通信建议使用 Pinia/Composable。

错误示例

<ChildComponent @updateValue="parentValue = $event" />

正确示例

<ChildComponent v-model="parentValue" />

避免复杂嵌套三元运算

三元表达式适合简单条件切换,若逻辑复杂或嵌套,应使用 if-else、computed 或方法代替。 在模板中,复杂三元表达式严重降低可读性,且容易遗漏分支,Review 时应强制重构

错误示例

<template>
  <div>
    {{ status === "loading" ? "加载中" : status === "error" ? "错误" : "完成" }}
  </div>
</template>

正确示例

<script setup lang="ts">
const statusText = computed(() => {
  if (status.value === "loading") return "加载中";
  if (status.value === "error") return "错误";
  return "完成";
});
</script>

<template>
  <div>{{ statusText }}</div>
</template>

定时器必须在卸载时清理

在 Vue 组件中使用 setInterval、setTimeout、requestAnimationFrame 等定时器,必须在组件卸载(onUnmounted)时清理,否则会导致内存泄漏或意外触发逻辑

错误示例

<script setup lang="ts">
onMounted(() => {
  setInterval(() => {
    console.log("轮询接口");
  }, 1000);
});
</script>

正确示例

<script setup lang="ts">
let timer: ReturnType<typeof setInterval>;

onMounted(() => {
  timer = setInterval(() => {
    console.log("轮询接口");
  }, 1000);
});

onUnmounted(() => {
  clearInterval(timer);
});
</script>

IO(API 请求、文件处理等)必须做错误处理

网络请求(fetch/axios)、文件操作等 IO 行为容易失败,必须捕获异常并反馈用户,防止应用无响应或白屏

错误示例

const fetchData = async () => {
  const res = await fetch("/api/data");
  const data = await res.json();
  console.log(data);
};

正确示例

const fetchData = async () => {
  try {
    const res = await fetch("/api/data");
    if (!res.ok) throw new Error("请求失败");
    const data = await res.json();
    console.log(data);
  } catch (err) {
    console.error("数据请求错误:", err);
    alert("网络错误,请稍后重试");
  }
};

避免数据竞态(Race Condition)

当组件内多次发起异步请求或副作用操作(如用户快速切换选项),后发出的请求可能比先发出的请求先返回,导致数据状态错乱。必须通过请求标记、AbortController 或最新响应检查防止。

错误示例 具体场景:用户快速切换 Item 1 → Item 2 → Item 1,可能 Item 1 的旧请求最后返回,把数据覆盖成错误值。

<script setup lang="ts">
const selectedId = ref(1);
const data = ref(null);

watch(selectedId, async (id) => {
  const res = await fetch(`/api/item/${id}`);
  data.value = await res.json();
});
</script>

<template>
  <select v-model="selectedId">
    <option :value="1">Item 1</option>
    <option :value="2">Item 2</option>
  </select>
  <div>{{ data }}</div>
</template>

解决思路

正确示例 1:使用请求标记(Token)

<script setup lang="ts">
const selectedId = ref(1);
const data = ref(null);
let requestToken = 0;

watch(selectedId, async (id) => {
  const token = ++requestToken;
  const res = await fetch(`/api/item/${id}`);
  if (token !== requestToken) return; // 旧请求,丢弃
  data.value = await res.json();
});
</script>

正确示例 2:使用 AbortController

<script setup lang="ts">
const selectedId = ref(1);
const data = ref(null);
let controller: AbortController;

watch(selectedId, async (id) => {
  controller?.abort(); // 中断上一个请求
  controller = new AbortController();

  try {
    const res = await fetch(`/api/item/${id}`, { signal: controller.signal });
    data.value = await res.json();
  } catch (err) {
    if (err.name !== "AbortError") console.error(err);
  }
});
</script>

正确示例 3:封装 Composable,统一竞态处理

// composables/useSafeFetch.ts
export function useSafeFetch() {
  let controller: AbortController;

  return async function safeFetch(url: string) {
    controller?.abort();
    controller = new AbortController();
    const res = await fetch(url, { signal: controller.signal });
    return res.json();
  };
}
<script setup lang="ts">
const { safeFetch } = useSafeFetch();
const data = ref(null);

watch(selectedId, async (id) => {
  data.value = await safeFetch(`/api/item/${id}`);
});
</script>

列表渲染中不推荐使用索引作为 key

Vue 的虚拟 DOM 需要依赖 key 来准确地跟踪节点身份,保证列表渲染的高效与正确。**key** 必须唯一且稳定,通常来自数据的唯一标识字段(如数据库 ID)。避免使用数组索引 **index** 作为 **key**,除非数据列表静态且无增删排序需求

错误示例:使用索引作为 key

<template>
  <ul>
    <li v-for="(item, index) in items" :key="index">
      {{ item.name }}
    </li>
  </ul>
</template>

正确示例:使用稳定唯一标识作为 key

<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      {{ item.name }}
    </li>
  </ul>
</template>

国际化

  1. 代码中的文案一定要做国际化处理 (比如中文正则表达式搜索检查)

  2. 国际化后的文案由 PM 提供,PM 不提供,使用 ChatGPT/Cursor 处理后与 PM 一起校对(拿不准找 Perry )

  3. 标点符号与语言对应,比如英文中不能出现中文括号

  4. 新增的国际化内容设置独立命令空间或者全文检索,避免 key 冲突

  5. 国际化内容的 key 是英文短语,不能是中文 

  6. PR 的 Code Review 中涉及国际化内容必须重点 review

 正确示例

export default {
'Administrator has enabled Multi-Factor Authentication (MFA)': 'El administrador ha habilitado la autenticación de múltiples factores (MFA)',
  'Open your app store': 'Abre tu tienda de aplicaciones',
};

 在组件中这样使用

<li>{{ t('Open your app store') }}</li>

Vue 组件设计

统一组件命名 / 文件命名策略

统一组件名采用 PascalCase(或一致 kebab-case),基础组件保留 Base 前缀,名称应全拼避免缩写,提高可维护性

错误示例

components/
  myComp.vue
  btn.vue

正确示例

components/
  MyComponent.vue
  BaseButton.vue

在组件中这样使用

<BaseButton/>

如果是 element-plus  组件库,可以使用如下的使用方式

<el-button/>

统一文件夹(目录)命名规范

项目中的所有目录名称必须遵循统一的命名风格,确保路径清晰、可预测、跨平台无大小写冲突。

错误示例:目录命名混乱

components/
  UserProfile/
  loginForm/
  Account_details/
  auth/

正确示例:统一 kebab-case

components/
  user-profile/
  login-form/
  account-details/
  auth/

TS 文件名命名

项目中的 TS 文件命名应该是小驼峰格式

错误示例

user-list.ts

 正确示例

userList.ts

组件的状态与 UI 分离

在 Vue3 组件开发中,所有数据处理逻辑(如 API 请求、数据格式化、状态管理等)应从 UI 层(模板 & 样式)中分离,放入 Composable、Store、Utils。模板只负责展示,逻辑放在单独模块便于测试、复用和维护。

错误示例

<script setup lang="ts">
import { ref, onMounted } from "vue";

const users = ref([]);
const loading = ref(false);
const error = ref("");

onMounted(async () => {
  loading.value = true;
  try {
    const res = await fetch("/api/users");
    users.value = await res.json();
  } catch (e) {
    error.value = "加载用户失败";
  } finally {
    loading.value = false;
  }
});

const formatName = (user) => `${user.firstName} ${user.lastName}`;
</script>

<template>
  <div v-if="loading">加载中...</div>
  <div v-else-if="error">{{ error }}</div>
  <ul v-else>
    <li v-for="user in users" :key="user.id">
      {{ formatName(user) }}
    </li>
  </ul>
</template>

上面的示例中存在的问题

  • API 请求逻辑、数据状态和格式化函数都在组件里

  • 组件职责太多:UI + 业务逻辑 + 状态管理

  • 无法复用 fetchUsers 和 formatName

正确示例 - 数据逻辑分离到 Composable

composables/useUsers.ts

import { ref } from "vue";

export function useUsers() {
  const users = ref([]);
  const loading = ref(false);
  const error = ref("");

  const fetchUsers = async () => {
    loading.value = true;
    try {
      const res = await fetch("/api/users");
      users.value = await res.json();
    } catch (e) {
      error.value = "加载用户失败";
    } finally {
      loading.value = false;
    }
  };

  const formatName = (user) => `${user.firstName} ${user.lastName}`;

  return { users, loading, error, fetchUsers, formatName };
}

UserList.vue

<script setup lang="ts">
import { onMounted } from "vue";
import { useUsers } from "@/composables/useUsers";

const { users, loading, error, fetchUsers, formatName } = useUsers();

onMounted(fetchUsers);
</script>

<template>
  <div v-if="loading">加载中...</div>
  <div v-else-if="error">{{ error }}</div>
  <ul v-else>
    <li v-for="user in users" :key="user.id">
      {{ formatName(user) }}
    </li>
  </ul>
</template>

正确的案例中,UI 专注展示,逻辑由 useUsers 管理、useUsers 可被其他组件复用、只需要测试 useUsers 方法就行

UI组件 vs 业务组件

UI组件(Button, Modal, Table):无业务逻辑,仅负责样式和交互

业务组件(UserList, OrderForm):封装具体业务逻辑,复用 UI 组件

错误示例:业务逻辑写在 UI 组件

<!-- Button.vue -->
<script setup>
const handleSaveUser = async () => {
  await api.saveUser()
}
</script>

<template>
  <button @click="handleSaveUser">保存</button>
</template>

正确示例:UI 组件尽量保证纯组件

<!-- Button.vue -->
<template>
  <button><slot /></button>
</template>

<!-- UserForm.vue -->
<Button @click="saveUser">保存</Button>

在写业务组件的时候,利用Composition API 分离逻辑,把 API 调用、数据处理抽离到 composable 中

避免直接操作 DOM

除非必要尽量不要使用 document.querySelector 等直接操作 DOM

错误示例

onMounted(() => {
  const el = document.querySelector('.btn')
  el?.addEventListener('click', () => { ... })
})

正确示例

<template>
  <button @click="handleClick" class="btn">Click</button>
</template>

<script setup lang="ts">
function handleClick() {
  // 处理逻辑
}
</script>

单元测试

1. 单元测试应覆盖核心业务逻辑,避免测试无意义的渲染细节

测试应聚焦于组件的行为和业务逻辑,而非仅仅验证静态的 DOM 结构,避免脆弱且维护成本高的测试。

错误示例

// 测试仅验证 DOM 具体标签和类名,DOM 结构细节变动即破坏测试
test('renders exact button markup', () => {
  const wrapper = mount(MyButton)
  expect(wrapper.html()).toBe('<button class="btn primary">Submit</button>')
})

正确示例

// 测试按钮是否存在且包含正确文本,关注业务效果而非具体标签细节
test('renders submit button', () => {
  const wrapper = mount(MyButton)
  const button = wrapper.find('button')
  expect(button.exists()).toBe(true)
  expect(button.text()).toBe('Submit')
})


2. 使用 Vue Test Utils 的异步渲染工具时,要正确等待 nextTick

Vue3 组件中很多行为是异步更新的,测试中操作后必须调用 await nextTick() 或使用 flushPromises() 等方法,确保断言是在 DOM 更新完成后进行。

错误示例

test('click increments count', () => {
  const wrapper = mount(Counter)
  wrapper.find('button').trigger('click')
  expect(wrapper.text()).toContain('Count: 1') // 断言过早,失败
})

正确示例

import { nextTick } from 'vue'

test('click increments count', async () => {
  const wrapper = mount(Counter)
  await wrapper.find('button').trigger('click')
  await nextTick()
  expect(wrapper.text()).toContain('Count: 1')
})


3. 事件触发测试必须确保事件正确被捕获并处理

测试组件自定义事件或原生事件时,需确保事件被正确监听,并使用 emitted() 方法断言事件触发,避免事件未触发测试通过的假象。

错误示例

test('emits submit event', () => {
  const wrapper = mount(FormComponent)
  wrapper.find('form').trigger('submit')
  expect(wrapper.emitted('submit')).toBeTruthy() // 可能事件未触发,但断言粗略
})

正确示例

test('emits submit event once', async () => {
  const wrapper = mount(FormComponent)
  await wrapper.find('form').trigger('submit.prevent')
  const submitEvents = wrapper.emitted('submit')
  expect(submitEvents).toHaveLength(1)
})


4. 不要在测试中硬编码组件内部状态,尽量从外部输入和输出测试

单元测试应以组件的公开接口(props、事件)为测试点,避免直接访问或修改组件内部私有数据,保持测试的稳健性和解耦。

错误示例

test('increments count internally', () => {
  const wrapper = mount(Counter)
  wrapper.vm.count = 5
  wrapper.vm.increment()
  expect(wrapper.vm.count).toBe(6) // 依赖内部状态
})

正确示例

test('increments count via user interaction', async () => {
  const wrapper = mount(Counter)
  await wrapper.find('button.increment').trigger('click')
  expect(wrapper.text()).toContain('Count: 1')
})


5. 避免在测试中使用复杂的真实 API 请求,应使用 Mock 或 Stub

测试时不应依赖外部接口的真实请求,推荐使用 jest.mock、msw、sinon 等模拟数据,保证测试的独立性和稳定性。

错误示例

test('fetches data and renders', async () => {
  const wrapper = mount(DataComponent)
  await wrapper.vm.fetchData() // 真实请求导致测试不稳定
  expect(wrapper.text()).toContain('Data loaded')
})

正确示例

import axios from 'axios'
jest.mock('axios')

test('fetches data and renders', async () => {
  axios.get.mockResolvedValue({ data: { items: ['a', 'b'] } })
  const wrapper = mount(DataComponent)
  await wrapper.vm.fetchData()
  expect(wrapper.text()).toContain('a')
})


6. 组件依赖的异步行为应通过 Mock 异步函数进行控制

若组件依赖异步方法(如定时器、异步 API),应在测试中 Mock 这些异步行为,避免测试时间过长或不稳定。

错误示例

test('auto refresh updates data', async () => {
  const wrapper = mount(AutoRefresh)
  await new Promise(r => setTimeout(r, 5000)) // 测试过慢且不确定
  expect(wrapper.text()).toContain('Refreshed')
})

正确示例

jest.useFakeTimers()

test('auto refresh updates data', async () => {
  const wrapper = mount(AutoRefresh)
  jest.advanceTimersByTime(5000)
  await nextTick()
  expect(wrapper.text()).toContain('Refreshed')
  jest.useRealTimers()
})


7. 使用快照测试时应谨慎,避免大规模快照导致维护困难

快照测试适合对关键 UI 做稳定性检测,但不应滥用,避免包含无关紧要的 DOM 变动。

错误示例

test('renders full component snapshot', () => {
  const wrapper = mount(ComplexComponent)
  expect(wrapper.html()).toMatchSnapshot() // 快照过大,难维护
})

正确示例

test('renders header snapshot only', () => {
  const wrapper = mount(ComplexComponent)
  expect(wrapper.find('header').html()).toMatchSnapshot()
})


8. 单元测试中避免使用全局依赖,推荐注入依赖或使用 provide/inject Mock

Vue3 组件可能依赖全局插件或 provide/inject,测试时应 Mock 这些依赖,避免测试受全局状态影响。

错误示例

test('uses global i18n', () => {
  const wrapper = mount(ComponentUsingI18n)
  expect(wrapper.text()).toContain('Hello') // 依赖真实 i18n,环境复杂
})

正确示例

import { createI18n } from 'vue-i18n'

const i18n = createI18n({ locale: 'en', messages: { en: { hello: 'Hello' } } })

test('uses mocked i18n', () => {
  const wrapper = mount(ComponentUsingI18n, {
    global: { plugins: [i18n] }
  })
  expect(wrapper.text()).toContain('Hello')
})
❌