L04 · 列表渲染:v-for 与 key 的秘密
🎯 本节目标:用 v-for 渲染 Todo 列表,理解 key 在 diff 算法中的角色
📦 本节产出:可动态展示任意数量 Todo 的列表视图
🔗 前置钩子:L03 的 ref/reactive 响应式数据 + todos 数组
🔗 后续钩子:L05 将为列表中的每项添加完成/删除操作1. 问题回顾
L03 中我们硬编码了 todos[0]、todos[1]、todos[2],新添加的 Todo 无法显示。我们需要一种方式自动遍历数组,为每个元素渲染一个组件。
2. v-for 基础语法
2.1 遍历数组
<template>
<!-- item 是数组中的每一项,index 是索引 -->
<div v-for="(item, index) in items" :key="item.id">
{{ index }}. {{ item.name }}
</div>
</template>2.2 应用到 Todo App
替换 App.vue 的脚本和模板,保留 L03 的 <style scoped>。
<!-- src/App.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import TodoItem from './components/TodoItem.vue'
import type { Todo } from './types/todo'
const todos = ref<Todo[]>([
{ id: 1, text: '搭建项目脚手架', done: true, priority: 'low', createdAt: '2024-01-01' },
{ id: 2, text: '理解组件和 Props', done: true, priority: 'medium', createdAt: '2024-01-02' },
{ id: 3, text: '学习响应式系统', done: false, priority: 'high', createdAt: '2024-01-03' },
])
let nextTodoId = 4
const newTodoText = ref('')
function addTodo() {
const text = newTodoText.value.trim()
if (!text) return
todos.value.push({
id: nextTodoId++,
text,
done: false,
priority: 'medium',
createdAt: new Date().toISOString().slice(0, 10), // UTC 日期
})
newTodoText.value = ''
}
</script>
<template>
<div class="app">
<header class="app-header">
<h1>📝 Vue Todo</h1>
<p class="subtitle">{{ todos.length }} 个任务</p>
</header>
<div class="add-todo">
<input
:value="newTodoText"
@input="newTodoText = ($event.target as HTMLInputElement).value"
@keydown.enter="!$event.isComposing && addTodo()"
placeholder="添加新任务..."
class="todo-input"
/>
<button @click="addTodo" class="add-btn">添加</button>
</div>
<main class="app-main">
<!-- ✅ v-for 遍历数组,渲染任意数量的 TodoItem -->
<TodoItem
v-for="todo in todos"
:key="todo.id"
:text="todo.text"
:done="todo.done"
:priority="todo.priority"
:created-at="todo.createdAt"
/>
</main>
</div>
</template>对比改造前后:
3. key 的作用:不只是消除警告
3.1 没有 key 会怎样
<!-- ❌ 没有 key -->
<TodoItem v-for="todo in todos" :text="todo.text" />Vue 允许无 key 的列表,并按位置就地更新;不能依赖一条固定的运行时警告来发现问题。ESLint 的相关规则会检查缺失 key 的写法。稳定的 key 用来跟踪项目身份,尤其影响组件状态是否跟随正确的数据项。
3.2 key 在 diff 算法中的角色
当数组变化时(添加、删除、排序),Vue 需要知道新旧列表中哪些节点是同一个:
3.3 用 index 做 key 的问题
<!-- ⚠️ 看起来可以,实际有坑 -->
<TodoItem v-for="(todo, index) in todos" :key="index" :text="todo.text" />场景:删除第一条 Todo
删除前: 删除后:
index 0 → "买牛奶" index 0 → "学 Vue" ← index 没变!
index 1 → "学 Vue" index 1 → "写文档" ← index 没变!
index 2 → "写文档"Vue 认为 index 0 的节点还在,只是内容变了——于是更新内容但保留了旧的组件状态(比如输入框里未提交的文字、动画状态)。
本项目会删除、排序任务,因此用同级列表中唯一且稳定的 todo.id 做 key。索引只表示位置,不适合跟踪这类数据项的身份;固定顺序且没有状态的简单列表则不一定需要同样的处理。
4. v-for 的其他用法
4.1 遍历对象
<script setup lang="ts">
const user = { name: '张三', age: 25, city: '北京' }
</script>
<template>
<!-- value 是属性值,key 是属性名,index 是序号 -->
<div v-for="(value, key, index) in user" :key="key">
{{ index }}. {{ key }}: {{ value }}
</div>
<!-- 输出:
0. name: 张三
1. age: 25
2. city: 北京
-->
</template>4.2 遍历数字范围
<template>
<!-- n 从 1 开始,不是 0 -->
<span v-for="n in 5" :key="n">{{ n }} </span>
<!-- 输出:1 2 3 4 5 -->
</template>4.3 v-for 与 v-if 不要同时用在一个元素上
<!-- ❌ 错误:v-if 优先级高于 v-for,此时 todo 还不存在 -->
<TodoItem
v-for="todo in todos"
v-if="!todo.done"
:key="todo.id"
/>
<!-- ✅ 方案 1:用 <template> 包裹 v-for -->
<template v-for="todo in todos" :key="todo.id">
<TodoItem v-if="!todo.done" :text="todo.text" />
</template>
<!-- ✅ 方案 2(更推荐):用 computed 过滤 -->
<!-- 在 script 中:const activeTodos = computed(() => todos.value.filter(t => !t.done)) -->
<TodoItem v-for="todo in activeTodos" :key="todo.id" :text="todo.text" />5. 数组变更检测
Vue 能检测到以下变更方法(直接修改原数组):
// ✅ 这些操作会触发视图更新
todos.value.push(newTodo)
todos.value.pop()
todos.value.shift()
todos.value.unshift(newTodo)
todos.value.splice(index, 1)
todos.value.sort((a, b) => a.id - b.id)
todos.value.reverse()替换数组也可以(创建新数组赋值):
// ✅ 替换整个数组也能触发更新
todos.value = todos.value.filter(t => !t.done)
todos.value = [...todos.value, newTodo]在 Vue 3 中没有 Vue 2 的限制。 Vue 2 中
arr[index] = newValue不响应式,Vue 3 用 Proxy 解决了这个问题。
// ✅ Vue 3 中直接按索引修改也可以
const first = todos.value[0]
if (first) {
todos.value[0] = { ...first, done: true }
// 或者直接修改已有对象:first.done = true
}6. 实战:常见列表操作
6.1 删除指定项
function deleteTodo(id: number) {
todos.value = todos.value.filter(t => t.id !== id)
// 或者用 splice(修改原数组)
// const index = todos.value.findIndex(t => t.id === id)
// if (index !== -1) todos.value.splice(index, 1)
}6.2 修改指定项
function toggleTodo(id: number) {
const todo = todos.value.find(t => t.id === id)
if (todo) {
todo.done = !todo.done // ✅ Vue 3 可以直接修改
}
}6.3 插入到指定位置
function insertAfter(afterId: number, newTodo: Todo) {
const index = todos.value.findIndex(t => t.id === afterId)
if (index === -1) return // 找不到目标时不插入
todos.value.splice(index + 1, 0, newTodo)
}6.4 排序
function sortByPriority() {
const order = { high: 0, medium: 1, low: 2 }
todos.value.sort((a, b) => order[a.priority] - order[b.priority])
}6.5 操作对照图
7. key 与组件状态:用真实案例理解
这是一个很容易踩坑的场景:列表中每项包含有内部状态的组件(如输入框)。
<!-- EditableTodoItem.vue — 每项有自己的编辑状态 -->
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{ text: string }>()
const isEditing = ref(false) // 组件内部状态
const editText = ref('')
</script>
<template>
<div class="editable-item">
<template v-if="isEditing">
<input v-model="editText" />
<button @click="isEditing = false">结束编辑</button>
</template>
<template v-else>
<span>{{ text }}</span>
<button @click="isEditing = true; editText = text">编辑</button>
</template>
</div>
</template>测试场景:
- 点击第一项的"编辑"按钮 → 输入框出现,输入 "新内容"
- 此时删除第一项
如果 key=index:
组件实例不会被销毁!Vue 认为 index:0 还在
→ 第二项的内容被渲染到 index:0 的组件中
→ 但 isEditing=true 和 editText="新内容" 是旧组件的状态
→ 用户看到第二项突然出现了编辑框和错误的内容 😱
如果 key=id:
id:1 的组件实例被正确销毁
→ id:2 和 id:3 的组件完整保留,状态正确
→ 用户看到第一项消失,其他不变 ✅Vue 根据节点类型和 key 判断能否复用实例。此例的编辑框只用于观察局部状态,“结束编辑”不会保存到父组件;更新数据的事件将在 L05、L06 中加入。
8. 大列表性能提示
列表是否需要优化,取决于每项的复杂度、可见节点数、更新频率和设备表现,应先测量:
// ✅ 用 computed 过滤而不是在模板里写复杂表达式
const filteredTodos = computed(() =>
todos.value.filter(t => t.text.includes(searchQuery.value))
)
// ✅ 使用 shallowRef(将在 L30 深入)
// 当列表数据只被整体替换而不修改内部属性时
const bigList = shallowRef<Item[]>([])
bigList.value = fetchedData // 只在 .value 赋值时触发更新
// ✅ 大列表使用虚拟滚动(Phase 3 · L30 将详细讲解)
// 主要渲染可见区域及缓冲区,减少同时存在的 DOM 节点| 观察到的问题 | 可考虑的方案 |
|---|---|
| 当前交互流畅 | 保持普通 v-for,使用稳定 key |
| 反复计算过滤结果 | 用 computed 缓存派生列表 |
| 深层响应式追踪成本较高 | 数据采用不可变更新时考虑 shallowRef |
| 同时渲染的 DOM 节点过多 | 分页或虚拟列表(如 @tanstack/vue-virtual) |
9. <template> 上的 v-for
有时你想循环渲染多个元素但不想添加额外的 DOM 包裹节点:
<!-- ❌ 额外的 div 包裹 -->
<div v-for="todo in todos" :key="todo.id">
<h3>{{ todo.text }}</h3>
<p>{{ todo.createdAt }}</p>
</div>
<!-- ✅ template 不渲染为 DOM 节点 -->
<template v-for="todo in todos" :key="todo.id">
<h3>{{ todo.text }}</h3>
<p>{{ todo.createdAt }}</p>
</template><template> 是 Vue 的"隐形容器",只存在于模板层面,不会渲染为真实 DOM 元素。
列表行为参见 Vue 列表渲染,性能选择参见 Vue 性能优化。
10. 本节总结
知识图谱
检查清单
- [ ] 能用
v-for遍历数组渲染组件列表 - [ ] 知道 Todo 列表为什么使用稳定 id,而不使用 index
- [ ] 能解释 key 在 diff 算法中的作用(用自己的话)
- [ ] 知道同一元素上
v-if优先于v-for,不能访问该循环的局部变量 - [ ] 知道 Vue 3 中数组变更检测没有 Vue 2 的限制
- [ ] 能用
<template v-for>渲染无包裹的多元素
🐞 防坑指南
| 坑 | 说明 | 正确做法 |
|---|---|---|
有状态列表不写 :key | 就地复用可能让局部状态与数据错位 | 用唯一且稳定的 id 作 key |
| 用 index 做 key | 删除/排序后索引变化 → 组件复用错误 | 用 todo.id 等业务标识 |
v-for + v-if 同一元素 | Vue 3 中 v-if 优先 → 无法读循环变量 | 用 <template v-for> 包裹 |
| 未检查索引是否存在 | 空数组上读取 arr[0].done 会报错 | 先检查该项;Vue 3 支持索引赋值 |
📐 最佳实践
- 过滤/排序用 computed:不要在
v-for中写复杂过滤,用computed预处理 - 大列表先测量:渲染节点过多造成卡顿时,再考虑分页或虚拟滚动
- key 选择:使用同级列表中唯一且稳定的原始值,例如 id;不要在渲染时重新生成随机 key
- 嵌套列表:按数据层级组织;内层有独立职责或模板难以维护时,再拆为子组件
Git 提交
git add .
git commit -m "L04: v-for 列表渲染 + key"🔗 钩子连接
→ 下一节:L05 · 条件渲染与事件
目前列表能展示、能添加,但不能完成和删除。L05 将引入:
v-if/v-show条件渲染@click事件绑定defineEmits子组件向父组件通信<TransitionGroup>组件实现列表删除动画