文章目录
-
- 一、初识 v-for:动态世界的基石
-
- 1.1 遍历数组:最常见的场景
-
- 1.1.1 基础语法:`item in items`
* 1.1.2 获取索引:`(item, index) in items`
* 1.1.3 嵌套循环:处理二维或多维数据
- 1.1.1 基础语法:`item in items`
- 1.2 遍历对象:探索属性的奥秘
-
- 1.2.1 基础语法:[
(value, key, index) in object\](#121%5Fvalue%5Fkey%5Findex%5Fin%5Fobject%5F200)
* 1.2.2 对象遍历的顺序
- 1.2.1 基础语法:[
- 1.3 遍历数字:生成固定序列
-
- 1.1 遍历数组:最常见的场景
- 二、核心机制:Key 的深度解析
-
- 2.1 为什么需要 Key:虚拟 DOM 与 Diff 算法
- 2.2 Key 的作用:为元素颁发“身份证”
- 2.3 如何选择一个好的 Key:稳定、唯一、可预测
- 2.4 Key 与组件:在 v-for 中使用组件
- 2.1 为什么需要 Key:虚拟 DOM 与 Diff 算法
- 三、高级技巧与最佳实践
-
- 3.1 v-for 与 v-if 的“爱恨情仇”
-
- 3.1.1 解决方案一:使用 [
<template>\标签](#311%5F%5Ftemplate%5F%5F594)
* 3.1.2 解决方案二:使用计算属性(最佳实践)
- 3.1.1 解决方案一:使用 [
- 3.2 数据变更的响应式陷阱与对策
-
- 3.2.1 数组的变更方法 vs 非变更方法
* 3.2.2 直接通过索引修改数组
* 3.2.3 直接修改数组长度
- 3.2.1 数组的变更方法 vs 非变更方法
- 3.3 列表过滤与排序的实战
- 3.4 性能优化:当列表变得巨大
-
- 3.4.1 什么是虚拟滚动?
* 3.4.2 实现虚拟滚动
- 3.4.1 什么是虚拟滚动?
-
- 3.1 v-for 与 v-if 的“爱恨情仇”
- 四、总结与回顾
一、初识 v-for:动态世界的基石
嘿,朋友!欢迎来到 Vue 3 列表渲染的世界。想象一下,你正在构建一个炫酷的社交媒体应用,用户的动态、评论列表、好友列表……这些内容都不是一成不变的,它们会随着时间、用户操作而动态增删改。如果每一条数据我们都要手动在 HTML 里写一份,那工作量将是灾难性的,而且完全无法应对数据的变化。
这时候,v-for 指令就像一位拥有“分身术”的魔法师,闪亮登场。它的核心使命就是:基于一组数据(比如一个数组或一个对象),帮你自动地、高效地重复渲染一段模板结构。你只需要告诉它“数据源是什么”以及“每一份‘分身’长什么样”,它就能为你生成一整排整齐划一的 DOM 元素。
官方文档对 v-for 的定义是:“v-for 指令基于一个数组来渲染一个列表。v-for 指令需要使用 item in items 形式的特殊语法,其中 items 是源数据数组,而 item 则是被迭代的数组元素的别名。”
这个定义非常精准,但我们可以用更通俗的话来理解。把 v-for 想象成一个勤劳的流水线工人:
items(数据源):就是传送带上待加工的零件。item(别名):是工人拿起每一个零件时,给这个零件起的一个临时代号,方便在加工过程中称呼它。v-for指令本身:就是那条“对传送带上每个零件都执行一遍加工动作”的命令。
通过这个比喻,你应该能感觉到 v-for 的本质:它是一个循环指令,将模板逻辑与数据紧密地绑定在一起。接下来,我们就从最基础的用法开始,一步步揭开它的神秘面纱。
1.1 遍历数组:最常见的场景
在 Web 开发中,我们遇到的数据结构,十有八九是数组。用户列表、商品列表、文章列表……无一例外。因此,掌握 v-for 遍历数组是基本功中的基本功。
1.1.1 基础语法:item in items
让我们从一个最简单的例子开始。假设我们有一个任务清单,我们想把它们渲染成一个无序列表。
1<script setup> 2import { ref } from 'vue'; 3 4// 准备一个包含任务对象的数组,作为我们的数据源 5// ref() 是 Vue 3 的 Composition API 中用来创建响应式数据的方法 6const tasks = ref([ 7 { id: 1, title: '学习 Vue 3 的 v-for' }, 8 { id: 2, title: '完成项目报告' }, 9 { id: 3, title: '晚上吃顿好的' } 10]); 11</script> 12 13<template> 14 <h2>我的任务清单</h2> 15 <ul> 16 <!-- 17 v-for 指令的核心语法: 18 - `task` 是我们为每个数组元素起的别名(临时变量名),你可以叫它 `item`, `t`, `taskItem` 等任何合法的变量名。 19 - `tasks` 是我们在 <script> 中定义的响应式数组。 20 - [`:key="task.id"`](https://xplanc.org/primers/document/zh/10.Bash/90.%E5%B8%AE%E5%8A%A9%E6%89%8B%E5%86%8C/EX.id.md) 是一个至关重要的属性,我们稍后会详细讲解。现在你只需要知道,给 v-for 生成的每个元素一个独一无二的 key 是一个好习惯。 21 --> 22 <li v-for="task in tasks" :key="task.id"> 23 <!-- 在这里,我们可以直接使用别名 `task` 来访问当前循环到的对象 --> 24 {{ task.title }} 25 </li> 26 </ul> 27</template> 28 29<style> 30/* 添加一点简单的样式,让列表更好看 */ 31ul { 32 list-style-type: none; 33 padding: 0; 34} 35li { 36 background-color: #f0f0f0; 37 margin: 5px 0; 38 padding: 10px; 39 border-radius: 4px; 40} 41</style> 42
代码剖析与解读:
- 数据准备:在
<script setup>中,我们使用ref创建了一个名为tasks的响应式数组。数组里的每个元素都是一个对象,包含id和title。ref的作用是让 Vue 能够追踪这个数组的变化,一旦数组内容改变(比如增加、删除、修改了某个任务),Vue 就会自动更新视图。 - 模板绑定:在
<template>部分,我们在<li>标签上写下了v-for="task in tasks"。- Vue 解析到这个指令时,会立刻去
tasks数组里取数据。 - 它会取出第一个元素
{ id: 1, title: '学习 Vue 3 的 v-for' },并将其赋值给我们定义的别名task。 - 然后,它会用这个
task对象去渲染<li>标签及其内部内容。此时,{{ task.title }}就会被替换成 “学习 Vue 3 的 v-for”。 - 接着,Vue 重复这个过程,取出第二个元素,渲染第二个
<li>;取出第三个元素,渲染第三个<li>……直到遍历完整个数组。
- Vue 解析到这个指令时,会立刻去
- 结果呈现:最终,浏览器会得到一个包含三个
<li>元素的<ul>列表,每个<li>都显示着对应的任务标题。
这就是 v-for 最基础、最核心的工作流程。它就像一个翻译官,把 JavaScript 的数组数据“翻译”成了对应的 HTML 结构。
1.1.2 获取索引:(item, index) in items
在实际开发中,我们常常需要知道当前正在处理的是第几个元素。比如,显示排名、序号,或者在某些特定操作中需要用到索引。v-for 也贴心地提供了获取索引的方式。
语法上,我们只需要在别名后面增加一个变量,用括号括起来即可:v-for="(task, index) in tasks"。
让我们改造一下上面的例子,给每个任务加上序号。
1<script setup> 2import { ref } from 'vue'; 3 4const tasks = ref([ 5 { id: 1, title: '学习 Vue 3 的 v-for' }, 6 { id: 2, title: '完成项目报告' }, 7 { id: 3, title: '晚上吃顿好的' } 8]); 9</script> 10 11<template> 12 <h2>我的任务清单(带序号)</h2> 13 <ul> 14 <!-- 15 语法升级: 16 - `task` 依然是元素的别名。 17 - `index` 是当前元素在数组中的索引,从 0 开始计数。 18 --> 19 <li v-for="(task, index) in tasks" :key="task.id"> 20 <!-- 21 我们现在可以在模板中同时使用 `task` 和 `index` 了。 22 注意:索引是从 0 开始的,所以通常我们会显示 `index + 1` 来作为人类习惯的序号。 23 --> 24 任务 #{{ index + 1 }}: {{ task.title }} 25 </li> 26 </ul> 27</template> 28 29<style> 30/* ... 样式保持不变 ... */ 31</style> 32
代码剖析与解读:
- 参数顺序:
(item, index)的顺序是固定的。第一个参数永远是数组元素的值,第二个参数才是索引。你当然可以叫它们别的名字,比如(val, idx),但位置不能错。 - 索引的起点:请务必记住,JavaScript 中的数组索引是从
0开始的。所以第一个元素的index是0,第二个是1,以此类推。这就是为什么我们在显示序号时使用了index + 1。 - 应用场景:索引虽然好用,但也要谨慎使用,尤其是在作为
key时,我们后面会重点讨论。它的常见合法用途包括:- 显示序号(如本例)。
- 在没有唯一 ID 的情况下,作为临时的、不稳定的标识符(不推荐用于
key)。 - 在某些计算或逻辑判断中需要用到位置信息。
1.1.3 嵌套循环:处理二维或多维数据
世界是复杂的,数据也是。我们经常会遇到“嵌套”的数据结构,比如一个分类列表,每个分类下又有一堆商品。这时,就需要用到嵌套的 v-for。
假设我们要渲染一个电影分类列表,每个分类下有多部电影。
1<script setup> 2import { ref } from 'vue'; 3 4// 这是一个嵌套数组,外层数组是分类,内层数组是电影 5const categories = ref([ 6 { 7 id: 'cat1', 8 name: '科幻片', 9 movies: [ 10 { id: 'm1', title: '星际穿越' }, 11 { id: 'm2', title: '盗梦空间' } 12 ] 13 }, 14 { 15 id: 'cat2', 16 name: '喜剧片', 17 movies: [ 18 { id: 'm3', title: '功夫' }, 19 { id: 'm4', title: '大话西游' } 20 ] 21 } 22]); 23</script> 24 25<template> 26 <h1>电影分类</h1> 27 <div v-for="category in categories" :key="category.id"> 28 <h2>{{ category.name }}</h2> 29 <ul> 30 <!-- 31 这是一个嵌套的 v-for。 32 外层循环遍历 `categories`,别名是 `category`。 33 内层循环遍历当前 `category` 对象的 `movies` 数组,别名是 `movie`。 34 注意作用域:内层循环可以访问外层循环的 `category` 变量,但外层循环无法访问内层的 `movie`。 35 --> 36 <li v-for="movie in category.movies" :key="movie.id"> 37 {{ movie.title }} 38 </li> 39 </ul> 40 </div> 41</template> 42 43<style> 44/* ... 略 ... */ 45</style> 46
代码剖析与解读:
- 数据结构:
categories是一个数组,每个元素是一个对象,代表一个分类。这个分类对象自身又包含一个movies数组。 - 外层循环:
v-for="category in categories"负责遍历所有的分类。它为每个分类生成一个<div>容器,并显示分类的名称<h2>{{ category.name }}</h2>。 - 内层循环:在
<div>内部,我们又写了一个v-for="movie in category.movies"。这个指令的作用是,针对当前正在处理的category,去遍历它的movies数组。 - 作用域链:这里有一个非常重要的概念——作用域。内层
v-for的模板(即<li>及其内部)可以访问到它自己定义的movie变量,同时也能“继承”或访问到外层v-for定义的category变量。这就是为什么我们可以直接写category.movies。反之,在外层v-for的模板中(比如<h2>标签那里),你是无法访问到movie变量的,因为它还没被定义。 - Key 的层级:请注意
:key的使用。外层循环的key使用了分类的id(category.id),内层循环的key使用了电影的id(movie.id)。这确保了在任何一个层级的数据发生变化时,Vue 都能准确地找到需要更新的 DOM 元素。
嵌套循环是处理复杂数据结构的利器,只要你理解了作用域的概念,就可以像套娃一样一层一层地处理下去。
1.2 遍历对象:探索属性的奥秘
v-for 不仅能遍历数组,还能遍历一个对象的所有属性。这在处理一些配置信息、用户资料等键值对数据时非常有用。
1.2.1 基础语法:(value, key, index) in object
遍历对象的语法稍微复杂一点,因为它可以同时获取到三个值:属性值、属性名和索引。
value: 属性的值。key: 属性的名(字符串)。index: 索引,从 0 开始,表示是第几个属性。
让我们用一个用户信息对象来演示。
1<script setup> 2import { reactive } from 'vue'; 3 4// 使用 reactive 创建一个响应式对象 5// reactive 和 ref 类似,但专门用于对象和数组 6const userProfile = reactive({ 7 name: '张三', 8 age: 28, 9 city: '北京', 10 occupation: '前端工程师' 11}); 12</script> 13 14<template> 15 <h2>用户资料</h2> 16 <table border="1" style="border-collapse: collapse; width: 300px;"> 17 <thead> 18 <tr> 19 <th>属性名</th> 20 <th>属性值</th> 21 </tr> 22 </thead> 23 <tbody> 24 <!-- 25 遍历对象的语法: 26 - `value` 是属性值 (e.g., '张三') 27 - `key` 是属性名 (e.g., 'name') 28 - `index` 是索引 (e.g., 0, 1, 2...) 29 --> 30 <tr v-for="(value, key, index) in userProfile" :key="key"> 31 <td>{{ key }}</td> 32 <td>{{ value }}</td> 33 </tr> 34 </tbody> 35 </table> 36</template> 37
代码剖析与解读:
- 数据准备:这里我们使用了
reactive而不是ref。对于对象类型的数据,reactive更为直接,它会将对象的深层属性都变为响应式的。ref也可以,但访问时需要.value(userProfile.value.name),reactive则可以直接访问(userProfile.name)。 - 模板遍历:
v-for="(value, key, index) in userProfile"展开了userProfile对象。- 第一次循环:
value是'张三',key是'name',index是0。 - 第二次循环:
value是28,key是'age',index是1。 - ……以此类推。
- 第一次循环:
- 参数顺序:和数组遍历一样,参数顺序是固定的。
(value, key, index)。你也可以只获取前两个:v-for="(value, key) in userProfile",或者只获取值:v-for="value in userProfile"。 - Key 的选择:在遍历对象时,使用
key(属性名)作为:key的值是一个非常自然且稳定的选择,因为对象的属性名通常是唯一的。
1.2.2 对象遍历的顺序
一个有趣的知识点是:JavaScript 对象的属性遍历顺序是怎样的?在 ES2015 (ES6) 之前,这个顺序并没有被标准化,不同引擎的实现可能不同。但从 ES2015 开始,规范规定了 Object.getOwnPropertyNames()、Reflect.ownKeys() 等方法以及 for...in 循环的属性遍历顺序:
- 首先遍历所有数值键,按数值升序排列。
- 然后遍历所有字符串键,按加入时间升序排列。
- 最后遍历所有 Symbol 键,按加入时间升序排列。
Vue 的 v-for 在遍历对象时,其底层实现依赖于 JavaScript 的 for...in 循环(或类似的机制),所以它也遵循这个顺序。
看个例子:
1<script setup> 2import { reactive } from 'vue'; 3 4const weirdObject = reactive({ 5 '1': '数字键1', 6 name: '字符串键name', 7 '3': '数字键3', 8 age: '字符串键age', 9 '2': '数字键2' 10}); 11</script> 12 13<template> 14 <h2>对象属性遍历顺序演示</h2> 15 <ul> 16 <li v-for="(value, key) in weirdObject" :key="key"> 17 Key: {{ key }}, Value: {{ value }} 18 </li> 19 </ul> 20 <!-- 21 最终渲染顺序会是: 22 Key: 1, Value: 数字键1 23 Key: 2, Value: 数字键2 24 Key: 3, Value: 数字键3 25 Key: name, Value: 字符串键name 26 Key: age, Value: 字符串键age 27 --> 28</template> 29
了解这个顺序可以帮助你预测 v-for 的渲染结果,避免因顺序问题产生困惑。不过,在绝大多数业务场景中,我们并不需要过分依赖这个顺序,如果顺序很重要,通常会用数组来组织数据。
1.3 遍历数字:生成固定序列
有时候,我们只是想简单地重复渲染某个元素 N 次,比如生成一个 5 星评分组件的星星,或者创建一个分页器的页码按钮。这时,v-for 也可以遍历一个数字。
语法非常简单:v-for="n in count"。这里的 n 会从 1 开始,一直到 count。
注意: 这和数组索引从 0 开始不同,遍历数字时,变量是从 1 开始的。这是一个需要特别留意的“小陷阱”。
1<script setup> 2import { ref } from 'vue'; 3 4const rating = ref(4); // 假设当前评分是 4 星 5const maxRating = 5; // 满分是 5 星 6</script> 7 8<template> 9 <h2>评分组件</h2> 10 <div class="rating"> 11 <!-- 12 遍历数字 1 到 5。 13 `star` 的值会依次是 1, 2, 3, 4, 5。 14 --> 15 <span 16 v-for="star in maxRating" 17 :key="star" 18 class="star" 19 :class="{ filled: star <= rating }" 20 > 21 <!-- 使用 Unicode 字符作为星星 --> 22 ★ 23 </span> 24 </div> 25</template> 26 27<style> 28.rating { 29 font-size: 2rem; 30} 31.star { 32 color: #ccc; /* 默认灰色 */ 33} 34.star.filled { 35 color: #f5c518; /* 激活的金色 */ 36} 37</style> 38
代码剖析与解读:
- 循环逻辑:
v-for="star in maxRating"会循环 5 次。第一次,star是1;第二次,star是2…… - 动态 Class:我们使用了 Vue 的动态 Class 绑定
:class="{ filled: star <= rating }"。 - Key 的使用:这里我们直接用循环变量
star作为key,因为在这个场景下,star(1, 2, 3, 4, 5) 是固定且唯一的,非常适合做key。
这个例子完美地展示了 v-for 在处理固定序列时的简洁与强大。
二、核心机制:Key 的深度解析
如果说 v-for 是列表渲染的“术”,那么 :key 就是其中的“道”。理解 :key 的工作原理,是从 Vue 新手走向进阶的关键一步。很多关于列表渲染的诡异 bug,比如输入框内容错乱、动画状态异常,其根源都与 :key 有关。
2.1 为什么需要 Key:虚拟 DOM 与 Diff 算法
要理解 :key,我们必须先简单了解一下 Vue 底层的两个核心概念:虚拟 DOM (Virtual DOM) 和 Diff 算法。
- 虚拟 DOM (VDOM):你可以把它想象成真实 DOM 的一个轻量级的 JavaScript “蓝图”或“副本”。每当数据变化时,Vue 不会立即去操作真实的、昂贵的 DOM 元素。相反,它会先在内存中根据新数据重新生成一个新的虚拟 DOM 树。
- Diff 算法:当新的虚拟 DOM 树生成后,Vue 会用 Diff 算法去比较“新旧”两个虚拟 DOM 树的差异。这个算法非常高效,它能精确地找出哪些地方发生了变化(比如一个文本变了、一个元素被删除了、一个元素的顺序变了)。
- 打补丁:最后,Vue 只把这些差异应用到真实的 DOM 上,这个过程就叫“打补丁”。这样就避免了大规模、低效的 DOM 操作,大大提升了性能。
现在,:key 登场了。当 v-for 渲染列表时,Diff 算法面临一个难题:当列表数据发生变化(比如排序、增删)时,如何高效地更新对应的真实 DOM 元素?
如果没有 :key,Vue 会采用一种“就地复用”的策略。它会尽可能地复用已有的 DOM 元素,只修改它们的内容。这种策略在某些情况下是高效的,但在另一些情况下则会引发灾难。
让我们用一个经典的例子来感受一下这个“灾难”。
1<script setup> 2import { ref } from 'vue'; 3 4const items = ref([ 5 { id: 1, text: '项目 A' }, 6 { id: 2, text: '项目 B' }, 7 { id: 3, text: '项目 C' } 8]); 9 10const shuffle = () => { 11 // 打乱数组顺序 12 items.value = items.value.sort(() => Math.random() - 0.5); 13}; 14</script> 15 16<template> 17 <button @click="shuffle">打乱顺序</button> 18 <ul> 19 <!-- 注意:这里我们故意不加 :key --> 20 <li v-for="item in items"> 21 {{ item.text }} 22 <input type="text" placeholder="在这里输入..." /> 23 </li> 24 </ul> 25</template> 26
操作与现象:
- 在页面加载后,你在第一个输入框里输入一些内容,比如“我是A的输入”。
- 点击“打乱顺序”按钮。
- 你会发现,
<li>标签里的文本(项目 A, B, C)确实被打乱了,但是你输入的内容“我是A的输入”却留在了原来的第一个<li>位置,而不是跟着“项目 A”一起移动。现在它可能和“项目 C”或“项目 B”配对在了一起。
原理剖析:
这就是“就地复用”的副作用。
- 没有 Key 时:当
items数组顺序改变后,Vue 的 Diff 算法看到新旧列表都有 3 个<li>。为了高效,它决定不销毁和重新创建<li>元素,而是复用它们。 - 它会拿着旧的
items(A, B, C) 和新的items(比如C, A, B) 按位置一一比较:- 旧的第 1 个
<li>(内容是 “项目 A”) vs 新的第 1 个<li>(内容应该是 “项目 C”)。算法发现文本不同,于是只修改了文本内容,把 “项目 A” 改成了 “项目 C”。但是,它完全没动这个<li>内部的<input>元素!你输入的内容还好好地待在里面。 - 旧的第 2 个
<li>(内容是 “项目 B”) vs 新的第 2 个<li>(内容应该是 “项目 A”)。修改文本为 “项目 A”,<input>状态保留。 - 旧的第 3 个
<li>(内容是 “项目 C”) vs 新的第 3 个<li>(内容应该是 “项目 B”)。修改文本为 “项目 B”,<input>状态保留。
- 旧的第 1 个
Vue 的算法只关心“同一位置的元素内容是否变了”,它不知道“哪个元素是哪个”。这就导致了“魂不附体”的现象。
2.2 Key 的作用:为元素颁发“身份证”
:key 的出现,就是为了解决这个身份识别问题。它给 v-for 生成的每一个元素提供了一个独一无二的标识,就像一张身份证。
当我们给 v-for 加上 :key="item.id" 后,Diff 算法的工作方式就变了:
- 有 Key 时:算法不再按位置比较,而是按
key比较。 - 它会拿着旧列表的
key集合 ([1, 2, 3]) 和新列表的key集合 (比如打乱后是[3, 1, 2]) 进行比对。 - 算法发现:
在这个过程中,DOM 元素、它们的状态(包括 <input> 的内容)以及它们上面的事件监听器,都会被完整地保留和移动。文本内容自然也会跟着正确的元素一起走。
现在,我们把 :key 加回去,再试一次:
1<script setup> 2// ... script 部分不变 ... 3</script> 4 5<template> 6 <button @click="shuffle">打乱顺序</button> 7 <ul> 8 <!-- 9 添加了 :key="item.id" 10 现在,每个 <li> 都有了一个独一无二的、与数据绑定的身份标识。 11 --> 12 <li v-for="item in items" :key="item.id"> 13 {{ item.text }} 14 <input type="text" placeholder="在这里输入..." /> 15 </li> 16 </ul> 17</template> 18
操作与现象:
- 在第一个输入框(对应“项目 A”)输入“我是A的输入”。
- 点击“打乱顺序”。
- 奇迹发生了! 你会发现,“项目 A” 这行文本,连同你输入的“我是A的输入”,像一个整体一样,一起移动到了新的位置。数据和它的状态完美地绑定在了一起。
这就是 :key 的魔力。它告诉 Vue:“别傻傻地按位置复用了,请根据这个 key 来识别、移动、创建或销毁元素,确保每个数据项都能找到它自己的 DOM 宿主。”
2.3 如何选择一个好的 Key:稳定、唯一、可预测
既然 :key 这么重要,那我们应该用什么值来做 key 呢?这里有几个黄金法则:
- 唯一性:
key必须在当前列表的兄弟元素之间是唯一的。你不能用item.text作为key,万一有两个项目的文本相同,key就重复了,会引发警告和不可预期的行为。 - 稳定性:
key应该是与数据本身绑定的、不会随数据内容或索引变化而变化的值。这就是为什么强烈推荐使用后端返回的唯一 ID(如item.id)。因为无论项目内容如何变化,它的 ID 是不变的。 - 避免使用
index作为key:这是一个非常常见的错误,尤其是在初学者中。v-for="(item, index) in items" :key="index"看起来很方便,但它几乎和不加key一样糟糕。
为什么不能用index?
因为index是不稳定的。当列表发生增删或排序时,每个元素的index都会改变。这又回到了“就地复用”的老问题。- 场景一:列表排序
* 原列表:A(index:0),B(index:1),C(index:2)
* 排序后:C(index:0),A(index:1),B(index:2)
* Diff 算法看到key=0的元素内容从 A 变成了 C,它只会修改文本,而不是移动元素。key失去了身份识别的意义。 - 场景二:列表头部插入
* 原列表:A(key:0),B(key:1)
* 在头部插入X后:X(key:0),A(key:1),B(key:2)
* Diff 算法比较:
*key=0:旧 A -> 新 X,修改文本。
*key=1:旧 B -> 新 A,修改文本。
*key=2:新增 B,创建新元素。
* 结果:Vue 错误地把 A 的内容更新成了 X,把 B 的内容更新成了 A,最后又创建了一个新的 B。这导致了大量不必要的 DOM 操作,并且同样会引发状态错乱。
什么时候index可以用?
只有在一种极其罕见的情况下可以:你的列表是静态的,永远不会被重新排序、过滤或在中间增删元素。比如,你只是用它来渲染 5 个固定的导航选项。但即便如此,使用一个有意义的key依然是更好的实践。
- 场景一:列表排序
- 不要使用随机数或时间戳:
key="Math.random()"或key="Date.now()"。这会导致每次渲染,key都是一个全新的值。Vue 会认为这是一个全新的元素,从而销毁旧的,创建新的。这会完全破坏组件的复用,导致性能急剧下降,并且所有状态都会丢失。
最佳实践总结表:
| Key 的选择 | 推荐度 | 原因分析 |
|---|---|---|
| item.id (唯一且稳定的ID) | ⭐⭐⭐⭐⭐ (强烈推荐) | 完美满足唯一性和稳定性,是 key 的最佳选择。 |
| item (对象本身) | ⭐⭐⭐ (谨慎使用) | 如果对象本身是唯一的且引用不变,可以。但如果对象内容变化,Vue 可能无法识别。不如 id 明确。 |
| index (数组索引) | ⭐ (强烈不推荐) | 不稳定,列表变化时会导致错误的 DOM 更新和状态错乱。 |
| 随机数/时间戳 | ⭐ (绝对禁止) | 每次都变,导致组件被频繁销毁和重建,性能灾难。 |
2.4 Key 与组件:在 v-for 中使用组件
当 v-for 用于渲染自定义组件时,key 的使用变得更加重要,并且有一个需要特别注意的细节。
假设我们有一个 TodoItem.vue 组件:
1<!-- TodoItem.vue --> 2<script setup> 3defineProps({ 4 todo: { 5 type: Object, 6 required: true 7 } 8}); 9</script> 10 11<template> 12 <li>{{ todo.text }}</li> 13</template> 14
现在我们在父组件中使用它:
1<!-- ParentComponent.vue --> 2<script setup> 3import { ref } from 'vue'; 4import TodoItem from './TodoItem.vue'; 5 6const todos = ref([ 7 { id: 1, text: '学习 v-for' }, 8 { id: 2, text: '学习 key' } 9]); 10</script> 11 12<template> 13 <ul> 14 <!-- 15 在组件上使用 v-for,:key 必须直接写在 <TodoItem> 组件标签上。 16 这样 Vue 才能正确地将 key 与组件实例关联起来。 17 --> 18 <TodoItem 19 v-for="todo in todos" 20 :key="todo.id" 21 :todo="todo" 22 /> 23 </ul> 24</template> 25
关键点:
key的位置:key必须直接设置在<TodoItem>组件标签上,而不是组件内部的模板根元素上(比如TodoItem.vue里的<li>)。这是因为key是v-for指令用来识别外部循环中每个“项”的,它需要绑定到v-for直接作用的那个元素或组件上。- Props 传递:我们通过
:todo="todo"将当前循环到的todo对象作为 prop 传递给了TodoItem组件。这是父子组件通信的标准方式。
当 todos 列表变化时,Vue 会根据 todo.id 这个 key 来决定是复用、移动、更新还是销毁哪个 TodoItem 组件实例。这确保了组件内部的状态(如果有的话)能够被正确地保留。
三、高级技巧与最佳实践
掌握了基础和核心机制后,我们来聊聊一些更高级的话题,这些技巧能让你在实战中写出更高效、更健壮、更优雅的代码。
3.1 v-for 与 v-if 的“爱恨情仇”
在 Vue 2 中,当 v-for 和 v-if 用在同一个元素上时,v-for 的优先级更高。这导致 v-if 无法访问到 v-for 的变量,是一个常见的错误。
在 Vue 3 中,这个情况发生了逆转。v-if 的优先级现在高于 v-for。这意味着,如果你把它们写在同一个元素上,v-if 将无法访问到 v-for 中的变量(如 item),因为 v-if 先执行,此时 item 还没有被定义。
1<!-- 错误示范!在 Vue 3 中会报错 --> 2<li v-for="user in users" :key="user.id" v-if="user.isActive"> 3 {{ user.name }} 4</li> 5
这段代码会抛出一个错误,类似于 Property "user" was accessed during render but is not defined on instance。因为 Vue 会先尝试计算 v-if="user.isActive",但此时它根本不知道 user 是什么。
那么,如何实现“只渲染列表中满足条件的项”呢?
3.1.1 解决方案一:使用 <template> 标签
<template> 标签是一个不可见的包裹元素,它不会在最终的 DOM 中渲染成任何实际标签,但可以作为指令的载体。我们可以把 v-for 放在 <template> 上,然后把 v-if 放在内部的元素上。
1<script setup> 2import { ref } from 'vue'; 3 4const users = ref([ 5 { id: 1, name: 'Alice', isActive: true }, 6 { id: 2, name: 'Bob', isActive: false }, 7 { id: 3, name: 'Charlie', isActive: true } 8]); 9</script> 10 11<template> 12 <h2>活跃用户列表 (使用 template)</h2> 13 <ul> 14 <!-- 15 1. v-for 指令作用在 <template> 标签上,负责循环。 16 2. <template> 内部的 <li> 元素会被重复渲染。 17 3. v-if 指令作用在 <li> 上,此时它可以访问到外层 v-for 定义的 `user` 变量。 18 --> 19 <template v-for="user in users" :key="user.id"> 20 <li v-if="user.isActive"> 21 {{ user.name }} 22 </li> 23 </template> 24 </ul> 25</template> 26
优点:
- 语法直观,逻辑清晰。
- 解决了
v-if无法访问v-for变量的问题。
缺点:
- 性能问题:这种方式仍然有性能开销。Vue 会遍历整个
users数组,为每一个user都创建一个<li>的虚拟节点,然后才通过v-if判断是否要渲染它。如果列表很大,而满足条件的元素很少,这就造成了不必要的计算。
3.1.2 解决方案二:使用计算属性(最佳实践)
更推荐、性能更好的方法是使用计算属性。我们可以在 JavaScript 代码中预先对数组进行过滤,然后 v-for 只需要遍历过滤后的结果即可。
1<script setup> 2import { ref, computed } from 'vue'; 3 4const users = ref([ 5 { id: 1, name: 'Alice', isActive: true }, 6 { id: 2, name: 'Bob', isActive: false }, 7 { id: 3, name: 'Charlie', isActive: true } 8]); 9 10// 创建一个计算属性 `activeUsers` 11// 它会根据 `users` 的变化自动重新计算 12const activeUsers = computed(() => { 13 // 使用数组的 filter 方法,返回一个只包含活跃用户的新数组 14 return users.value.filter(user => user.isActive); 15}); 16</script> 17 18<template> 19 <h2>活跃用户列表 (使用计算属性)</h2> 20 <ul> 21 <!-- 22 v-for 现在只需要遍历 `activeUsers`,这个数组已经是过滤好的结果。 23 模板变得非常干净,没有任何条件判断逻辑。 24 --> 25 <li v-for="user in activeUsers" :key="user.id"> 26 {{ user.name }} 27 </li> 28 </ul> 29</template> 30
优点:
- 性能卓越:计算属性是基于它们的响应式依赖进行缓存的。只有当
users数组发生变化时,activeUsers才会重新计算。v-for遍历的是一个更小的、已经过滤好的数组,大大减少了循环次数和 DOM 节点的创建。 - 关注点分离:将数据过滤的逻辑(JavaScript)和视图渲染的逻辑(HTML)清晰地分离开。模板只负责展示,业务逻辑放在
<script>中,代码更易维护。 - 可复用性:
activeUsers这个计算属性可以在模板的多个地方被复用,而无需重复编写过滤逻辑。
结论:永远优先使用计算属性来处理列表的过滤、排序等逻辑。这是 Vue 开发中最重要的最佳实践之一。只有在极少数无法使用计算属性的场景下,才考虑 <template> 标签方案。
3.2 数据变更的响应式陷阱与对策
Vue 的响应式系统非常强大,但它也有一些“规则”。如果你不按规则来修改数据,Vue 可能无法检测到变化,从而导致视图不更新。这在 v-for 中尤其需要注意。
3.2.1 数组的变更方法 vs 非变更方法
Vue 能够侦测到数组的一些“变更方法”调用,因为这些方法会原地修改数组。
- 变更方法:
push(),pop(),shift(),unshift(),splice(),sort(),reverse()。
当你调用这些方法时,Vue 会知道数组变了,并自动触发视图更新。 - 非变更方法:
filter(),concat(),slice()。
这些方法不会修改原数组,而是返回一个新数组。如果你直接调用它们,Vue 是不知道变化的。
错误示范:
1// 假设 `items` 是一个 ref 数组 2// 这样做是无效的!因为 filter 返回了一个新数组,但我们没有用它来替换旧的数组。 3items.value.filter(item => item.id !== targetId); 4
正确做法:
1// 将 filter 返回的新数组赋值给原来的 ref 2items.value = items.value.filter(item => item.id !== targetId); 3
通过重新赋值,我们改变了 items.value 这个 ref 的引用,Vue 的响应式系统就能捕捉到这个变化,并更新视图。
数组变更方法与非变更方法对比表:
| 方法类型 | 方法列表 | 特点 | Vue 响应式行为 | 使用示例 |
|---|---|---|---|---|
| 变更方法 | push, pop, shift, unshift, splice, sort, reverse | 原地修改原数组 | 自动触发更新 | items.value.push(newItem) |
| 非变更方法 | filter, concat, slice | 返回一个新数组,不修改原数组 | 不会自动触发更新 | items.value = items.value.filter(...) |
3.2.2 直接通过索引修改数组
JavaScript 中,我们可以直接通过索引来修改数组元素:arr[0] = 'new value'。然而,这种操作在 Vue 2 中不是响应式的,在 Vue 3 中也有限制。
虽然在 Vue 3 中,由于使用了 Proxy,直接 items.value[0] = ... 在大多数情况下是响应式的,但这仍然不被推荐,因为它存在一些边缘情况和性能上的考量。更可靠、更明确的方式是使用 splice。
场景: 将索引为 1 的元素替换为新对象。
推荐做法:使用 splice
1// 从索引 1 开始,删除 1 个元素,并插入 `newItem` 2items.value.splice(1, 1, newItem); 3
splice 是一个变更方法,Vue 完全可以追踪到它,并且这种方式意图明确,代码可读性更好。
3.2.3 直接修改数组长度
同样,直接修改数组的 length 属性(arr.length = newLength)也不是响应式的。
场景: 将数组长度清零。
推荐做法: 直接赋值一个空数组。
1items.value = []; 2
或者使用 splice(0)。
1items.value.splice(0); 2
这两种方式都能被 Vue 正确地侦测到。
总结: 当你需要修改数组时,优先使用 Vue 的变更方法。如果需要通过索引或长度来修改,请使用 splice 或直接替换整个数组引用。这样可以确保你的数据变化总能如实地反映在视图上。
3.3 列表过滤与排序的实战
结合计算属性,我们可以非常优雅地实现列表的搜索过滤和动态排序功能。
让我们构建一个完整的例子:一个可以搜索、可以按不同字段排序的用户列表。
1<script setup> 2import { ref, computed } from 'vue'; 3 4const users = ref([ 5 { id: 1, name: 'Alice', age: 25, city: 'New York' }, 6 { id: 2, name: 'Bob', age: 30, city: 'Paris' }, 7 { id: 3, name: 'Charlie', age: 22, city: 'London' }, 8 { id: 4, name: 'David', age: 35, city: 'New York' } 9]); 10 11// 搜索关键词 12const searchQuery = ref(''); 13// 排序依据的字段 14const sortKey = ref('name'); // 默认按 name 排序 15// 排序顺序 (asc: 升序, desc: 降序) 16const sortOrder = ref('asc'); 17 18// 创建一个计算属性,它会根据搜索和排序条件返回处理后的列表 19const processedUsers = computed(() => { 20 // 1. 创建一个数组的副本,避免直接修改原数据 21 let result = [...users.value]; 22 23 // 2. 过滤 24 if (searchQuery.value) { 25 const query = searchQuery.value.toLowerCase(); 26 result = result.filter(user => { 27 return user.name.toLowerCase().includes(query) || 28 user.city.toLowerCase().includes(query); 29 }); 30 } 31 32 // 3. 排序 33 if (sortKey.value) { 34 result.sort((a, b) => { 35 let aValue = a[sortKey.value]; 36 let bValue = b[sortKey.value]; 37 38 // 简单的字符串或数字比较 39 if (aValue < bValue) return sortOrder.value === 'asc' ? -1 : 1; 40 if (aValue > bValue) return sortOrder.value === 'asc' ? 1 : -1; 41 return 0; 42 }); 43 } 44 45 return result; 46}); 47 48// 切换排序顺序的函数 49function toggleSort(key) { 50 if (sortKey.value === key) { 51 // 如果点击的是当前排序列,则切换升降序 52 sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'; 53 } else { 54 // 如果点击的是新列,则设置为该列,并默认为升序 55 sortKey.value = key; 56 sortOrder.value = 'asc'; 57 } 58} 59</script> 60 61<template> 62 <div> 63 <h2>用户列表管理</h2> 64 65 <!-- 搜索框 --> 66 <input 67 type="text" 68 v-model="searchQuery" 69 placeholder="搜索姓名或城市..." 70 > 71 72 <!-- 排序按钮 --> 73 <div class="sort-buttons"> 74 <button @click="toggleSort('name')"> 75 按姓名排序 {{ sortKey === 'name' ? (sortOrder === 'asc' ? '↑' : '↓') : '' }} 76 </button> 77 <button @click="toggleSort('age')"> 78 按年龄排序 {{ sortKey === 'age' ? (sortOrder === 'asc' ? '↑' : '↓') : '' }} 79 </button> 80 </div> 81 82 <!-- 用户列表 --> 83 <ul> 84 <!-- v-for 只负责渲染最终处理好的数据 --> 85 <li v-for="user in processedUsers" :key="user.id"> 86 {{ user.name }} ({{ user.age }}) - {{ user.city }} 87 </li> 88 </ul> 89 </div> 90</template> 91 92<style> 93/* ... 略 ... */ 94</style> 95
代码剖析:
- 数据驱动:我们定义了三个响应式变量:
users(原始数据)、searchQuery(搜索条件)、sortKey和sortOrder(排序条件)。 - 计算属性
processedUsers:这是整个功能的核心。- 它首先复制了原始数据,这是一个好习惯,可以避免污染源数据。
- 然后,它检查
searchQuery是否有值,如果有,就使用filter方法进行过滤。 - 接着,它使用
sort方法根据sortKey和sortOrder对结果进行排序。 - 这个计算属性会自动缓存。只有当
users,searchQuery,sortKey,sortOrder中任何一个发生变化时,它才会重新执行。
- 模板的纯粹性:模板部分非常干净。
v-model="searchQuery"将输入框和搜索词绑定。点击按钮会调用toggleSort函数来改变排序条件。v-for="user in processedUsers"则只负责渲染最终的结果。 - 逻辑与视图分离:所有的业务逻辑(过滤、排序)都被封装在了
processedUsers这个计算属性中。模板只负责展示和用户交互,职责清晰,易于测试和维护。
这个例子充分展示了计算属性在处理复杂列表逻辑时的强大威力。
3.4 性能优化:当列表变得巨大
当你的列表有成百上千甚至上万个条目时,即使 Vue 的 Diff 算法再高效,一次性创建和渲染这么多的 DOM 节点也会导致页面卡顿,尤其是在移动设备上。
这时,就需要引入“虚拟滚动”技术。
3.4.1 什么是虚拟滚动?
虚拟滚动的核心思想是:只渲染可视区域内的列表项,以及可视区域上下少量缓冲区的列表项。
当用户滚动列表时,动态地计算哪些项应该进入可视区域,哪些应该离开,并只更新这些必要的 DOM 元素。列表中成千上万的数据在内存中,但只有几十个在页面上是真实存在的 DOM。
这极大地减少了 DOM 节点的数量,从而提升了渲染性能。
3.4.2 实现虚拟滚动
虚拟滚动的实现比较复杂,涉及到计算滚动高度、动态变换元素位置等。幸运的是,社区已经有很多成熟的库可以帮助我们。
推荐库:vue-virtual-scroller
这是一个非常流行且功能强大的虚拟滚动库。
使用步骤简介:
- 安装库:
1npm install --save vue-virtual-scroller
- 全局引入(在你的
main.js中):
1import { createApp } from 'vue' 2import App from './App.vue' 3import VueVirtualScroller from 'vue-virtual-scroller' 4import 'vue-virtual-scroller/dist/vue-virtual-scroller.css' 5const app = createApp(App) 6app.use(VueVirtualScroller) 7app.mount('#app')
- 在组件中使用:
1<script setup> 2import { ref } from 'vue'; 3// 生成一个包含 10000 个项目的巨大数组 4const hugeList = ref(Array.from({ length: 10000 }, (_, index) => ({ 5 id: index, 6 text: `Item ${index + 1}` 7}))); 8</script> 9<template> 10 <h2>虚拟滚动列表</h2> 11 <!-- 12 使用 <RecycleScroller> 组件 13 - items: 数据源 14 - item-size: 每个列表项的固定高度(像素) 15 - key-field: 指定哪个字段作为唯一标识 16 --> 17 <RecycleScroller 18 class="scroller" 19 :items="hugeList" 20 :item-size="50" 21 key-field="id" 22 v-slot="{ item }" 23 > 24 <!-- 25 通过 v-slot="{ item }" 获取到当前渲染项的数据 26 这里渲染的内容就是你的列表项模板 27 --> 28 <div class="item"> 29 {{ item.text }} 30 </div> 31 </RecycleScroller> 32</template> 33<style> 34.scroller { 35 height: 500px; /* 必须给容器一个固定高度 */ 36} 37.item { 38 height: 50px; /* 必须与 item-size 一致 */ 39 padding: 0 12px; 40 display: flex; 41 align-items: center; 42 border-bottom: 1px solid #eee; 43} 44</style>
代码剖析:
- 我们用
RecycleScroller组件替代了原生的<ul>和v-for。 items属性绑定我们的巨大数据源。item-size告诉组件每个列表项的高度是多少,这是计算可视区域能容纳多少项的关键。key-field告诉组件用数据对象的哪个属性作为key。v-slot="{ item }是一个作用域插槽,RecycleScroller会把当前需要渲染的那个item对象传递给我们,我们用它来展示内容。
通过这种方式,即使 hugeList 有 10000 条数据,页面上也始终只渲染大约 500px / 50px = 10 个 <div class="item"> 元素,性能得到了极大的提升。
四、总结与回顾
好了,朋友,我们一起对 Vue 3 的 v-for 指令进行了一次从入门到精通的深度探索。让我们来回顾一下这段旅程中的关键知识点。
v-for 不仅仅是一个指令,它体现了 Vue 框架“数据驱动视图”的核心思想。通过深入理解它,你不仅能掌握如何渲染列表,更能理解 Vue 响应式系统的工作方式,以及如何编写高性能、可维护的前端代码。
《Vue 3 v-for 指南:从基础到精通,彻底掌握列表渲染的艺术》 是转载文章,点击查看原文。