Skip to content

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> 上,避免无意义包裹元素

最小的稳定列表 ​

vue
<!-- 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 的事故现场 ​

vue
<!-- 不稳:删除第一项后,后面的输入 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 敲碎整棵树。

vue
<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、模板块:边角也要会 ​

vue
<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 留下的旧传闻。真正还需要关注的是:状态更新能否追踪与节点身份是否稳定是两件事。

先定义源数据,再定义展示顺序 ​

“所有联系人”是源数据;“只看收藏、按姓名排序、再取当前页”是展示规则。把两者混在一起,会让一个点击排序的动作永久重排源数组,也让“清除筛选后回到原顺序”变得不可解释。一个可靠的顺序是:源数据 → 筛选 → 排序 → 分页 → 模板。

vue
<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 顺序反推业务逻辑。

排错路线:排序后为什么输入跑到别人身上 ​

  1. 先检查展示数组。 筛选、搜索、分页和排序都应从源数组派生;在 computed 中意外调用原地 sort() 会先把数据顺序本身改掉。
  2. 再检查 key 的来源。 它是否来自实体的稳定 ID,还是过滤后的 index、临时随机数、可变标题?只要会变化或会重排,就不是身份。
  3. 检查局部 UI 状态。 失焦、展开项、正在输入的草稿属于 DOM/组件状态;用业务 ID 把它们与实体关联,别用数组位置关联。
  4. 最后测量规模。 key 正确不等于数千项列表就足够快。大集合先减少渲染项或虚拟化,再考虑渲染层优化。

翻车博物馆 ​

现象根因修复
排序后输入框文字跑到别项用 index 当 key,节点身份错位用稳定业务 ID
列表每次渲染都闪动/重建key 每次随机生成在创建实体时生成并保存 ID
过滤条件写在同一元素的 v-if作用域优先级不对且难维护computed 先得到展示数组
computed 一运行原列表顺序就变调用了原地 sort()toSorted() 或复制数组
v-for 没写 key 也“能跑”Vue 只能按位置猜动态列表始终提供 key,除非你能证明静态且无状态

练习:可排序的联系人簿 ​

创建 src/App.vue 与 src/components/ContactRow.vue。联系人输入固定为:

ts
[
  { 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 只是为了不报警告”,你能解释它还守护着什么状态。

用心记录每一步成长