vue的key和diff到底干了啥 ✨✨✨

1,660 阅读8分钟

用最短的篇幅说明白vue中key在渲染时起到了什么作用,以及v-for到底为什么不要用index作为key。

从官网的简绍分析一个异常

完善一下最后的例子

<template>
  <div>
    <transition name="list">
      <span @click="add">{{ text }}</span> <!-- :key="text" -->
    </transition>
  </div>
</template>

<script>
export default {
  data() {
    return {
      text: 1
    }
  },
  methods: {
    add(){
      this.text ++
    }
  }
}
</script>

<style scoped lang="scss">
.list-enter-from, .list-leave-to {
  transition: all 1s;
  opacity: 0;
}
</style>

现象是只有当span标签绑定key值的时候才会正常渲染动画,我们得到一个初步结论,当key发生变化的时候视图会重新渲染。如果没有绑定key或者key没有变化,那么vnode也就是虚拟dom不会重新生成,DOM元素也不会重新渲染,只是做了文本节点的插入更新了依赖状态text。

vue官网对key属性的简绍还提到了虚拟DOM算法,这里的虚拟DOM算法其实就是diff算法。

VNode

在说diff之前得先了解一下什么是虚拟DOM(VNode),虚拟DOM是一个用于表示真实 DOM 结构和属性的 JavaScript 对象,设计这个对象的目的是避免对DOM进行频繁的操作,把DOM操作转移到VNode上,再一次性把VNode渲染到视图上减少重拍重绘提升性能,同时在状态发生变化的时候VNode可用于和当前真实DOM做对比只对有差异的部分进行局部渲染从而实现性能上的优化。在Vue源码中实现虚拟 DOM 的 JavaScript 对象就是 VNode,我们看一下源码中的 VNode 结构:

其实就是一个普通的 JavaScript Class 类,定义了很多用来描述DOM的属性,我们把一个简单的DOM结构转换成虚拟DOM来看一下。

<div id="app">
  <ul>
    <li class="item">a</li>
    <li class="item">b</li>
  </ul>
</div>

{
  context: '$vue$3',
  elm: 'div#app'
  tag: 'div',
  data: {
    attr: {id: 'app'}
  },
  children: [
    {
      tag: 'ul',
      children: [
        {
          tag: 'li',
          data: {staticClass: 'item'},
          children: [
            {text: 'a'}
          ]
        },
        {
          tag: 'li',
          data: {staticClass: 'item'},
          children: [
            {text: 'a'}
          ]
        }
      ]
    }
  ]
}

在分析VNode的时候小结以下几点:

  • 所有对象的 context 选项都指向了 Vue 实例。
  • elm 属性则指向了其相对应的真实 DOM 节点。
  • DOM 中的文本内容被当做了一个只有 text 没有 tag 的节点。
  • 像 class、id、style 等HTML属性都放在了 data 中。

createElement

vue的核心渲染逻辑是render方法,而render的核心是createElement方法,在源码 src/core/vdom/create-element.js 目录下,createElement的作用就是生成VNode。

update

现在知道了VNode的作用和VNode从何而来,那么VNode是怎么转换成真实DOM的呢?

_update 是 Vue 实例的一个私有方法,_update 方法的作用是把 VNode 渲染成真实的 DOM,在源码 src/core/instance/lifecycle.js 目录下。

_update 被调用的时机有2个,一个是首次渲染,一个是数据更新的时候,_update源码会通过prevVnode判断是首次渲染还是数据更新,然后通过 inBrowser 判断当前环境,服务器端环境执行 noop,noop是一个空函数(因为服务端没有DOM),浏览器端执行 patch 方法,最终生成真实DOM的就是patch方法,在源码 src/platforms/web/runtime/patch.js 目录下,再对生命周期做遍历,在对应的时期做不同的事情。

createElement 和 update 不做过多分析本文以为 key 和 diff 为主。

DIFF

终生成DOM的patch方法执行了createPatchFunction方法,并穿了两个参数 nodeOps、 modules,nodeOps 是对 document.createTextNode、document.createElementNS 等DOM操作api做了封装,modules 定义了模块钩子函数的实现,createPatchFunction 方法在源码 src/core/vdom/patch.js 目录下。

createPatchFunction方法是diff的核心,对diff的分析围绕createPatchFunction展开。

✋createPatchFunction源码src/core/vdom/patch.js

首先对比新旧VNode是不是相同的节点,如过不是直接用新的替换旧的。

如果是相同的节点,再判断是不是文本节点,如果是文本节点直接替换。

如果不是文本节点,就对子节点 children 进行对比。

children 的对比逻辑就是diff算法。

对新旧children做对比的实现逻辑是先拿到新旧children的首尾节点,在一个while逻辑里分别对比,每次循环不断把首尾的位置向中间收缩。

对比通过sameVnode函数实现,sameVnode函数返回true,表示该VNode节点没有发生变化,可以被复用。

// src/core/vdom/patch.js

let oldStartIdx = 0              // 旧节点首位index
let newStartIdx = 0              // 新节点首位index
let oldEndIdx = oldCh.length - 1 // 旧节点尾位index
let oldStartVnode = oldCh[0]     // 旧节点首位Vnode
let oldEndVnode = oldCh[oldEndIdx] // 旧节点尾位Vnode
let newEndIdx = newCh.length - 1   // 新节点尾位index
let newStartVnode = newCh[0]       // 新节点首位Vnode
let newEndVnode = newCh[newEndIdx] // 新节点尾位Vnode

let oldKeyToIdx, idxInOld, vnodeToMove, refElm

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
  if (isUndef(oldStartVnode)) {
    // 旧节点首位Vnode为空,旧节点首位元素向后移动。
    oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
  } else if (isUndef(oldEndVnode)) {
    // 旧节点尾位Vnode为空,旧节点尾位元素向前移动。
    oldEndVnode = oldCh[--oldEndIdx]
  } else if (sameVnode(oldStartVnode, newStartVnode)) {
    // 旧节点首位Vnode 和 新节点首位Vnode 比较,sameVnode验证通过则复用vnode,调用patchVnode更新DOM,然后新旧节点首位向后移动。
    patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
    oldStartVnode = oldCh[++oldStartIdx]
    newStartVnode = newCh[++newStartIdx]
  } else if (sameVnode(oldEndVnode, newEndVnode)) {
    // 旧节点尾位Vnode 和 新节点尾位Vnode 比较,sameVnode验证通过则复用vnode,调用patchVnode更新DOM,然后新旧节点尾位向前移动。
    patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
    oldEndVnode = oldCh[--oldEndIdx]
    newEndVnode = newCh[--newEndIdx]
  } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
    // 旧节点首位Vnode 和 新节点尾位Vnode 比较。
    patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
    canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
    oldStartVnode = oldCh[++oldStartIdx]
    newEndVnode = newCh[--newEndIdx]
  } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
    patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
    canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
    oldEndVnode = oldCh[--oldEndIdx]
    newStartVnode = newCh[++newStartIdx]
  } else {
    if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
    idxInOld = isDef(newStartVnode.key)
      ? oldKeyToIdx[newStartVnode.key]
      : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
    if (isUndef(idxInOld)) { // New element
      createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
    } else {
      vnodeToMove = oldCh[idxInOld]
      if (sameVnode(vnodeToMove, newStartVnode)) {
        patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        oldCh[idxInOld] = undefined
        canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
      } else {
        // same key but different element. treat as new element
        createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
      }
    }
    newStartVnode = newCh[++newStartIdx]
  }
}

在while的对比逻辑里有一个比较重要的函数 sameVnode(diff核心)

function sameVnode (a, b) {
  return (
    a.key === b.key && (
      (
        a.tag === b.tag &&
        a.isComment === b.isComment &&
        isDef(a.data) === isDef(b.data) &&   // data 里包括 props
        sameInputType(a, b)
      ) || (
        isTrue(a.isAsyncPlaceholder) &&
        a.asyncFactory === b.asyncFactory &&
        isUndef(b.asyncFactory.error)
      )
    )
  )
}

sameVnode 用来判断 VNode 是否可被复用(是否发生变化),首先判断的就是 key 是否一致,当 key 一致才会对VNode的其他属性进行比较。

这里得到一条结论就是key变了DOM势必重新渲染,这和文章开头vue官网的例子是一致的。

关于sameVnode要注意的一点是当我们没有给元素绑定key值的时候 a.key、b.key 取到的值是 undefined,undefined === undefined 也是通过的,所以不绑key值VNode也可能被复用。

最终基于 key 的变化重新排列元素顺序,移除 key 不存在的元素,增加新增的元素。

这就是diff的大致过程,比对出最小的必须修改单元,更新真实的DOM。

为什么不能用index做为v-for的key

VNode的设计目的是减少对DOM的操作,diff的目的是复用VNode,计算出修改的最小单元,以下面的渲染逻辑为例我们看用index做key绑定会带来什么问题。

例1(依赖props仅仅是性能的浪费)

<template>
  <div>
    <p v-for="(item, index) in arr" :key="index">{{item}}</p>
  </div>
</template>

<script>
export default {
  data() {
    return {
      arr: ['a', 'b', 'c', 'd', 'e'],
    }
  },
  methods: {
    del(index = 0){
      this.arr.splice(index, 1)
    }
  }
}

上面的例子当我们调用 del(0) 删掉第一项之后,key和props的对应关系如下:

old     new
0, a -> 0, b
1, b -> 1, c
2, c -> 2, d
3, d -> 3, e
4, e -> 

在diff的sameVnode阶段,b、c、d、e 的props完全没变,但是由于index变了导致key变了,b、c、d、e 无法复用必须重新渲染,破坏了设计者的优化机制造成性能浪费。

上面这种情况还不是最糟糕的,diff的时候因为props变了,使得视图可以正确渲染,那如果模板不依赖状态会发生什么情况呢?看下面的例子。

例2(不依赖props视图渲染会异常)

<template>
  <div class="wrap">
    <div class="box">
      <div v-for="(item, index) in test1" :key="index" @click="del1(index)">1</div>
    </div>
    <div class="box">
      <div v-for="(item, index) in test2" :key="item" @click="del2(index)">1</div>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      test1: ['a', 'b', 'c', 'd', 'e'],
      test2: ['a', 'b', 'c', 'd', 'e']
    }
  },
  methods: {
    del1(index){
      this.test1.splice(index, 1)
    },
    del2(index){
      this.test2.splice(index, 1)
    }
  }
}
</script>

<style scoped lang="scss">
.wrap{
  display: flex;
  .box{
    width: 50%;
    div{
      background: pink;
      margin: 10px;
      cursor: pointer;
    }
  }
}
</style>

因为模板不依赖状态,删除某一项的时候很难分辨,所以手动在控制台做一下标记,我们发现当用index做key的时候不管删除的是第几项,视图渲染上都是把最后一项删除掉了。

因为默认渲染出来key依次是 0、1、2、3、4,这时候删除任意一项key都会变成0、1、2、3,关键是在sameVnode阶段当前案例不依赖props,所以导致在新旧对比的时候只比较了key,结果每次不管怎么操作都是删掉最后一项。

例3(不依赖props 渲染Math.random)

想要达到不依赖状态的目的可以尝试把text节点改成Math.random,这样测试起来比较直观。

<template>
  <div class="wrap">
    <div class="box">
      <div v-for="(item, index) in test1" :key="index" @click="test1.splice(index, 1)">
        {{Math.floor(Math.random() * 20)}}
      </div>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      test1: ['a', 'b', 'c', 'd', 'e']
    }
  }
}
</script>

运行上面的例子发现,每次触发click事件Math.random都会重新渲染,并没有像我们预期的那样,这是因为文本节点会被直接替换掉,导致Math.random的多次渲染,我们把Math.random封装到组件里就会执行key和props的对比逻辑。

<template>
  <div class="wrap">
    <div class="box">
      <div v-for="(item, index) in test1" :key="index" @click="test1.splice(index, 1)">
        <RenRandom />  <!-- RenRandom 不可依赖 item -->
      </div>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      test1: ['a', 'b', 'c', 'd', 'e']
    }
  },
  components: {
    RenRandom: {
      template: '<i>{{Math.floor(Math.random() * 20)}}</i>'
    }
  }
}
</script>

这回和我们的预期一致了,Math.random只会在初始化渲染一次,不管怎么操作每次被删除的都是列表最后一项。

例4(动画异常)

最后再看一个和动画有关的例子

<template>
  <transition-group name="list">
    <div class="item" v-for="(item, index) in arr" :key="index">
      <span>{{item}}</span>
      <button @click="remove(index)">remove</button>
    </div>
  </transition-group>
</template>

<script>
  export default {
    data() {
      return {
        arr: ['a', 'b', 'c', 'd', 'e']
      }
    },
    methods: {
      remove(index) {
        this.arr.splice(index, 1)
      }
    }
  }
</script>

<style scoped lang="scss">
.item {
  height: 50px;
  line-height: 50px;
  overflow: hidden;
  background: #ddd;
  border-bottom: 1px solid #888;
}
.list-enter-active, .list-leave-active {
  transition: all 1s;
}
.list-enter-from, .list-leave-to {
  height: 0;
}
</style>

其实就是把上面第一个例子加了动画,以删除第一项为例分析动画异常的原因。

old     new
0, a -> 0, b
1, b -> 1, c
2, c -> 2, d
3, d -> 3, e
4, e -> 

虽然视图渲染正常,但是根据key可以看出来实际删除的是最后一项,所以动画在最后一项上播放,视图的正常是因为props的更新。

不绑定v-for的key会带来什么问题

上面说过如果在v-for的时候不绑定key,那么在sameVnode阶段 undefined === undefined 永远是成立的,直接对 props 进行比对,这就是官网说 “如果不使用 key,Vue 会使用一种最大限度减少动态元素并且尽可能的尝试就地修改/复用相同类型元素” 的原因,不使用key因为最大程度上复用了元素所以性能是比较高的,但是因为无法确定元素的唯一性不保证特殊情况能准确的更新列表项的状态。

为什么不能用随机数作为key

<div :key="Math.random()" v-for="item in list" />

key的原则是和value有一一对应的稳定关系,且不允许重复,这样diff的对比才能生效,当用Math.random做key时,key完全是动态的,diff形同虚设,所以不允许这么做。

还有一种做法是在视图渲染状态之前先对状态做一次加工,在状态上绑定一个Math.random()值,当做key,这种做法带来的问题是如果数据需要提交给服务端那么我们写的key还需要在提交前手动过滤掉,这种做法也是不推荐的。

key到底要怎么搞

理想的key和元素应该具备唯一且稳定的映射关系,这样无论对列表进行什么操作,映射关系是不会变的,而index只是元素在列表中的排序,一旦删除、移动、添加了元素,索引就会发生变化,key的首选是数据项的id,但是实际业务中往往服务端不能百分百的提供id,mock数据又很难保证id值唯一,部分业务场景列表项是前端递增产出的,在这种种情况下就需要前端实现key绑定的方案了,看下面的代码。

<template>
  <div>
    <p v-for="(item, index) in list" :key="getKey(item)">
      value: {{item}}  key: {{getKey(item)}}
      <button @click="del(index)">del</button>
    </p>
    <button @click="add">add</button>
  </div>
</template>

<script>
const keyWeakMap = new WeakMap()
let uid = 0

export default {
  data() {
    return {
      list: [{a: 1}, {a: 1}, {a: 2}]
    }
  },
  methods: {
    getKey(item) {
      const key = keyWeakMap.get(item)
      if(key) return key
      keyWeakMap.set(item, ++uid)
      return uid
    },
    add(){
      this.list.unshift({a: this.list.length + 1})
    },
    del(index){
      this.list.splice(index, 1)
    }
  }
}
</script>

我们通过WeakMap的键支持object的特性用它来维护数据项item和绑定key之间的关系,这样就在整个生命周期保证了key的稳定和映射关系,并且不会对数据造成污染。

当然也有缺点,因为WeakMap的键必须是object,不支持基本类型,所以当list为 [1, 2, 3] 时,这种做法就不适用了。

MDN:WeakMap

总结

  • 不允许用index绑定key,因为index作为索引是脆弱的,增删操作之后极易发生变化,不能代表元素本身,理想的key和元素应该具备唯一且稳定的映射关系,数据项的id是最佳选择,当id不可用时也可考虑用WeakMap维护key和数据项的映射关系。

  • 当用index绑定了key之后,如果当前列表项的渲染是依赖props的,那么由于props的响应看起来一切正常,但是如果有渲染动画就会暴露每次操作最后一项的异常。

  • 当用index绑定了key之后,如果当前列表项的渲染不依赖props(包含非文本节点的组件),在diff阶段会发生异常,导致视图错乱。

  • vue在diff阶段对文本节点会直接替换,不管key是否发生变化,会用“新文本节点”替换“旧文本节点”。

  • 如果不使用 key,vue会最大程度对元素进行复用,所以性能会比较好,但是由于没有key不保证数据的准确响应。

  • key除了在v-for上还可以用在其他元素上。

用index做为key会错误的复用旧节点。