记一次Vue中的v-for踩坑之旅

用过Vue的同学都知道,v-for指令常用于遍历数组或者对象,然后依次渲染出指定的内容。同时,我们也知道,官方文档也建议,在使用v-for指令时,记得要加上key属性,方便提升应用性能。例如一个简单的增删Todo应用如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<template>
<div class="todos">
<input v-model.trim="task" @keypress.enter="onSaveTodo" placeholder="请输入待办任务" />
<ul>
<li v-for="(todo, index) in todos" :key="index">
<span @click="onRemoveTodo(index)">{{ todo }} <i class="remove">&times;</i></span>
</li>
</ul>
</div>
</template>

<script>
export default {
name: 'TodoApp',
data() {
return {
todos: [],
task: ''
}
},
methods: {
onSaveTodo() {
this.todos.push(this.task)
this.task = ''
},
onRemoveTodo(index) {
this.todos.splice(index, 1)
}
}
}
</script>

<style>
.todos .remove {
color: #ff0000;
cursor: pointer;
}
</style>

See the Pen vue中v-for使用

代码很简单明了,也运行的很高效。我们用了v-for指令,也加了key, 一切都和完美,感叹Vue真好用,真是高效哇!

组件封装

在Vue中,官方建议我们多进行组件封装和抽象,这样方便后期维护。因为每一个Todo都有自己的状态,例如完成或者未完成, 我们需要将每一个Todo抽象为组件。所以我们要做一下简单的改进:新建一个TodoItem.vue,然后在主文件中导入使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<template>
<div class="todo-item" :class="{checked: checked}">
<input type="checkbox" v-model="checked" />
<span class="label" @click="onRemoveItem">{{ todo }} <i class="remove">&times;</i></span>
</div>
</template>

<script>
export default {
name: 'TodoItem'
props: {
todo: String
},
data() {
return {
checked: false
}
},
methods: {
onRemoveItem() {
this.$emit('remove')
}
}
}
</script>

<style>
.todo-item.checked .label {
text-decoration: line-through;
}
</style>

代码非常简单,我们在TodoItem.vue中新增加了一个checked属性,当复选框勾选后,todo文字会显示删除效果。重新修改下主文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<template>
<div class="todos">
<input v-model.trim="task" @keypress.enter="onSaveTodo" placeholder="请输入待办任务" />
<ul>
<li v-for="(todo, index) in todos" :key="index">
<todo-item :todo="todo" @remove="onRemoveTodo(index)"></todo-item>
</li>
</ul>
</div>
</template>

<script>
import TodoItem from './TodoItem'
export default {
name: 'TodoApp',
components: {
TodoItem
},
data() {
return {
todos: [],
task: ''
}
},
methods: {
onSaveTodo() {
this.todos.push(this.task)
this.task = ''
},

onRemoveTodo(index) {
this.todos.splice(index, 1)
}
}
}
</script>

<style>
.todos .remove {
color: #ff0000;
cursor: pointer;
}
</style>

代码变化不大,只是在v-for循环时加入了<todo-item></todo-item>而已。看看运行效果

See the Pen vue中v-for使用(组建嵌套)

好像没啥问题? 但是如果我们增加两条数据,将第一条记录勾选后然后再删除,令人费解的事情发生了:

可以清晰地看到,第二条记录之前是未勾选状态,但是删除第一条后,它变成了勾选状态?这是为什么呢?

问题分析

这个问题其实我现实项目中的一个抽象,当时我也遇到了类似的问题,想了好几个小时都没解决。我一行一行分析我的代码,是不是代码哪里写错了?最后一行一行分析,突然想到是不是key用的不对?于是我将key弄成一个唯一的id,然后奇迹发生了,页面都正常了。这是为什么呢?

在我们的例子,如果我们我们将v-for中的key改成如下所示(保证todo不重复)问题就解决了:

1
2
3
4
5
6
<ul>
<!-- 为了演示访问表,假设todo是永不相同的 -->
<li v-for="(todo, index) in todos" :key="todo">
<span @click="onRemoveTodo(index)">{{ todo }} <i class="remove">&times;</i></span>
</li>
</ul>

虽然当时问题是解决了,但是这个v-for的问题一直在困扰我,到底是什么原因导致这种现象发生,为什么key弄成唯一的id就好使了呢?官方说在v-for时增加key可以提升应用性能,到底是怎么提升的?

刚好最近在看Vue Virtual DOM的diff算法,终于从中间找到了解决该问题的曙光

Vue中的Virtual DOM

在Vue中,template中的内容最后都会被解析并渲染为VNode, 这个就是所谓的Virtual DOM。当我们修改Vue中的数据后,Vue会对前后两次的VNode进行diff,找出最小的差异,然后再渲染DOM,这样可以提高应用的性能。

VNode其实是对真实DOM的Javascript抽象,例如一个简答的DOM树如下所示:

1
2
3
<div class="test">
<span class="demo">hello,VNode</span>
</div>

在VNode中,会这样进行展示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
tag: 'div'
data: {
class: 'test'
},
children: [
{
tag: 'span',
data: {
class: 'demo'
}
text: 'hello,VNode'
}
]
}

也就是说,VNode可以真实描述并还原DOM。

Virtual DOM diff算法

Vue中的Virtual DOM diff算法比较复杂,一言两语无法描述清楚。由于网上已经有很多文章,我这里只针对v-for问题进行针对性解释。

我们先看看diff的核心函数(这些是源码的抽象,源码里面更为复杂):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function patch (oldVNode, vnode, parentElm) {
if (!oldVnode) {
addVnodes(parentElm, null, vnode, 0, vnode.length - 1);
} else if (!vnode) {
removeVnodes(parentElm, oldVnode, 0, oldVnode.length - 1);
} else {
if (sameVnode(oldVNode, vnode)) {
patchVnode(oldVNode, vnode);
} else {
removeVnodes(parentElm, oldVnode, 0, oldVnode.length - 1);
addVnodes(parentElm, null, vnode, 0, vnode.length - 1);
}
}
}

patch就是比较前后两个VNode,然后找出其最小差异并修改、创建或者删除DOM。在该函数中,oldVNode代表旧的数据,vnode代表最新的数据。比较时,会进行深度优先逐层进行比较。如下图所示:


也就是说,在上图中,只有相同颜色的VNode才进行比较,这样算法复杂度就比较低,整体下来只有O(n),效率算法非常高了。

patch函数可以看出,diff算法的的核心逻辑是这样的:

  • 如果旧的VNode不存在,新的VNode存在,则创建新的DOM
  • 如果旧的VNode存在,新的VNode不存在,则删除旧的DOM
  • 如果新旧两个VNode都存在并相同,则找出最小差异然后更新DOM
  • 如果新旧两个VNode都存但不相同,则将旧的DOM删除,然后创建新的DOM

这里的关键是:如何判断两个VNode相同呢?请看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function sameInputType (a, b) {
if (a.tag !== 'input') return true
let i
const typeA = (i = a.data) && (i = i.attrs) && i.type
const typeB = (i = b.data) && (i = i.attrs) && i.type
return typeA === typeB
}

function sameVnode () {
return (
a.key === b.key &&
a.tag === b.tag &&
a.isComment === b.isComment &&
!!a.data === !!b.data &&
sameInputType(a, b)
)
}

也就是说,只有当 key、 tag、 isComment(是否为注释节点)相同、 data同时定义(或不定义),同时满足当标签类型为input的时候type相同,那么它们就是相同的VNode。

注意这里的key相同,才代表VNode相同。对比我们之前出错的样例,因为我们的key是索引号,可知第一条记录的索引号为0。当第一条记录被删除后,第二条记录的key的索引号会从1变为0,这样导致了两者的key相同。因为key相同时,diff算法会认为它们是相同的VNode,那么旧的VNode(如果VNode是一个组件,它有一个componentInstance指向Vue实例)指向的Vue实例会被复用,导致显示出错。修改key为唯一id时,根据上文patch函数的逻辑,旧的VNode所对应的DOM会被干掉,然后得新的DOM会被创建。因为是新创建的DOM,那么对应的Vue也是新创建的,一切就会显示正常。

所以,保证key唯一,就可以解决组件出错的问题。

上文中,我们没有提到diff算法的核心,也就是说当两个VNode相同时,patchVnode是怎么实现的。建议大家阅读相关参考文章。

总结

v-for使用非常简单,但是要特别注意key的使用。官方之所以说加上key会提升应用性能是因为:key相同时,两个VNode会相同,可以避免不必要的DOM更新。而且在diff内部,也会根据key来跟踪VNode。但是,官方也说了,尽量保证key是唯一的id,这样可以避免一些匪夷所思的bug。

参考资料