面试题——为什么 Vue 中不要用 index 作为 key?(diff 算法详解)

前言

在vue中使用v-for时需要,都会提示或要求使用  :key,有的的开发者会直接使用数组的 index 作为 key 的值,但不建议直接使用 index作为 key 的值,有时我们面试时也会遇到面试官问:为什么不推荐使用 index 作为 key ?接下来和小颖一起来瞅瞅吧

为什么要有 key

官网解释

当 Vue 正在更新使用 v-for 渲染的元素列表时,它默认使用“就地更新”的策略。如果数据项的顺序被改变,Vue 将不会移动 DOM 元素来匹配数据项的顺序,而是就地更新每个元素,并且确保它们在每个索引位置正确渲染。这个类似 Vue 1.x 的 track-by="$index"

这个默认的模式是高效的,但是只适用于不依赖子组件状态或临时 DOM 状态 (例如:表单输入值) 的列表渲染输出。

为了给 Vue 一个提示,以便它能跟踪每个节点的身份,从而重用和重新排序现有元素,你需要为每项提供一个唯一 key attribute:

key的作用

官网解释:key

预期:number | string | boolean (2.4.2 新增) | symbol (2.5.12 新增)

key 的特殊 attribute 主要用在 Vue 的虚拟 DOM 算法,在新旧 nodes 对比时辨识 VNodes。如果不使用 key,Vue 会使用一种最大限度减少动态元素并且尽可能的尝试就地修改/复用相同类型元素的算法。而使用 key 时,它会基于 key 的变化重新排列元素顺序,并且会移除 key 不存在的元素。

有相同父元素的子元素必须有独特的 key。重复的 key 会造成渲染错误。

通俗解释:

key 在 diff 算法的作用,就是用来判断是否是同一个节点。

Vue 中使用虚拟 dom 且根据 diff 算法进行新旧 DOM 对比,从而更新真实 dom ,key 是虚拟 DOM 对象的唯一标识, 在 diff 算法中 key 起着极其重要的作用,key可以管理可复用的元素,减少不必要的元素的重新渲染,也要让必要的元素能够重新渲染。

为什么key 值不建议用index?

性能消耗

使用 index 做 key,破坏顺序操作的时候, 因为每一个节点都找不到对应的 key,导致部分节点不能复用,所有的新 vnode 都需要重新创建。

示例:

<template>
  <div class="hello">
    <ul>
      <li v-for="(item,index) in studentList" :key="index">{{ item.name }}</li>
    </ul>
    <br>
    <button @click="addStudent">添加一条数据</button>
  </div>
</template>

<script>
export default {
  name: 'ceshi',
  data() {
    return {
      studentList: [
        {id: 1, name: '张三', age: 18},
        {id: 2, name: '李四', age: 19},
        {id: 3, name: '王麻子', age: 20},
      ],
    };
  },
  methods: {
    addStudent() {
      const studentObj = {id: 4, name: '王五', age: 20};
      this.studentList = [studentObj, ...this.studentList]
    }
  }
}
</script>

打开浏览器的开发工具,修改数据的文本,后面加上 “-我没变”,点击添加一条数据   按钮,则发现dom整体都变了

 

 

 当给key绑定唯一不重复的值时:

<li v-for="item in studentList" :key="item.id">{{ item.name }}</li>

打开浏览器的开发工具,修改数据的文本,后面加上 “-我没变”,点击添加一条数据   按钮,则发现只是顶部多了一条,其他dom没有重新渲染。

 

 

当用index做key时,当我们在前面加了一条数据时 index 顺序就会被打断,导致新节点 key 全部都改变了,所以导致我们页面上的数据都被重新渲染了。而用了不会重复的唯一标识id时,diff算法比较后发现只有头部有变化,其他没有变,则只给头部新增了一个元素。从上面比较可以看出,用唯一值作为 key 可以节约开销这样大家应该就明白了吧·················

参考:在 Vue 中为什么不推荐用 index 做 key

数据错位

示例:

<template>
  <div class="hello">
    <ul>
      <li v-for="item in studentList" :key="item.id">{{ item.name }}<input /></li>
    </ul>
    <br>
    <button @click="addStudent">添加一条数据</button>
  </div>
</template>

<script>
export default {
  name: 'ceshi',
  data() {
    return {
      studentList: [
        {id: 1, name: '张三', age: 18},
        {id: 2, name: '李四', age: 19}
      ],
    };
  },
  methods: {
    addStudent() {
      const studentObj = {id: 4, name: '王五', age: 20};
      this.studentList = [studentObj, ...this.studentList]
    }
  }
}
</script>

我们往 input 里面输入一些值,添加一位同学看下效果:

 

 

 

 这时候我们就会发现,在添加之前输入的数据错位了。添加之后王五的输入框残留着张三的信息,这很显然不是我们想要的结果。

 

 

 从上面比对可以看出来这时因为采用 index 作为 key 时,当在比较时,发现虽然文本值变了,但是当继续向下比较时发现  DOM 节点还是和原来一摸一样,就复用了,但是没想到 input 输入框残留输入的值,这时候就会出现输入的值出现错位的情况

当我们将key绑定为唯一标识id时,如图所示。key 相同的节点都做到了复用。起到了diff 算法的真正作用。

 总结:

  • 用 index 作为 key 时,在对数据进行,逆序添加,逆序删除等破坏顺序的操作时,会产生没必要的真实 DOM更新,从而导致效率低
  • 用 index 作为 key 时,如果结构中包含输入类的 DOM,会产生错误的 DOM 更新
  • 在开发中最好每条数据使用唯一标识固定的数据作为 key,比如后台返回的 ID,手机号,身份证号等唯一值
  • 如果不对数据进行逆序添加 逆序删除破坏顺序的操作, 只用于列表展示的话 使用index 作为Key没有毛病

相关内容

 为什么要提出虚拟DOM

在Web早期,页面的交互比较简单,没有复杂的状态需要管理,也不太需要频繁的操作DOM,随着时代的发展,页面上的功能越来越多,我们需要实现的需求也越来越复杂,DOM的操作也越来越频繁。通过js操作DOM的代价很高,因为会引起页面的重排重绘,增加浏览器的性能开销,降低页面渲染速度,既然操作DOM的代价很高那么有没有那种方式可以减少对DOM的操作?这就是为什么提出虚拟DOM一个很重要的原因。
 
虚拟DOM就是为了解决浏览器性能问题而被设计出来的。若一次操作中有10次更新DOM的动作,虚拟DOM不会立即操作DOM,而是将这10次更新的diff内容保存到本地一个JS对象中,最终将这个JS对象一次性attch到DOM树上,再进行后续操作,避免大量无谓的计算量。所以,用JS对象模拟DOM节点的好处是,页面的更新可以先全部反映在JS对象(虚拟DOM)上,操作内存中的JS对象的速度显然要更快,等更新完成后,再将最终的JS对象映射成真实的DOM,交由浏览器去绘制。
参考: VUE中key的作用与diff算法
 
简而言之呢   
      频繁的操作DOM会影响浏览器的性能,为了解决这个问题从而提出了 虚拟DOM,虚拟DOM是用javascript对象表示的,而操作javascript是很简便高效的。虚拟DOM和真正的DOM有一层映射关系,很多需要操作DOM的地方都会去操作虚拟DOM,最后统一一次更新DOM。 因而可以提高性能。

附加面试题:

如果有许多次操作dom的动作,vue中是怎么更新dom的?
vue中不会立即操作dom,而是将要更新的diff内容保存到新的虚拟dom对象中,通过diff算法后得到最终的虚拟dom,将其映射成真是的dom更新视图。

虚拟DOM是什么

      Vue.js通过编译将模版转换成渲染函数(render),执行渲染函数就可以得到一个以vnode节点(JavaScript对象)作为基础的树形结构,vnode节点里面包含标签名(tag)、属性(attrs)和子元素对象(children)等等属性,这个树形结构就是Virtual DOM,简单来说,可以把Virtual DOM理解为一个树形结构的JS对象。

参考: 浅谈Vue中的虚拟DOM

简而言之  虚拟DON就是javascript对象,通过对虚拟 DOM进行diff,算出最小差异,然后更新真实的DOM。

虚拟DOM的优势

  1. 提供一种方便的工具,使得开发效率得到保证
  2. 保证最小化的DOM操作,使得执行效率得到保证
  3. 具备跨平台的优势 由于 Virtual DOM 是以 JavaScript 对象为基础而不依赖真实平台环境,所以使它具有了跨平台的能力,比如说浏览器平台、Weex、Node 等。
  4. 提升渲染性能 Virtual DOM的优势不在于单次的操作,而是在大量、频繁的数据更新下,能够对视图进行合理、高效的更新

参考:vue 中虚拟dom的理解浅谈Vue中的虚拟DOM

为什么虚拟DOM可以提高渲染速度

  1. 根据虚拟dom树最初渲染成真实dom
  2. 当数据变化,或者说是页面需要重新渲染的时候,会重新生成一个新的完整的虚拟dom
  3. 拿新的虚拟dom来和旧的虚拟dom做对比(使用diff算法)。得到需要更新的地方之后,从而只对发生了变化的节点进行更新操作。

如果大家不太明白的话,我们来看个示例

<template>
  <div class="hello">
    <ul>
      <li v-for="(item,index) in studentList" :key="item.id" @click="changeName(index)">{{ item.name }}</li>
    </ul>
  </div>
</template>

<script>
export default {
  name: 'ceshi',
  data() {
    return {
      studentList: [
        {id: 1, name: '张三', age: 18},
        {id: 2, name: '李四', age: 19},
        {id: 3, name: '王麻子', age: 20},
      ],
    };
  },
  methods: {
    changeName(index) {
      this.studentList[index].name = '我变啦'
    }
  }
}
</script>

上面的修改数组值得方式 仅在 2.2.0+ 版本中支持 Array + index 用法。

如果使用之前的则使用    Vue.set( target, propertyName/index, value )

运行后,我们打开开发者工具,然后手动修改页面的文本,给每个后面加  “-我没变” 

 然后随便点击其中一个,我们会发现只有点击那个文本变了,并且只是它的文本内容变了,dom并没有整体变。

vue中是如何实现模板转换成视图的

参考: 浅谈Vue中的虚拟DOM

简单来说就是:通过编译将模版转换成渲染函数(render),执行渲染函数就可以得到一个以vnode节点(JavaScript对象)作为基础的树形结构(虚拟dom),当数据发生变化虚拟dm通过diff算法找出新树和旧树的不同,记录两棵树差异根据差异应用到所构建的真正的DOM树上,视图就更新。

diff算法

 虚拟DOM中,在DOM的状态发生变化时,虚拟DOM会进行Diff运算,来更新只需要被替换的DOM,而不是全部重绘。 在Diff算法中,只平层的比较前后两棵DOM树的节点,没有进行深度的遍历。

规则

同层比较,如上面div的Old Vnode,跟其Vnode比较,div只会跟同层div比较,不会跟p进行比较,下面是示例图:

流程图

当数据发生改变时,set方法会让调用Dep.notify通知所有订阅者Watcher,订阅者就会调用patch给真实的DOM打补丁,更新相应的视图。

 

 具体分析

patch

 

 来看看patch是怎么打补丁的(代码只保留核心部分)

function patch (oldVnode, vnode) {
    // some code
    if (sameVnode(oldVnode, vnode)) {
        patchVnode(oldVnode, vnode)
    } else {
        const oEl = oldVnode.el // 当前oldVnode对应的真实元素节点
        let parentEle = api.parentNode(oEl)  // 父元素
        createEle(vnode)  // 根据Vnode生成新元素
        if (parentEle !== null) {
            api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl)) // 将新元素添加进父元素
            api.removeChild(parentEle, oldVnode.el)  // 移除以前的旧元素节点
            oldVnode = null
        }
    }
    // some code 
    return vnode
}

patch函数接收两个参数oldVnodeVnode分别代表新的节点和之前的旧节点

  • 判断两节点是否值得比较,值得比较则执行patchVnode
    function sameVnode (a, b) {
      return (
        a.key === b.key &&  // key值
        a.tag === b.tag &&  // 标签名
        a.isComment === b.isComment &&  // 是否为注释节点
        // 是否都定义了data,data包含一些具体信息,例如onclick , style
        isDef(a.data) === isDef(b.data) &&  
        sameInputType(a, b) // 当标签是<input>的时候,type必须相同
      )
    }
  • 不值得比较则用Vnode替换oldVnode

如果两个节点都是一样的,那么就深入检查他们的子节点。如果两个节点不一样那就说明Vnode完全被改变了,就可以直接替换oldVnode

虽然这两个节点不一样但是他们的子节点一样怎么办?别忘了,diff可是逐层比较的,如果第一层不一样那么就不会继续深入比较第二层了。(我在想这算是一个缺点吗?相同子节点不能重复利用了...)

patchVnode

当我们确定两个节点值得比较之后我们会对两个节点指定patchVnode方法。那么这个方法做了什么呢?

patchVnode (oldVnode, vnode) {
    const el = vnode.el = oldVnode.el
    let i, oldCh = oldVnode.children, ch = vnode.children
    if (oldVnode === vnode) return
    if (oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text) {
        api.setTextContent(el, vnode.text)
    }else {
        updateEle(el, vnode, oldVnode)
        if (oldCh && ch && oldCh !== ch) {
            updateChildren(el, oldCh, ch)
        }else if (ch){
            createEle(vnode) //create el's children dom
        }else if (oldCh){
            api.removeChildren(el)
        }
    }
}

这个函数做了以下事情:

  • 找到对应的真实dom,称为el
  • 判断VnodeoldVnode是否指向同一个对象,如果是,那么直接return
  • 如果他们都有文本节点并且不相等,那么将el的文本节点设置为Vnode的文本节点。
  • 如果oldVnode有子节点而Vnode没有,则删除el的子节点
  • 如果oldVnode没有子节点而Vnode有,则将Vnode的子节点真实化之后添加到el
  • 如果两者都有子节点,则执行updateChildren函数比较子节点,这一步很重要

参考:详解vue的diff算法 

updataChildren原理 

posted @ 2022-09-05 11:48  爱喝酸奶的吃货  阅读(7128)  评论(0编辑  收藏  举报