key:我与diff之间“说得清道得明”的关系

125 阅读7分钟

前言

大家好!最近在写自己项目的时候发现v-for后面总会跟着个key,以前都没太注意过这个小玩意,最近心血来潮查了一下,才发现这里面大有门道,所以今天我们来聊聊一个在 Vue 开发中经常被讨论的话题:为什么在 v-for 渲染列表时,不建议直接使用元素的索引(index)作为 key 属性。这个问题对于初学者来说可能有些困惑,但对于构建高效、可维护的应用程序至关重要。希望通过本文,各位读者能够更好地理解其中的原因,并在实际开发中做出更加合理的选择。

正文

什么是虚拟dom

在了解key之前我们得简单认识一下什么是虚拟dom,先看看下面这段代码

<body>
    <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>

    <div id="app">
            <ul class="list">
                <li class="item" v-for="item in list" :key="index">{{item}}</li>
            </ul>
    </div>

    <script>
        const { createApp, ref } = Vue

        createApp({
            setup() {
                const list = ref(['html', 'css', 'javascript','vue'])
               
                return {
                    list,
                }
            }
        }).mount('#app')
    </script>
</body>

2.png

我们可以看到当页面出现并审查的时候,li出现了四次,但是我们在代码中li只有一个,剩下的是依靠v-for循环生成的,这就是虚拟dom,在学vue的时候总把template当html写,但是老师会很认真纠正我们的错误,说这不是html而是模板,很大的一个原因就是虚拟dom。

在模板被js引擎编译之前,浏览器是压根不知道这里有几个li元素的,只有当编译之后,js引擎会以 JavaScript 对象的形式表示真实DOM树。通过将这些对象与实际DOM进行比较,这样我们就可以确定何时以及如何更新页面。dom树就像是下面的代码一样,当然我这里只是一个很简单的例子,实际上的dom树要比这个复杂得多,会包含更多的东西。

   <script>
        let old = [
            {
                tagName: 'li',
                key: 0,
                value: 'html'
            },
            {
                tagName: 'li',
                key: 1,
                value: 'css'
            },
            {
                tagName: 'li',
                key: 2,
                value: 'javascript'
            },
            {
                tagName: 'li',
                key: 3,
                value: 'vue'
            },

        ]
    </script>

  1.png

什么是 key 属性?

了解了虚拟dom的概念之后我们就可以来聊聊key了。在 Vue 中,key 是一个特殊的属性,用于帮助框架识别哪些元素发生了变化,从而可以有效地更新 DOM。当数据发生变化时,Vue 会根据 key 来决定是重新渲染整个列表还是仅仅更新那些改变了的数据项。这有助于提高性能,避免不必要的操作

为什么索引不是理想的 key

  1. 稳定性问题

看过我之前两篇文章的读者姥爷应该发现了我在这两篇文章中并没有细聊关于dom元素的更新,算是给今天的文章埋下了一个伏笔。依旧是刚刚的小demo,我在这里稍微做一点小修改,添加按钮和函数,把长度改为3

 <script>
        const { createApp, ref } = Vue

        createApp({
            setup() {
                const list = ref(['html', 'css', 'javascript'])
                const change = () => {
                    list.value.reverse()
                }
                return {
                    list,
                    change
                }
            }
        }).mount('#app')
    </script>

这样当我点击的时候,数组会翻转导致虚拟dom修改,问题来了,对于正中间的css来说,不论我怎么翻转都在第二个,如果你是js引擎,你会推倒重来?还是修修改改?

1.png

通过审查元素我们可以看到修改的一瞬间只有头尾两个元素发生变化,也就是说js引擎选择的是后者。

  当我们的模板更新之后,compiler会生成第二份虚拟dom,随后,会与第一份比较,就像游戏找不同一样,尽可能复用部分代码,然后生成一个path补丁包,以减少回流重绘带来的大量性能消耗,这就是大名鼎鼎的diff算法。

  2.png

有趣的一点来了,当我将模板修改一下

 

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        .red {
            background-color: #cd2828;
        }

        .yellow {
            background-color: rgb(151, 151, 40);
        }
    </style>
</head>

<body>
    <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>

    <div id="app">
        <!-- 使用 v-if 和 v-else 切换父节点 -->
        <div v-if="isShow" class="red">
            <ul class="list">
                <li class="item" v-for="(item, index) in list" :key="index">{{ item }}</li>
            </ul>
        </div>
        <div v-else class="yellow">
            <ul class="list">
                <li class="item" v-for="(item, index) in list" :key="index">{{ item }}</li>
            </ul>
        </div>

        <button @click="change">Change</button>
    </div>

    <script>
        const { createApp, ref } = Vue

        createApp({
            setup() {
                const isShow = ref(true)
                const list = ref(['html', 'css', 'javascript'])

                const change = () => {
                    isShow.value = !isShow.value
                }

                return {
                    list,
                    change,
                    isShow
                }
            }
        }).mount('#app')
    </script>
</body>

看起来只是在最外层套了一层无关紧要的div,而点击按钮的行为无非是选择哪个div而已,在我们看来之前的所有dom元素都可以复用,但是实际上并非如此。当父节点发生变化的时候(例如这里的直接替换)diff算法是不会大海捞针去寻找哪些dom可以复用的,而是直接删个干净重新生成。道理也很简单,对于js执行引擎来说,等你找完所有的dom子节点,已经够我生成并渲染新的dom树了。

    <div id="app">
        <div>
            <ul class="list">
                <li class="item" v-for="item in list" :key="item">{{item}}</li>
            </ul>
            
              <ul class="list">
                <li class="item" v-for="item in list" :key="key">{{item}}</li>
            </ul>
        </div>


        <button @click="change">change</button>
    </div>

    <script>
        const { createApp, ref } = Vue

        createApp({
            setup() {
                const list = ref(['html', 'css', 'javascript'])
                const change = () => {
                    list.value.unshift('vue')
                   
                }
                return {
                    list,
                    change
                }
            }
        }).mount('#app')

1.png

在这里我分别用index和item做key,当我们点击按钮的时候,数组头部被插入元素,这也就导致了后面所有元素的index都发生改变,key也随之发生改变,所以diff算法会认为这是两个不同的dom,而如果我们用item本身(item是唯一的)去做key,这不仅消耗了额外的计算资源,还可能导致一些依赖于索引的行为出现问题,比如动画效果或是与特定索引相关的事件处理函数。
  1. 可预测的行为

保持状态:如果你的应用中包含了一些需要保存状态的组件(例如表单输入),使用稳定的 key 值可以帮助保持这些组件的状态不变。相反,如果使用索引作为 key,那么每当列表发生变化时,组件可能会被错误地重置。这意味着用户正在填写的表单字段可能突然清空,或者选中的选项意外地改变。

动画效果:Vue 提供了内置的过渡系统支持平滑的动画效果。要让这些动画正确工作,每个元素都需要有一个唯一的标识符,以便于跟踪其从旧到新的状态变换过程。使用索引会导致动画行为变得不可预测,尤其是在列表频繁变化的情况下。

  1. 性能优化 - Diff算法的作用

减少不必要的计算:Vue 使用一种称为虚拟DOM的机制来追踪视图的变化。每次数据更新后,它会比较新旧两棵虚拟DOM树,找出最小的修改路径,这一过程被称为Diff算法。提供稳定且独特的 key 可以极大地简化Diff算法的工作,因为它可以直接跳过那些未发生变化的节点。相反,如果使用索引作为 key,Diff算法必须检查每一个节点,这增加了计算复杂度和时间成本。

增强响应速度:特别是对于大型列表而言,正确的 key 使用策略可以显著提升应用的整体响应速度和流畅度。通过减少不必要的DOM操作,浏览器可以更快地完成渲染任务,为用户提供更流畅的体验。此外,合理的 key 分配还有助于缓存机制的有效运作,进一步提高性能表现。

鉴于上述原因,我推荐为 v-for 列表中的每一项提供一个唯一且稳定的 key。理想情况下,这个 key 应该基于数据本身的某个属性,比如数据库中的ID,或者是在创建对象时自动生成的UUID。这样做的好处是,无论列表如何变化,只要数据本身未变,对应的 key 也不会改变,从而保证了视图层的稳定性与高效性。

总结

综上所述,虽然使用索引作为 key 在某些简单场景下看似可行,但从长远来看,它并不是最佳选择。通过采用更合适的 key 值,我们可以确保我们的应用程序既快速又可靠。希望今天分享的内容对你有所帮助,祝各位读者姥爷开发愉快,0 warning(s), 0 error(s)!