06|列表渲染与 key:给每位演员身份证,别拿座位号冒充
第一章概览 / 06。目标:稳定渲染集合,理解
key决定的是节点身份与复用,不只是为了消除一条警告。
主例保存为 Vue 3.5.x + Vite TypeScript 项目的 src/App.vue。若目标浏览器不支持 toSorted(),把它替换为 [...array].sort(...);无论用哪种写法,验收条件都是“不原地改写源数组”。
你会遇到的集合,不止数组
| 数据源 | 写法 | 备注 |
|---|---|---|
| 数组 | v-for="item in items" | 最常用;第二个别名是索引 |
| 对象 | v-for="(value, key, index) in record" | 适合配置展示,别把对象属性顺序当业务排序协议 |
| 数字 | v-for="n in 5" | n 从 1 开始;适合骨架/星级等小范围生成 |
字符串、Map、Set 等可迭代对象 | v-for="item in source" | 遵循 JavaScript Iterable 协议;先设计清楚展示形状 |
| 多节点条目 | <template v-for="…" :key="…"> | key 放在 <template> 上,避免无意义包裹元素 |
最小的稳定列表
<!-- src/App.vue -->
<script setup lang="ts">
import { computed, ref } from 'vue'
type Task = { id: string; title: string; priority: 1 | 2 | 3; done: boolean }
const tasks = ref<Task[]>([
{ id: 't-7', title: '理解 key 的身份语义', priority: 1, done: false },
{ id: 't-8', title: '把 index 送回它该待的地方', priority: 2, done: false }
])
const hideDone = ref(false)
// 派生数组不改变源数据;toSorted 不会原地排序。
const visibleTasks = computed(() =>
tasks.value
.filter((task) => !hideDone.value || !task.done)
.toSorted((a, b) => a.priority - b.priority)
)
function removeTask(id: string) {
tasks.value = tasks.value.filter((task) => task.id !== id)
}
</script>
<template>
<label><input v-model="hideDone" type="checkbox" /> 隐藏已完成</label>
<ul>
<li v-for="task in visibleTasks" :key="task.id">
<label>
<input v-model="task.done" type="checkbox" />
{{ task.title }}(P{{ task.priority }})
</label>
<button type="button" @click="removeTask(task.id)">删除</button>
</li>
</ul>
</template>key 的真正职责:告诉 Vue“你还是不是你”
当列表插入、删除、排序或过滤时,Vue 会比较新旧 VNode。稳定的 key 让它知道某项对应的是哪一个既有 DOM 节点/组件实例,从而正确保留输入焦点、局部状态、动画位置和组件生命周期。
| key 的来源 | 是否推荐 | 理由 |
|---|---|---|
| 后端或领域实体的不可变 ID | 强烈推荐 | 项目内稳定、跨排序也不变 |
| 本地创建时生成的唯一 ID | 推荐 | 尚未落库时也能保持身份 |
| 数组 index | 仅静态、绝不重排的展示列表 | 插入/筛选/排序后“第 2 个”换人了,DOM 却以为没换 |
Math.random() 每次渲染生成 | 禁止 | 每次都告诉 Vue 全员换人,复用与状态全没了 |
| 整个对象 | 不推荐 | 身份依赖引用,序列化/重建后会变;使用 string/number ID 更清晰 |
看一眼 index 的事故现场
<!-- 不稳:删除第一项后,后面的输入 DOM 可能被错误复用到“下一位”。 -->
<li v-for="(contact, index) in contacts" :key="index">
<input v-model="contact.name" />
</li>
<!-- 稳:联系人带着自己的身份移动。 -->
<li v-for="contact in contacts" :key="contact.id">
<input v-model="contact.name" />
</li>深水区:key 管的是节点身份,不是数据更新开关
修改 tasks.value[0].done、替换整个数组或改变数组长度,在 Vue 3 中都可以被响应式系统追踪;key 并不负责“让列表更新”。它解决的是另一件事:当新旧列表都出现一项时,Vue 应该把哪一个旧 DOM 节点、子组件实例、焦点和局部状态交给它继续使用。
这也是为什么“让 key 每次随机”看似能修复一个 UI 问题,实则会制造更多问题:它强迫 Vue 把整项当新角色,输入选择区、展开状态、局部缓存与过渡位置都会被丢掉。若你只是想刷新数据,应更新数据;若你需要重置组件,应把重置意图设计成状态或受控的生命周期,而不是用随机 key 敲碎整棵树。
<script setup lang="ts">
import { ref } from 'vue'
type Draft = { id: string; title: string }
const drafts = ref<Draft[]>([])
function addDraft(title: string) {
// ID 在实体创建时生成并保存,而不是在模板渲染时生成。
drafts.value.push({ id: crypto.randomUUID(), title })
}
</script>
<template>
<article v-for="draft in drafts" :key="draft.id">{{ draft.title }}</article>
</template>对象、Map、模板块:边角也要会
<script setup lang="ts">
import { computed, ref } from 'vue'
const featureFlags = ref({ betaSearch: true, compactNav: false })
const scores = ref(new Map([['柚子', 95], ['团子', 88]]))
const enabledFlags = computed(() =>
Object.entries(featureFlags.value).filter(([, enabled]) => enabled)
)
</script>
<template>
<dl>
<template v-for="([name, enabled]) in enabledFlags" :key="name">
<dt>{{ name }}</dt>
<dd>{{ enabled ? '启用' : '关闭' }}</dd>
</template>
</dl>
<ul>
<!-- Map 的迭代项是 [key, value]。 -->
<li v-for="([name, score]) in scores" :key="name">{{ name }}:{{ score }}</li>
</ul>
</template>列表更新策略:先算展示数据,再渲染
filter()、map()、concat()、slice() 等不会修改原数组;把它们的结果放在 computed 中,模板就保持干净。sort()、reverse()、splice() 会修改数组;若要派生排序,用 toSorted() / toReversed()(或复制后再处理),别在计算属性里原地把源数据洗牌。
Vue 3 的数组索引赋值与 length 变化都是可追踪的;“必须用 splice 才能更新”是 Vue 2 留下的旧传闻。真正还需要关注的是:状态更新能否追踪与节点身份是否稳定是两件事。
先定义源数据,再定义展示顺序
“所有联系人”是源数据;“只看收藏、按姓名排序、再取当前页”是展示规则。把两者混在一起,会让一个点击排序的动作永久重排源数组,也让“清除筛选后回到原顺序”变得不可解释。一个可靠的顺序是:源数据 → 筛选 → 排序 → 分页 → 模板。
<script setup lang="ts">
import { computed, ref } from 'vue'
type Article = { id: string; title: string; starred: boolean; updatedAt: number }
const articles = ref<Article[]>([])
const starredOnly = ref(false)
const visibleArticles = computed(() => {
const filtered = articles.value.filter((article) => !starredOnly.value || article.starred)
return filtered.toSorted((a, b) => b.updatedAt - a.updatedAt)
})
</script>
<template>
<li v-for="article in visibleArticles" :key="article.id">{{ article.title }}</li>
</template>这不仅让模板容易读,也让测试容易写:你可以固定源数组,单独断言每个展示规则是否返回预期项目,而不必通过 DOM 顺序反推业务逻辑。
排错路线:排序后为什么输入跑到别人身上
- 先检查展示数组。 筛选、搜索、分页和排序都应从源数组派生;在
computed中意外调用原地sort()会先把数据顺序本身改掉。 - 再检查 key 的来源。 它是否来自实体的稳定 ID,还是过滤后的 index、临时随机数、可变标题?只要会变化或会重排,就不是身份。
- 检查局部 UI 状态。 失焦、展开项、正在输入的草稿属于 DOM/组件状态;用业务 ID 把它们与实体关联,别用数组位置关联。
- 最后测量规模。
key正确不等于数千项列表就足够快。大集合先减少渲染项或虚拟化,再考虑渲染层优化。
翻车博物馆
| 现象 | 根因 | 修复 |
|---|---|---|
| 排序后输入框文字跑到别项 | 用 index 当 key,节点身份错位 | 用稳定业务 ID |
| 列表每次渲染都闪动/重建 | key 每次随机生成 | 在创建实体时生成并保存 ID |
过滤条件写在同一元素的 v-if | 作用域优先级不对且难维护 | computed 先得到展示数组 |
computed 一运行原列表顺序就变 | 调用了原地 sort() | toSorted() 或复制数组 |
v-for 没写 key 也“能跑” | Vue 只能按位置猜 | 动态列表始终提供 key,除非你能证明静态且无状态 |
练习:可排序的联系人簿
创建 src/App.vue 与 src/components/ContactRow.vue。联系人输入固定为:
[
{ id: 'u-2', name: '北川', tags: ['TypeScript'], favorite: false },
{ id: 'u-1', name: '安娜', tags: ['Vue'], favorite: true },
{ id: 'u-3', name: '陈墨', tags: ['Vue', 'CSS'], favorite: true }
]ContactRow.vue 为每位联系人保存一个未提交备注草稿;App.vue 用 computed 完成“只看收藏”和姓名升/降序,源数组顺序必须始终是 u-2,u-1,u-3。先在 u-1 行输入备注 重点联系,再切换筛选与排序;草稿必须继续属于 u-1。
参考答案:查看第一章练习答案中的第 06 题。
验收清单:如何判断练习完成
- 筛选和排序来自
computed派生列表;页面显示的源 id 顺序始终为u-2,u-1,u-3。 - 改变排序或收藏条件后,
重点联系仍跟着u-1,证明子组件实例身份没有漂移。 - 每项都使用
:key="contact.id";你能说明数组 index 只能表示当前位置,不能表示业务身份。
通关自检:当有人说“key 只是为了不报警告”,你能解释它还守护着什么状态。