Vue2.0详解diff算法

2,006 阅读8分钟

虚拟Dom以及diff算法的由来

我们都知道,Vue1.0中是没有虚拟Dom和diff算法的,具体可以查看我之前的文章Vue1.0原理刨析及实现,那么Vue2.0为什么要引入虚拟Dom和diff算法呢,因为Vue1.0有太多的闭包,小项目还可以,大项目就不适合,就会造成内存泄漏,所以引入虚拟Dom和diff算法成了必须要迈过的一道坎。

虚拟Dom(VNode)

假设我们的真实dom是:

<ul id="container">
    <li class="box" :key="user1">张三</li>
    <li class="box" :key="user2">李四</li>
</ul>

那么他对应的VNode就是:

<script>
let oldVNode = {
  tag: "ul",
  data: {
    staticClass: "container",
  },
  text: undefined,
  children: [
    {
      tag: "li",
      data: { staticClass: "box", key: "user1" },
      text: undefined,
      children: [
        { tag: undefined, data: undefined, text: "张三", children: undefined },
      ],
    },
    {
      tag: "li",
      data: { staticClass: "box", key: "user2" },
      text: undefined,
      children: [
        { tag: undefined, data: undefined, text: "李四", children: undefined },
      ],
    },
  ],
};
</script>

这时候修改一个li标签的内容

<ul id="container">
    <li class="box" :key="user1">张三123123123</li>
    <li class="box" :key="user2">李四</li>
</ul>

对应的虚拟dom就变成

let oldVNode = {
  tag: "ul",
  data: {
    staticClass: "container",
  },
  text: undefined,
  children: [
    {
      tag: "li",
      data: { staticClass: "box", key: "user1" },
      text: undefined,
      children: [
        { tag: undefined, data: undefined, text: "张三123123123", children: undefined },
      ],
    },
    {
      tag: "li",
      data: { staticClass: "box", key: "user2" },
      text: undefined,
      children: [
        { tag: undefined, data: undefined, text: "李四", children: undefined },
      ],
    },
  ],
};

diff

简单介绍

用一句话来概括就是:同层比较、深度优先

image.png

  • 同层比较?

    如果比较的过程中不是同层比较,那么时间复杂度会上升,不再是On

  • 深度优先?

    在你比较俩颗节点树的时候,就是一个递归的过程

执行过程

当我们this.key = xxx时,触发当前keysetter,并通过内部dep.notify()通知所有watcher进行更新,更新的时候就会调用patch方法。

image.png

patch

这个函数的作用就是:通过sameVnode()判断oldVnodenewVnode是否为同一种节点类型。

  • 是:调用patchVnode()进行diff算法
  • 否:直接替换

什么时候会走else?
举例:比如组件初始化的时候,没有oldVnode,那么Vue会传入一个真实dom(isRealElement就是为处理初始化定义的),显然sameVnode(a,b)结果为false,他们并不是同一种类型节点。

patch的核心代码:

function patch(oldVnode, newVnode) {
    const isRealElement = isDef(oldVnode.nodeType) //判断oldVnode是否是真实节点
    if (!isRealElement && sameVnode(oldVnode, vnode)) {
        // 更新周期走这里,diff发生的地方
        patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly) 
    } else {
        // 如果是真实dom,就转换为Vnode,赋值给oldVnode
        if (isRealElement) {
            oldVnode = emptyNodeAt(oldVnode) 
        }

        // replacing existing element
        const oldElm = oldVnode.elm
        const parentElm = nodeOps.parentNode(oldElm) // 得到真实dom的父节点

        // 将oldVnode转换为真实dom,并插入
        createElm(
            vnode,
            insertedVnodeQueue,
            oldElm._leaveCb ? null : parentElm,
            nodeOps.nextSibling(oldElm)
        )


        if (isDef(parentElm)) {
            removeVnodes([oldVnode], 0, 0) // 删除老的节点
        } else if (isDef(oldVnode.tag)) {
            invokeDestroyHook(oldVnode)
        }
    }
}

sameVnode

这个方法主要是用来比较传入的俩个vnode是否是相同节点。判断条件见如下代码:

function sameVnode (a, b) {
  return (
    a.key === b.key && // 比较key
    a.asyncFactory === b.asyncFactory && (
      (
        a.tag === b.tag && // 比较标签
        a.isComment === b.isComment && // 比较注释
        isDef(a.data) === isDef(b.data) && // 比较data
        sameInputType(a, b)
      ) || (
        isTrue(a.isAsyncPlaceholder) &&
        isUndef(b.asyncFactory.error)
      )
    )
  )
}

patchVnode

主要作用:比较俩个Vnode,包括三种类型操作:属性更新文本更新子节点更新

具体规则如下:

  1. 新老节点均有children子节点,则对子节点进行diff操作,调用updateChildren
  2. 如果新节点有子节点,而老节点没有子节点,先清空老节点的文本内容,然后为其新增子节点。
  3. 如果新节点没有子节点,而老节点有子节点,则移除该节点所有的子节点。
  4. 新老节点都没有子节点的时候,只是文本的替换。

patchVnode核心代码:

function patchVnode (oldVnode, vnode,) {
    if (oldVnode === vnode) {
      return
    } // 如果新节点等于老节点直接返回

    const elm = vnode.elm = oldVnode.elm // 将oldVnode的真实dom节点赋值给Vnode

    // 获取新旧节点的子节点数组
    const oldCh = oldVnode.children
    const ch = vnode.children

    // 如果新节点没文本,大概率有子元素
    if (isUndef(vnode.text)) {

      // 如果双方都有子元素
      if (isDef(oldCh) && isDef(ch)) {
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
      }
        
      // 如果新节点有子元素
      else if (isDef(ch)) {
        if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
      }
          
      // 如果老节点有子元素(走到这一步说明新节点没有子元素)
      else if (isDef(oldCh)) {
        removeVnodes(oldCh, 0, oldCh.length - 1)
      }
          
      // 如果老节点有文本(走到这一步说明新节点没有文本)
      else if (isDef(oldVnode.text)) {
        nodeOps.setTextContent(elm, '')
      }
    }

    // 如果老节点的文本 != 新节点的文本
    else if (oldVnode.text !== vnode.text) {
      nodeOps.setTextContent(elm, vnode.text)
    }
   
  }

上面的代码验证了我们上面说的四点规则,其中最主要的还是新旧节点都有子元素的时候的对比,也就是updateChildren

updateChildren

这个方式是patchVnode中的一个重要方法,也叫重排操作。主要进行新旧虚拟节点的子节点的对比,等通过sameVnode()找到相同节点时,再递归调用patchVnode

对比过程

  1. 旧首 => 新首
  2. 旧尾 => 新尾
  3. 旧首 => 新尾
  4. 旧尾 => 新首
  5. 如果以上都匹配不到,再以新vnode为准,依次遍历老节点,直到找到相同的节点之后,再调用patchVnode

备注:过程1~4你可以理解为Vue优化的一种手段,想想你平时使用Vue场景,要么在开头或结尾插入,要么只是单纯的修改某个值this.key = xxx),Vue考虑到了这种场景可能出现的频率很高,索性就做了这个优化,避免每次重复遍历,这样对性能提升很大。

image.png

接下来用一个实际例子,来看一下diff过程

描述:真实DOM和oldVnode是内容分别为a、b、cdiv,新的虚拟dom只是改变了原来节点的内容以及新增了一个内容为新ddiv,别的没有任何变化。需要注意的是每次比较都遵循上面的规则。

初始值:

  • oSIdx(oldVnode开头下标) = 0

  • oEIdx(oldVnode结尾下标) = 2

  • nSIdx(newVnode开头下标) = 0

  • nEIdx(newVnode结尾下标) = 3

image.png

  • 第一步
oldVnode[oSIdx] === newVnode[nSIdx]

描述:按照规则,先 旧首 => 新首sameVnode(a,b)结果为true,说明是相同节点。需要做的就是调用patchVnode(oldVnode,vNode)更新节点的内容,之后oSIdx++nSIdx++

image.png

  • 第二步
oldVnode[oSIdx] === newVnode[nSIdx]//注意:此时oSIdx为1,nSIdx为1

描述:此时分别比较oldVnodenewVnode对应的第二个节点,因为是循环,所以依然是重新执行规则 旧首 => 新首(下面的源码里面可以看到对应逻辑),sameVnode(a,b)结果为true,所以依然是调用patchVnode(oldVnode,vNode)更新节点内容。之后oSIdx++nSIdx++

image.png

  • 第三步
oldVnode[oSIdx] === newVnode[nSIdx]//注意:此时oSIdx为2,nSIdx为2

描述:这一步跟前两步一样,这里不做过多描述。之后oSIdx++nSIdx++

image.png

  • 第四步
oSIdx = 3  oEIdx = 2
nSIdx = 3  nEIdx = 3

描述:因为此时oSIdx>oEIdxnSIdx===nEIdx按照源码的逻辑,结束while循环),说明oldCh先遍历完,所以newCholdCh多,说明是新增操作,执行addVnodes(),将新节点插入到dom中。

image.png

附录: updateChildren核心源码,以及注释

 function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    let oldStartIdx = 0 // oldVnode开始下标
    let oldEndIdx = oldCh.length - 1 // oldVnode结尾下标
    let oldStartVnode = oldCh[0] // oldVnode第一个节点
    let oldEndVnode = oldCh[oldEndIdx] // oldVnode最后一个节点
    
    let newStartIdx = 0 // newVnode开始下标
    let newEndIdx = newCh.length - 1 // newVnode结尾下标
    let newStartVnode = newCh[0] // newVnode第一个节点
    let newEndVnode = newCh[newEndIdx] // newVnode最后一个节点
    let oldKeyToIdx, idxInOld, vnodeToMove, refElm

   
    const canMove = !removeOnly

    if (process.env.NODE_ENV !== 'production') {
      checkDuplicateKeys(newCh)
    }

    // 注意循环条件,只有oldVnode和newVnode的开始节点小于等于的时候才会循环
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      if (isUndef(oldStartVnode)) {
        oldStartVnode = oldCh[++oldStartIdx] // 这一步就是额外操作,如果oldStartVnode取不到元素,就向后移
      } else if (isUndef(oldEndVnode)) {
        oldEndVnode = oldCh[--oldEndIdx] // 这一步就是额外操作,如果oldEndVnode取不到元素,就向后移
      } 
      // 真正开始执行diff
      else if (sameVnode(oldStartVnode, newStartVnode)) {
        // 旧首新首比较
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        // 旧尾新尾比较
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldStartVnode, newEndVnode)) { 
        // 旧首新尾比较
        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)) { 
        // 旧尾新首比较
        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]
      }
    }
    
    // 循环结束后,判断是oldCh多,还是newCh多
    // 如果oldCh多 newCh少 就是删除
    // 如果oldCh少 newCh多 就是创建
    if (oldStartIdx > oldEndIdx) {
      refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
      addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
    } else if (newStartIdx > newEndIdx) {
      removeVnodes(oldCh, oldStartIdx, oldEndIdx)
    }
  }

为什么不能用index做key,有哪些危害。

平时for循环的时候,我们为什么一般不用index来做key?

下面我来举两个例子,什么情况会影响,什么情况不会影响,正确的key应该如何设置。

// 旧                                   // 新
<ul>                                    <ul>  
                                           <li :key="0"> user3 </li> 
    <li :key="0"> user1 </li>              <li :key="1"> user1 </li>
    <li :key="1"> user2 </li>              <li :key="2"> user2 </li>                               
</ul>                                   </ul>
                                

在上面的例子中,我们只在列表开头插入了一条,别的都不变。按照正常思维,user1、user2可以复用,只创建user3即可。但现在我们的key是index,当我们点击button执行插入操作的时候,我们看浏览器会如何解析?

index做key

<ul class="container">
  <li v-for="(item, index) in list" :key="index" class="box">{{ item }}</li>
</ul>
<button @click="addUser">添加</button>


<script>
export default {
  data() {
    return {
      list: ["user1", "user2", "user3"],
    };
  },
  methods: {
    addUser() {
      this.list.unshift("user5");
    },
  },
};
</script>

image.png

他直接会将li所有对应的节点全部更新!为什么会这样呢?这时候你就要回头看sameVnode(a,b)方法。要比较的节点key是相同的,tag是相同的,所以sameVnode()结果为true,就会直接调用patchVnode()将俩个相同节点进行文本更新

image.png

用我们的例子来说的话,进行了4次dom操作(3次更新,1次创建),本应该user4被创建,而现在创建的变成了user3根本没有复用这个概念!!!

item做key

如果我们用item来做key的话,看下浏览器会怎么更新?(正常业务的列表中都有id,一般都用id来做key)

<ul class="container">
  <li v-for="item in list" :key="item" class="box">{{ item }}</li>
</ul>
<button @click="addUser">添加</button>

image.png

是不是只会进行1次dom操作(1次创建),如此便实现了复用这个概念,更小的代价实现了更新。

image.png

点睛之笔

如果我button的方法是push而不是unshift,那么我们不管用index做key也好,item做key也好,都是一样的,都只会创建新节点,进行一次dom操作。

addUser() {
  this.list.push("user5");
},

这是因为diff的规则就是:

  1. 旧首 => 新首
  2. 旧尾 => 新尾
  3. 旧首 => 新尾
  4. 旧尾 => 新首
  5. 如果以上都匹配不到,再以新vnode为准,依次遍历老节点,直到找到相同的节点之后,再调用patchVnode

看到这里,有没有对diff的规则又加深了理解呢。