跳到主要内容

Vue 列表渲染

列表渲染是前端开发中最常见的需求之一。Vue 的 v-for 指令提供了一种简洁、高效的方式来渲染列表数据。本章将详细介绍列表渲染的各种用法和最佳实践。

v-for 基本用法

遍历数组

我们可以使用 v-for 指令基于一个数组来渲染一个列表。v-for 指令需要一种特殊语法 item in items,其中 items 是源数据数组,而 item 是被迭代的数组元素的别名

<script setup>
import { ref } from 'vue'

const items = ref([
{ message: 'Foo' },
{ message: 'Bar' }
])
</script>

<template>
<li v-for="item in items">
{{ item.message }}
</li>
</template>

带索引的遍历

v-for 块中可以访问父作用域的属性。此外,v-for 还支持一个可选的第二个参数来表示当前项的位置索引:

<script setup>
import { ref } from 'vue'

const parentMessage = ref('Parent')
const items = ref([
{ message: 'Foo' },
{ message: 'Bar' }
])
</script>

<template>
<li v-for="(item, index) in items">
{{ parentMessage }} - {{ index }} - {{ item.message }}
</li>
</template>

v-for 的变量作用域与以下 JavaScript 代码类似:

const parentMessage = 'Parent'
const items = [
/* ... */
]
items.forEach((item, index) => {
// 可以访问外层作用域的 parentMessage
// 但 item 和 index 只在这里可用
console.log(parentMessage, item.message, index)
})

使用解构

注意 v-for 的值如何匹配 forEach 回调的函数签名。实际上,你可以像解构函数参数一样,在 v-for 的项目别名中使用解构:

<script setup>
import { ref } from 'vue'

const items = ref([
{ message: 'Foo', id: 1 },
{ message: 'Bar', id: 2 }
])
</script>

<template>
<!-- 解构 item -->
<li v-for="{ message } in items">
{{ message }}
</li>

<!-- 带索引的解构 -->
<li v-for="({ message }, index) in items">
{{ message }} - {{ index }}
</li>
</template>

使用 of 代替 in

你也可以使用 of 作为分隔符来代替 in,这样更接近 JavaScript 的迭代器语法:

<div v-for="item of items"></div>

嵌套 v-for

对于嵌套的 v-for,作用域的工作方式类似于嵌套函数。每个 v-for 作用域都可以访问父级作用域:

<script setup>
import { ref } from 'vue'

const items = ref([
{ message: 'Foo', children: ['A', 'B'] },
{ message: 'Bar', children: ['C', 'D'] }
])
</script>

<template>
<li v-for="item in items">
<span v-for="childItem in item.children">
{{ item.message }} - {{ childItem }}
</span>
</li>
</template>

v-for 遍历对象

你也可以使用 v-for 来遍历一个对象的所有属性。遍历的顺序会基于对该对象调用 Object.values() 的结果:

<script setup>
import { reactive } from 'vue'

const myObject = reactive({
title: 'How to do lists in Vue',
author: 'Jane Doe',
publishedAt: '2016-04-10'
})
</script>

<template>
<ul>
<li v-for="value in myObject">
{{ value }}
</li>
</ul>
</template>

遍历对象的键和索引

可以通过提供第二个参数来获取属性名(即键名):

<li v-for="(value, key) in myObject">
{{ key }}: {{ value }}
</li>

还可以提供第三个参数来获取索引:

<li v-for="(value, key, index) in myObject">
{{ index }}. {{ key }}: {{ value }}
</li>

v-for 遍历数字

v-for 可以直接接受一个整数值。在这种用例中,会将该模板重复 n 次,基于 1...n 的范围:

<div v-for="n in 10">{{ n }}</div>

注意这里的 n1 开始而不是 0

在 template 上使用 v-for

与模板 v-if 类似,你也可以在 <template> 标签上使用 v-for 来渲染一个包含多个元素的块。例如:

<script setup>
import { ref } from 'vue'

const items = ref([
{ msg: 'Foo' },
{ msg: 'Bar' }
])
</script>

<template>
<ul>
<template v-for="item in items">
<li>{{ item.msg }}</li>
<li class="divider" role="presentation"></li>
</template>
</ul>
</template>

使用 key 维护状态

当 Vue 正在更新使用 v-for 渲染的元素列表时,它默认使用"就地更新"的策略。如果数据项的顺序被改变,Vue 将不会移动 DOM 元素来匹配数据项的顺序,而是就地更新每个元素,并且确保它们在每个索引位置正确渲染。

这种默认模式是高效的,但只适用于当你的列表渲染输出不依赖子组件状态或临时 DOM 状态(例如表单输入值)时

为什么需要 key?

为了给 Vue 一个提示,以便它可以跟踪每个节点的标识,从而重用和重新排序现有的元素,你需要为每个项目提供一个唯一的 key 属性:

<script setup>
import { ref } from 'vue'

const items = ref([
{ id: 1, name: '苹果' },
{ id: 2, name: '香蕉' },
{ id: 3, name: '橙子' }
])
</script>

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

key 的注意事项

  1. 推荐使用字符串或数字类型的值:不要使用对象作为 v-for 的 key
  2. 使用 <template v-for> 时,key 应该放在 <template> 容器上
<template v-for="todo in todos" :key="todo.name">
<li>{{ todo.name }}</li>
</template>
  1. 不要使用数组索引作为 key:如果列表顺序可能改变,使用索引作为 key 会导致问题
<script setup>
import { ref } from 'vue'

const items = ref([
{ id: 1, text: '学习 Vue' },
{ id: 2, text: '做作业' }
])
</script>

<template>
<!-- 推荐:使用唯一 id -->
<li v-for="item in items" :key="item.id">{{ item.text }}</li>

<!-- 不推荐:使用索引(当列表顺序改变时会有问题) -->
<li v-for="(item, index) in items" :key="index">{{ item.text }}</li>
</template>
建议

只要可能,请为 v-for 提供一个 key 属性,除非迭代的 DOM 内容足够简单(即不包含组件或有状态的 DOM 元素),或者你故意想利用默认行为来提高性能。

v-for 与组件

你可以直接在组件上使用 v-for,就像在任何普通元素上一样(别忘了提供一个 key):

<MyComponent v-for="item in items" :key="item.id" />

然而,这不会自动将任何数据传递给组件,因为组件有自己独立的作用域。为了将迭代的数据传递给组件,我们需要使用 props:

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

const items = ref([
{ id: 1, name: '项目 1' },
{ id: 2, name: '项目 2' }
])
</script>

<template>
<MyComponent
v-for="(item, index) in items"
:item="item"
:index="index"
:key="item.id"
/>
</template>

不自动将 item 注入组件的原因是,这会使组件与 v-for 的工作方式紧密耦合。明确组件数据来源可以使组件在其他场景下也能复用。

数组变化检测

变异方法

Vue 能够检测到响应式数组的变异方法被调用,并触发必要的更新。这些变异方法是:

方法描述
push()在数组末尾添加一个或多个元素
pop()删除数组的最后一个元素
shift()删除数组的第一个元素
unshift()在数组开头添加一个或多个元素
splice()添加/删除/替换数组元素
sort()对数组元素进行排序
reverse()反转数组元素的顺序
<script setup>
import { ref } from 'vue'

const items = ref(['a', 'b', 'c'])

function demo() {
// push() - 添加元素到末尾
items.value.push('d') // ['a', 'b', 'c', 'd']

// pop() - 移除最后一个元素
items.value.pop() // ['a', 'b', 'c']

// shift() - 移除第一个元素
items.value.shift() // ['b', 'c']

// unshift() - 添加到开头
items.value.unshift('a') // ['a', 'b', 'c']

// splice() - 删除、插入、替换
items.value.splice(1, 1) // 删除索引1的元素: ['a', 'c']
items.value.splice(1, 0, 'b') // 在索引1插入: ['a', 'b', 'c']
items.value.splice(1, 1, 'B') // 替换索引1: ['a', 'B', 'c']

// sort() - 排序
items.value.sort() // ['B', 'a', 'c']

// reverse() - 反转
items.value.reverse() // ['c', 'a', 'B']
}
</script>

替换数组

变异方法,顾名思义,会改变调用它们的原始数组。相比之下,也有非变异方法,例如 filter()concat()slice(),它们不会改变原始数组,而是始终返回一个新数组。当使用非变异方法时,应该用新数组替换旧数组:

<script setup>
import { ref } from 'vue'

const items = ref([
{ message: 'Foo' },
{ message: 'Bar' },
{ message: 'Baz' }
])

function filterItems() {
// 使用 filter 返回新数组,然后替换
items.value = items.value.filter(item => item.message.match(/Foo/))
}

function concatItems() {
// 使用 concat 返回新数组
items.value = items.value.concat([{ message: 'New' }])
}

function sliceItems() {
// 使用 slice 返回新数组
items.value = items.value.slice(0, 2)
}
</script>

你可能会认为这会导致 Vue 丢弃现有的 DOM 并重新渲染整个列表——幸运的是,情况并非如此。Vue 实现了一些智能的启发式方法来最大化 DOM 元素的重用,因此将一个数组替换为包含重叠对象的另一个数组是非常高效的操作。

显示过滤/排序后的结果

有时我们想要显示一个数组经过过滤或排序后的版本,而不实际改变或重置原始数据。在这种情况下,可以创建一个返回过滤或排序后数组的计算属性。

使用计算属性(推荐)

<script setup>
import { ref, computed } from 'vue'

const numbers = ref([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])

// 计算属性返回偶数
const evenNumbers = computed(() => {
return numbers.value.filter(n => n % 2 === 0)
})
</script>

<template>
<li v-for="n in evenNumbers">{{ n }}</li>
</template>

使用方法

在计算属性不适用的情况下(例如在嵌套的 v-for 循环中),可以使用方法:

<script setup>
import { ref } from 'vue'

const sets = ref([
[1, 2, 3, 4, 5],
[6, 7, 8, 9, 10]
])

function even(numbers) {
return numbers.filter(number => number % 2 === 0)
}
</script>

<template>
<ul v-for="numbers in sets">
<li v-for="n in even(numbers)">{{ n }}</li>
</ul>
</template>

注意事项

在计算属性中使用 reverse()sort() 时要小心!这两个方法会改变原始数组,在计算属性的 getter 中应该避免这样做。在调用这些方法之前,应该先创建原始数组的副本:

// 错误:会改变原始数组
return numbers.value.reverse()

// 正确:先创建副本
return [...numbers.value].reverse()

v-for 与 v-if

不推荐

不推荐在同一元素上使用 v-ifv-for

当它们同时存在于一个节点上时,v-if 的优先级比 v-for 更高。这意味着 v-if 的条件将无法访问到 v-for 作用域内定义的变量别名:

<!-- 这会抛出一个错误,因为属性 "todo" 没有在实例上定义 -->
<li v-for="todo in todos" v-if="!todo.isComplete">
{{ todo.name }}
</li>

正确的做法

方式一:将 v-for 移到 <template> 标签上

<script setup>
import { ref } from 'vue'

const todos = ref([
{ name: '学习 Vue', isComplete: false },
{ name: '做作业', isComplete: true },
{ name: '锻炼身体', isComplete: false }
])
</script>

<template>
<template v-for="todo in todos" :key="todo.name">
<li v-if="!todo.isComplete">
{{ todo.name }}
</li>
</template>
</template>

方式二:使用计算属性过滤(推荐)

<script setup>
import { ref, computed } from 'vue'

const todos = ref([
{ name: '学习 Vue', isComplete: false },
{ name: '做作业', isComplete: true },
{ name: '锻炼身体', isComplete: false }
])

const incompleteTodos = computed(() => {
return todos.value.filter(todo => !todo.isComplete)
})
</script>

<template>
<li v-for="todo in incompleteTodos" :key="todo.name">
{{ todo.name }}
</li>
</template>

实际应用示例

示例:待办事项列表

<script setup>
import { ref } from 'vue'

const newTodo = ref('')
const todos = ref([
{ id: 1, text: '学习 Vue', done: false },
{ id: 2, text: '做作业', done: true },
{ id: 3, text: '锻炼身体', done: false }
])

let nextId = 4

function addTodo() {
if (newTodo.value.trim()) {
todos.value.push({
id: nextId++,
text: newTodo.value.trim(),
done: false
})
newTodo.value = ''
}
}

function removeTodo(id) {
const index = todos.value.findIndex(todo => todo.id === id)
if (index > -1) {
todos.value.splice(index, 1)
}
}

function toggleTodo(todo) {
todo.done = !todo.done
}

function clearCompleted() {
todos.value = todos.value.filter(todo => !todo.done)
}
</script>

<template>
<div class="todo-app">
<h2>待办事项</h2>

<!-- 添加新待办 -->
<div class="add-todo">
<input
v-model="newTodo"
@keyup.enter="addTodo"
placeholder="添加新事项"
/>
<button @click="addTodo">添加</button>
</div>

<!-- 待办列表 -->
<ul class="todo-list">
<li
v-for="todo in todos"
:key="todo.id"
:class="{ completed: todo.done }"
>
<input
type="checkbox"
:checked="todo.done"
@change="toggleTodo(todo)"
/>
<span>{{ todo.text }}</span>
<button class="delete-btn" @click="removeTodo(todo.id)">删除</button>
</li>
</ul>

<!-- 统计 -->
<div class="stats">
<span>共 {{ todos.length }} 项</span>
<span>已完成 {{ todos.filter(t => t.done).length }} 项</span>
<button @click="clearCompleted">清除已完成</button>
</div>
</div>
</template>

<style scoped>
.todo-app {
max-width: 500px;
margin: 0 auto;
}
.add-todo {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.add-todo input {
flex: 1;
padding: 10px;
}
button {
padding: 10px 16px;
background: #42b883;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background: #33a06f;
}
.todo-list {
list-style: none;
padding: 0;
}
.todo-list li {
display: flex;
align-items: center;
padding: 12px;
border-bottom: 1px solid #eee;
}
.todo-list li.completed span {
text-decoration: line-through;
color: #999;
}
.todo-list span {
flex: 1;
margin: 0 10px;
}
.delete-btn {
background: #f5222d;
padding: 5px 10px;
font-size: 12px;
}
.stats {
display: flex;
gap: 15px;
margin-top: 20px;
color: #666;
font-size: 14px;
}
.stats button {
background: #999;
padding: 5px 10px;
font-size: 12px;
}
</style>

示例:可排序列表

<script setup>
import { ref, computed } from 'vue'

const sortKey = ref('name')
const sortOrder = ref('asc')

const items = ref([
{ id: 1, name: '苹果', price: 5, stock: 100 },
{ id: 2, name: '香蕉', price: 3, stock: 150 },
{ id: 3, name: '橙子', price: 4, stock: 80 },
{ id: 4, name: '葡萄', price: 8, stock: 60 }
])

const sortedItems = computed(() => {
return [...items.value].sort((a, b) => {
let comparison = 0
if (a[sortKey.value] > b[sortKey.value]) comparison = 1
if (a[sortKey.value] < b[sortKey.value]) comparison = -1
return sortOrder.value === 'asc' ? comparison : -comparison
})
})

function sortBy(key) {
if (sortKey.value === key) {
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
} else {
sortKey.value = key
sortOrder.value = 'asc'
}
}
</script>

<template>
<div class="sortable-list">
<h2>商品列表</h2>

<table>
<thead>
<tr>
<th @click="sortBy('name')">
名称
<span v-if="sortKey === 'name'">
{{ sortOrder === 'asc' ? '↑' : '↓' }}
</span>
</th>
<th @click="sortBy('price')">
价格
<span v-if="sortKey === 'price'">
{{ sortOrder === 'asc' ? '↑' : '↓' }}
</span>
</th>
<th @click="sortBy('stock')">
库存
<span v-if="sortKey === 'stock'">
{{ sortOrder === 'asc' ? '↑' : '↓' }}
</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="item in sortedItems" :key="item.id">
<td>{{ item.name }}</td>
<td>¥{{ item.price }}</td>
<td>{{ item.stock }}</td>
</tr>
</tbody>
</table>
</div>
</template>

<style scoped>
.sortable-list {
max-width: 500px;
margin: 0 auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background: #f5f5f5;
cursor: pointer;
user-select: none;
}
th:hover {
background: #eee;
}
</style>

示例:搜索过滤

<script setup>
import { ref, computed } from 'vue'

const searchQuery = ref('')
const selectedCategory = ref('')

const items = ref([
{ id: 1, name: '苹果', category: '水果', price: 5 },
{ id: 2, name: '香蕉', category: '水果', price: 3 },
{ id: 3, name: '胡萝卜', category: '蔬菜', price: 2 },
{ id: 4, name: '西兰花', category: '蔬菜', price: 4 },
{ id: 5, name: '草莓', category: '水果', price: 10 },
{ id: 6, name: '番茄', category: '蔬菜', price: 3 }
])

const categories = computed(() => {
return [...new Set(items.value.map(item => item.category))]
})

const filteredItems = computed(() => {
return items.value.filter(item => {
const matchesSearch = item.name
.toLowerCase()
.includes(searchQuery.value.toLowerCase())
const matchesCategory = !selectedCategory.value ||
item.category === selectedCategory.value
return matchesSearch && matchesCategory
})
})
</script>

<template>
<div class="search-filter">
<h2>商品搜索</h2>

<!-- 搜索和过滤 -->
<div class="filters">
<input
v-model="searchQuery"
placeholder="搜索商品..."
class="search-input"
/>
<select v-model="selectedCategory" class="category-select">
<option value="">全部分类</option>
<option v-for="cat in categories" :key="cat" :value="cat">
{{ cat }}
</option>
</select>
</div>

<!-- 结果列表 -->
<ul class="item-list">
<li v-for="item in filteredItems" :key="item.id">
<span class="name">{{ item.name }}</span>
<span class="category">{{ item.category }}</span>
<span class="price">¥{{ item.price }}</span>
</li>
</ul>

<p v-if="filteredItems.length === 0" class="no-results">
没有找到匹配的商品
</p>
<p v-else class="result-count">
共找到 {{ filteredItems.length }} 件商品
</p>
</div>
</template>

<style scoped>
.search-filter {
max-width: 500px;
margin: 0 auto;
}
.filters {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.search-input {
flex: 1;
padding: 10px;
}
.category-select {
padding: 10px;
}
.item-list {
list-style: none;
padding: 0;
}
.item-list li {
display: flex;
justify-content: space-between;
padding: 12px;
border-bottom: 1px solid #eee;
}
.name {
font-weight: bold;
}
.category {
color: #666;
font-size: 14px;
}
.price {
color: #f5222d;
font-weight: bold;
}
.no-results, .result-count {
text-align: center;
color: #999;
margin-top: 20px;
}
</style>

小结

本章我们详细学习了 Vue 列表渲染的完整内容:

  1. v-for 基本用法:遍历数组、对象、数字
  2. 索引和键:获取索引和键名
  3. 解构语法:在 v-for 中使用解构
  4. template 支持:在 template 上使用 v-for
  5. key 属性:为什么需要 key 及正确使用方式
  6. 数组变化检测:变异方法和非变异方法
  7. 过滤和排序:使用计算属性处理数据
  8. v-for 与 v-if:正确处理两者的组合

最佳实践

  • 始终为 v-for 提供唯一的 key
  • 使用计算属性进行过滤和排序
  • 避免在同一元素上使用 v-ifv-for
  • 使用展开运算符 ... 创建数组副本后再调用 reverse()sort()

练习

  1. 创建一个商品列表,实现按名称、价格排序功能
  2. 实现一个带分页功能的列表(每页显示 N 条)
  3. 创建一个可拖拽排序的列表(提示:需要结合 HTML5 拖拽 API)

准备好进入下一章,学习事件处理的内容了吗?