Appearance
列表渲染
Vue 提供了 v-for 指令来渲染列表数据。本文将详细介绍如何使用 v-for 指令以及相关的最佳实践。
基本用法
渲染数组
使用 v-for 指令可以基于一个数组来渲染一个列表:
vue
<template>
<ul>
<li v-for="item in items" :key="item.id">
{{ item.message }}
</li>
</ul>
</template>
<script setup>
import { ref } from 'vue'
const items = ref([
{ id: 1, message: 'Foo' },
{ id: 2, message: 'Bar' },
{ id: 3, message: 'Baz' }
])
</script>访问索引
v-for 也支持可选的第二个参数表示当前项的索引:
vue
<template>
<ul>
<li v-for="(item, index) in items" :key="item.id">
{{ index }} - {{ item.message }}
</li>
</ul>
</template>渲染对象
你也可以用 v-for 来遍历一个对象的所有属性:
vue
<template>
<ul>
<li v-for="value in myObject" :key="value">
{{ value }}
</li>
</ul>
</template>
<script setup>
import { reactive } from 'vue'
const myObject = reactive({
title: 'How to do lists in Vue',
author: 'Jane Doe',
publishedAt: '2016-04-10'
})
</script>对象的键名和索引
在遍历对象时,你也可以提供第二个参数为键名,第三个参数为索引:
vue
<template>
<ul>
<li v-for="(value, key, index) in myObject" :key="key">
{{ index }}. {{ key }}: {{ value }}
</li>
</ul>
</template>key 属性
为什么需要 key
当 Vue 正在更新使用 v-for 渲染的元素列表时,它默认使用"就地更新"的策略。如果数据项的顺序被改变,Vue 将不会移动 DOM 元素来匹配数据项的顺序,而是就地更新每个元素。
为了给 Vue 一个提示,以便它能跟踪每个节点的身份,从而重用和重新排序现有元素,你需要为每项提供一个唯一的 key 属性:
vue
<template>
<div v-for="item in items" :key="item.id">
<!-- 内容 -->
</div>
</template>key 的选择
- 推荐:使用唯一且稳定的值作为 key,如
item.id - 不推荐:使用索引作为 key(除非列表是静态的)
vue
<!-- 好的做法 -->
<li v-for="todo in todos" :key="todo.id">
{{ todo.text }}
</li>
<!-- 不推荐的做法 -->
<li v-for="(todo, index) in todos" :key="index">
{{ todo.text }}
</li>数组变更检测
变更方法
Vue 能够侦听响应式数组的变更方法,并在它们被调用时触发相关的更新。这些变更方法包括:
push()pop()shift()unshift()splice()sort()reverse()
vue
<template>
<div>
<ul>
<li v-for="item in items" :key="item.id">
{{ item.text }}
</li>
</ul>
<button @click="addItem">添加项目</button>
<button @click="removeItem">删除项目</button>
<button @click="sortItems">排序</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const items = ref([
{ id: 1, text: '学习 Vue' },
{ id: 2, text: '整个牛项目' },
{ id: 3, text: '征服世界' }
])
const addItem = () => {
const newId = items.value.length + 1
items.value.push({
id: newId,
text: `新项目 ${newId}`
})
}
const removeItem = () => {
items.value.pop()
}
const sortItems = () => {
items.value.sort((a, b) => a.text.localeCompare(b.text))
}
</script>替换数组
变更方法,顾名思义,会变更调用了这些方法的原始数组。相比之下,也有非变更方法,例如 filter()、concat() 和 slice()。它们不会变更原始数组,而总是返回一个新数组。当使用非变更方法时,可以用新数组替换旧数组:
vue
<script setup>
import { ref } from 'vue'
const items = ref([1, 2, 3, 4, 5])
// 这会触发更新
items.value = items.value.filter(item => item % 2 === 0)
</script>显示过滤/排序后的结果
有时,我们想要显示一个数组经过过滤或排序后的版本,而不实际变更或重置原始数据。在这种情况下,可以创建一个计算属性,来返回过滤或排序后的数组。
vue
<template>
<div>
<input v-model="searchText" placeholder="搜索..." />
<ul>
<li v-for="item in filteredItems" :key="item.id">
{{ item.name }} - {{ item.category }}
</li>
</ul>
<button @click="sortBy = 'name'">按名称排序</button>
<button @click="sortBy = 'category'">按分类排序</button>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const searchText = ref('')
const sortBy = ref('name')
const items = ref([
{ id: 1, name: 'Apple', category: 'Fruit' },
{ id: 2, name: 'Carrot', category: 'Vegetable' },
{ id: 3, name: 'Banana', category: 'Fruit' },
{ id: 4, name: 'Broccoli', category: 'Vegetable' }
])
const filteredItems = computed(() => {
let result = items.value
// 过滤
if (searchText.value) {
result = result.filter(item =>
item.name.toLowerCase().includes(searchText.value.toLowerCase())
)
}
// 排序
result = result.slice().sort((a, b) => {
return a[sortBy.value].localeCompare(b[sortBy.value])
})
return result
})
</script>在 <template> 上使用 v-for
类似于 v-if,你也可以利用 <template> 来循环渲染一段包含多个元素的内容:
vue
<template>
<ul>
<template v-for="item in items" :key="item.id">
<li>{{ item.msg }}</li>
<li class="divider" role="presentation"></li>
</template>
</ul>
</template>v-for 与 v-if
当它们处于同一节点,v-if 的优先级比 v-for 更高,这意味着 v-if 将没有权限访问 v-for 里的变量:
vue
<!-- 这会抛出一个错误,因为属性 todo 此时没有在该实例上定义 -->
<li v-for="todo in todos" v-if="!todo.isComplete" :key="todo.id">
{{ todo.name }}
</li>可以把 v-for 移动到 <template> 标签中来修正:
vue
<template v-for="todo in todos" :key="todo.id">
<li v-if="!todo.isComplete">
{{ todo.name }}
</li>
</template>组件上使用 v-for
在自定义组件上,你可以像在任何普通元素上一样使用 v-for:
vue
<template>
<div>
<MyComponent
v-for="item in items"
:item="item"
:key="item.id"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
import MyComponent from './MyComponent.vue'
const items = ref([
{ id: 1, title: 'Hello world' },
{ id: 2, title: 'Goodbye world' }
])
</script>子组件示例
vue
<!-- MyComponent.vue -->
<template>
<div class="item">
<h3>{{ item.title }}</h3>
<p>ID: {{ item.id }}</p>
</div>
</template>
<script setup>
defineProps({
item: {
type: Object,
required: true
}
})
</script>
<style scoped>
.item {
border: 1px solid #ccc;
padding: 10px;
margin: 5px 0;
border-radius: 4px;
}
</style>实际应用示例
待办事项列表
vue
<template>
<div class="todo-app">
<h1>待办事项</h1>
<form @submit.prevent="addTodo">
<input
v-model="newTodo"
placeholder="添加新的待办事项..."
required
/>
<button type="submit">添加</button>
</form>
<div class="filters">
<button
v-for="filter in filters"
:key="filter.key"
:class="{ active: currentFilter === filter.key }"
@click="currentFilter = filter.key"
>
{{ filter.label }}
</button>
</div>
<ul class="todo-list">
<li
v-for="todo in filteredTodos"
:key="todo.id"
:class="{ completed: todo.completed }"
>
<input
type="checkbox"
v-model="todo.completed"
/>
<span class="todo-text">{{ todo.text }}</span>
<button @click="removeTodo(todo.id)" class="delete-btn">
删除
</button>
</li>
</ul>
<p class="stats">
剩余 {{ remainingCount }} 项,共 {{ todos.length }} 项
</p>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const newTodo = ref('')
const currentFilter = ref('all')
const todos = ref([
{ id: 1, text: '学习 Vue 3', completed: false },
{ id: 2, text: '构建待办应用', completed: true },
{ id: 3, text: '部署到生产环境', completed: false }
])
const filters = [
{ key: 'all', label: '全部' },
{ key: 'active', label: '未完成' },
{ key: 'completed', label: '已完成' }
]
const filteredTodos = computed(() => {
switch (currentFilter.value) {
case 'active':
return todos.value.filter(todo => !todo.completed)
case 'completed':
return todos.value.filter(todo => todo.completed)
default:
return todos.value
}
})
const remainingCount = computed(() => {
return todos.value.filter(todo => !todo.completed).length
})
const addTodo = () => {
if (newTodo.value.trim()) {
todos.value.push({
id: Date.now(),
text: newTodo.value.trim(),
completed: false
})
newTodo.value = ''
}
}
const removeTodo = (id) => {
const index = todos.value.findIndex(todo => todo.id === id)
if (index > -1) {
todos.value.splice(index, 1)
}
}
</script>
<style scoped>
.todo-app {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.todo-app h1 {
text-align: center;
color: #333;
}
form {
display: flex;
margin-bottom: 20px;
}
form input {
flex: 1;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px 0 0 4px;
}
form button {
padding: 10px 20px;
background-color: #007bff;
color: white;
border: none;
border-radius: 0 4px 4px 0;
cursor: pointer;
}
.filters {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.filters button {
padding: 8px 16px;
border: 1px solid #ddd;
background-color: white;
border-radius: 4px;
cursor: pointer;
}
.filters button.active {
background-color: #007bff;
color: white;
}
.todo-list {
list-style: none;
padding: 0;
}
.todo-list li {
display: flex;
align-items: center;
padding: 10px;
border-bottom: 1px solid #eee;
}
.todo-list li.completed .todo-text {
text-decoration: line-through;
color: #999;
}
.todo-text {
flex: 1;
margin-left: 10px;
}
.delete-btn {
background-color: #dc3545;
color: white;
border: none;
padding: 5px 10px;
border-radius: 4px;
cursor: pointer;
}
.stats {
text-align: center;
color: #666;
margin-top: 20px;
}
</style>商品列表
vue
<template>
<div class="product-list">
<h2>商品列表</h2>
<div class="controls">
<input
v-model="searchQuery"
placeholder="搜索商品..."
class="search-input"
/>
<select v-model="sortBy" class="sort-select">
<option value="name">按名称排序</option>
<option value="price">按价格排序</option>
<option value="rating">按评分排序</option>
</select>
<select v-model="filterCategory" class="filter-select">
<option value="">所有分类</option>
<option
v-for="category in categories"
:key="category"
:value="category"
>
{{ category }}
</option>
</select>
</div>
<div class="products-grid">
<div
v-for="product in filteredProducts"
:key="product.id"
class="product-card"
>
<img :src="product.image" :alt="product.name" />
<h3>{{ product.name }}</h3>
<p class="category">{{ product.category }}</p>
<div class="rating">
<span
v-for="star in 5"
:key="star"
class="star"
:class="{ filled: star <= product.rating }"
>
★
</span>
<span class="rating-text">({{ product.rating }})</span>
</div>
<p class="price">¥{{ product.price }}</p>
<button @click="addToCart(product)" class="add-to-cart">
加入购物车
</button>
</div>
</div>
<div v-if="filteredProducts.length === 0" class="no-results">
没有找到匹配的商品
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const searchQuery = ref('')
const sortBy = ref('name')
const filterCategory = ref('')
const products = ref([
{
id: 1,
name: 'iPhone 14',
category: '手机',
price: 5999,
rating: 4.5,
image: 'https://via.placeholder.com/200x200'
},
{
id: 2,
name: 'MacBook Pro',
category: '电脑',
price: 12999,
rating: 4.8,
image: 'https://via.placeholder.com/200x200'
},
{
id: 3,
name: 'AirPods Pro',
category: '耳机',
price: 1999,
rating: 4.3,
image: 'https://via.placeholder.com/200x200'
},
{
id: 4,
name: 'iPad Air',
category: '平板',
price: 4399,
rating: 4.6,
image: 'https://via.placeholder.com/200x200'
}
])
const categories = computed(() => {
return [...new Set(products.value.map(p => p.category))]
})
const filteredProducts = computed(() => {
let result = products.value
// 搜索过滤
if (searchQuery.value) {
result = result.filter(product =>
product.name.toLowerCase().includes(searchQuery.value.toLowerCase())
)
}
// 分类过滤
if (filterCategory.value) {
result = result.filter(product => product.category === filterCategory.value)
}
// 排序
result = result.slice().sort((a, b) => {
switch (sortBy.value) {
case 'price':
return a.price - b.price
case 'rating':
return b.rating - a.rating
default:
return a.name.localeCompare(b.name)
}
})
return result
})
const addToCart = (product) => {
console.log('添加到购物车:', product.name)
// 这里可以添加购物车逻辑
}
</script>
<style scoped>
.product-list {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.controls {
display: flex;
gap: 15px;
margin-bottom: 30px;
flex-wrap: wrap;
}
.search-input,
.sort-select,
.filter-select {
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.search-input {
flex: 1;
min-width: 200px;
}
.products-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
}
.product-card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 15px;
text-align: center;
transition: transform 0.2s, box-shadow 0.2s;
}
.product-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.product-card img {
width: 100%;
height: 200px;
object-fit: cover;
border-radius: 4px;
margin-bottom: 10px;
}
.product-card h3 {
margin: 10px 0;
color: #333;
}
.category {
color: #666;
font-size: 14px;
margin: 5px 0;
}
.rating {
display: flex;
align-items: center;
justify-content: center;
margin: 10px 0;
}
.star {
color: #ddd;
font-size: 18px;
}
.star.filled {
color: #ffd700;
}
.rating-text {
margin-left: 5px;
color: #666;
font-size: 14px;
}
.price {
font-size: 18px;
font-weight: bold;
color: #e74c3c;
margin: 10px 0;
}
.add-to-cart {
background-color: #007bff;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
}
.add-to-cart:hover {
background-color: #0056b3;
}
.no-results {
text-align: center;
color: #666;
font-size: 18px;
margin-top: 50px;
}
</style>性能优化
1. 使用正确的 key
vue
<!-- 好的做法:使用唯一标识符 -->
<div v-for="user in users" :key="user.id">
{{ user.name }}
</div>
<!-- 避免:使用索引作为 key(当列表会变化时) -->
<div v-for="(user, index) in users" :key="index">
{{ user.name }}
</div>2. 避免在模板中进行复杂计算
vue
<!-- 不推荐:在模板中进行复杂计算 -->
<li v-for="item in items.filter(i => i.active).sort((a, b) => a.name.localeCompare(b.name))" :key="item.id">
{{ item.name }}
</li>
<!-- 推荐:使用计算属性 -->
<li v-for="item in sortedActiveItems" :key="item.id">
{{ item.name }}
</li>
<script setup>
import { computed } from 'vue'
const sortedActiveItems = computed(() => {
return items.value
.filter(item => item.active)
.sort((a, b) => a.name.localeCompare(b.name))
})
</script>3. 虚拟滚动
对于大量数据的列表,考虑使用虚拟滚动:
vue
<template>
<div class="virtual-list" @scroll="handleScroll" ref="container">
<div :style="{ height: totalHeight + 'px' }" class="virtual-list-phantom"></div>
<div class="virtual-list-content" :style="{ transform: `translateY(${startOffset}px)` }">
<div
v-for="item in visibleData"
:key="item.id"
class="virtual-list-item"
:style="{ height: itemHeight + 'px' }"
>
{{ item.text }}
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
const props = defineProps({
items: Array,
itemHeight: {
type: Number,
default: 50
},
containerHeight: {
type: Number,
default: 300
}
})
const container = ref(null)
const scrollTop = ref(0)
const totalHeight = computed(() => props.items.length * props.itemHeight)
const visibleCount = computed(() => Math.ceil(props.containerHeight / props.itemHeight))
const startIndex = computed(() => Math.floor(scrollTop.value / props.itemHeight))
const endIndex = computed(() => Math.min(startIndex.value + visibleCount.value, props.items.length))
const startOffset = computed(() => startIndex.value * props.itemHeight)
const visibleData = computed(() => {
return props.items.slice(startIndex.value, endIndex.value)
})
const handleScroll = (e) => {
scrollTop.value = e.target.scrollTop
}
onMounted(() => {
container.value.style.height = props.containerHeight + 'px'
})
</script>
<style scoped>
.virtual-list {
overflow-y: auto;
position: relative;
}
.virtual-list-phantom {
position: absolute;
left: 0;
top: 0;
right: 0;
z-index: -1;
}
.virtual-list-content {
left: 0;
right: 0;
top: 0;
position: absolute;
}
.virtual-list-item {
padding: 10px;
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
}
</style>选项式 API
在选项式 API 中,列表渲染的用法基本相同:
vue
<template>
<ul>
<li v-for="item in filteredItems" :key="item.id">
{{ item.name }}
</li>
</ul>
</template>
<script>
export default {
data() {
return {
items: [
{ id: 1, name: 'Apple', category: 'fruit' },
{ id: 2, name: 'Carrot', category: 'vegetable' }
],
filter: 'all'
}
},
computed: {
filteredItems() {
if (this.filter === 'all') {
return this.items
}
return this.items.filter(item => item.category === this.filter)
}
},
methods: {
addItem(item) {
this.items.push(item)
},
removeItem(id) {
const index = this.items.findIndex(item => item.id === id)
if (index > -1) {
this.items.splice(index, 1)
}
}
}
}
</script>最佳实践
1. 总是使用 key
vue
<!-- 好 -->
<div v-for="item in items" :key="item.id">
{{ item.name }}
</div>
<!-- 不好 -->
<div v-for="item in items">
{{ item.name }}
</div>2. 避免 v-if 和 v-for 在同一元素上
vue
<!-- 不推荐 -->
<li v-for="user in users" v-if="user.isActive" :key="user.id">
{{ user.name }}
</li>
<!-- 推荐 -->
<template v-for="user in users" :key="user.id">
<li v-if="user.isActive">
{{ user.name }}
</li>
</template>
<!-- 或者使用计算属性 -->
<li v-for="user in activeUsers" :key="user.id">
{{ user.name }}
</li>3. 使用计算属性进行复杂的列表操作
vue
<script setup>
import { computed } from 'vue'
const processedItems = computed(() => {
return items.value
.filter(item => item.visible)
.sort((a, b) => a.order - b.order)
.map(item => ({
...item,
displayName: `${item.name} (${item.category})`
}))
})
</script>4. 合理使用 v-memo
对于大列表中的复杂项目,可以使用 v-memo 来优化性能:
vue
<template>
<div
v-for="item in list"
:key="item.id"
v-memo="[item.id, item.selected]"
>
<p>ID: {{ item.id }}</p>
<p>Selected: {{ item.selected }}</p>
<p>...more info</p>
</div>
</template>