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


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

<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,然后在主文件中导入使用

<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文字会显示删除效果。重新修改下主文件

<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不重复)问题就解决了:

<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树如下所示:

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

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

{
    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的核心函数(这些是源码的抽象,源码里面更为复杂):

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相同呢?请看下面的代码:

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。

参考资料


文章作者: Asyncoder
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Asyncoder !
  目录