欢乐走免费领手机
24.13MB · 2025-11-10
虚拟列表(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 是一个强大的 Vue 3 组合式 API 工具集,其中 useVirtualList 提供了开箱即用的虚拟列表解决方案。后续所有的示例代码均使用 useVirtualList 实现
快速开始
npm install @vueuse/core
# 或
pnpm add @vueuse/core
不得不说,Vue的技术生态中,很多第三方依赖开箱即用,这一点非常棒!
import { useVirtualList } from '@vueuse/core'
const { list, containerProps, wrapperProps } = useVirtualList(
items, // 数据源
options // 配置选项
)
类型: MaybeRef<T[]>
说明: 要渲染的数据数组,可以是响应式引用或普通数组
// 方式 1: 响应式引用
const items = ref([1, 2, 3, ...])
// 方式 2: 普通数组
const items = [1, 2, 3, ...]
// 方式 3: computed
const items = computed(() => originalData.filter(...))
类型: UseVirtualListOptions
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
itemHeight | number | (index: number) => number | 必需 | 项目高度,可以是固定值或计算函数 |
overscan | number | 5 | 预渲染的额外项目数量,提升滚动流畅度 |
类型: 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>
类型: Object
说明: 需要绑定到容器元素的属性对象
包含属性:
ref: 容器的引用onScroll: 滚动事件处理函数style: 容器样式(可能包含 overflow 等)使用方式:
<div v-bind="containerProps" style="height: 400px; overflow-y: auto;">
<!-- 内容 -->
</div>
类型: Object
说明: 需要绑定到包装器元素的属性对象
包含属性:
style: 包装器样式,包含计算的总高度作用:
使用方式:
<div v-bind="containerProps">
<div v-bind="wrapperProps">
<!-- 列表项 -->
</div>
</div>
关键点:
经过笔者的调研,主要有四种虚拟列表技术方案
| 场景 | 说明 |
|---|---|
| 数据列表 | 表格数据、商品列表、用户列表 |
| 日志查看器 | 系统日志、操作记录 |
| 聊天记录 | 简单文本消息列表 |
| 文件浏览器 | 文件/文件夹列表 |
组件代码: 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>
| 场景 | 说明 |
|---|---|
| 下拉选择器 | 数据量不确定的下拉列表 |
| 搜索建议 | 搜索结果列表(数量动态变化) |
| 弹窗列表 | 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 | 自动缩小 |
| 数据多时 | 显示滚动条 | 显示滚动条 |
| 场景 | 说明 |
|---|---|
| 新闻列表 | 标题长度不一,内容预览不同 |
| 卡片流 | 不同类型的卡片高度不同 |
| 评论列表 | 评论内容长度不一 |
| 商品展示 | 不同商品信息复杂度不同 |
| 邮件列表 | 带附件、标签等额外信息 |
组件代码: 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;
};
getItemHeight(index) 对同一个 index 必须始终返回相同的值// 错误:随机高度(不可预测)
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; // 提前计算好的高度
};
| 场景 | 说明 |
|---|---|
| 复杂弹窗 | 弹窗内的复杂列表,高度不确定 |
| 搜索结果 | 不同类型的搜索结果混合 |
| 通知中心 | 不同类型通知,数量动态变化 |
| 购物车 | 不同商品信息,数量动态 |
| 活动页面 | 不同类型的活动卡片 |
组件代码: 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: 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;
});
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 环境中,需要注意以下几点:
// 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 |
// 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