Vue源码系列之Diff算法

2,040 阅读7分钟

什么是Diff算法

众所周知,像VUE这类MVVM架构的框架,在VM层面实现View和Model的双向绑定,通过构建虚拟DOM作为View的映射。当Model数据改变触发虚拟DOM发生改变,VM层通过优化算法,用成本较小的方式对比出虚拟DOM和真实DOM的差异,作用于真实DOM中,以完成视图更新,这个过程便是Diff

为什么要使用Diff

当数据发生改变时,我们期望的是视图也对应发生变化,但如果无论是触发任何数据变化,都会将视图所有真实DOM树中的节点都进行更新,这在用户体验上是开发者不想看到的,Diff的好处是只对虚拟DOM和真实DOM的差异部分进行操作更新。

事实上虚拟DOM并不一定比直接操作DOM来的快(参考尤大的解答:www.zhihu.com/question/31…), 虚拟DOM 最重要的价值不是性能,它使开发者脱离DOM操作,将精力放在业务上,框架替开发者完成相应的DOM操作,使用虚拟DOM可以使平台不再局限于web,更多如weex、React Native等,而Diff就是对DOM操作过程的优化

查看源码前提

vue源码中存在大量工具函数如isUndef,isDef,isTrue等,是用来做类型判断的函数,接下来文章中出现的源码也使用到了这三个函数

export function isUndef (v: any): boolean %checks {
  return v === undefined || v === null
}
​
export function isDef (v: any): boolean %checks {
  return v !== undefined && v !== null
}
​
export function isTrue (v: any): boolean %checks {
  return v === true
}

触发流程

那么从新建vue实例到数据更新,是在哪里使用了Diff呢,我们一起往下看:

当vue新建实例时会调用mountComponent

// src\platforms\web\runtime\index.js
import { mountComponent } from 'core/instance/lifecycle'
Vue.prototype.$mount = function () {
  return mountComponent(this, el, hydrating)
}
// src\core\instance\lifecycle.js
export function mountComponent () {
    // ...
    updateComponent = () => {
      vm._update(vm._render())
    }
    new Watcher(vm, updateComponent, noop, ...)
}

mountComponent会为实例创建Watcher,并将回调函数vm._update(vm._render())传入Watcher,这样数据更新后Watcher便可以通过_update更新视图,其中_render是用于生成对应Vnode树

// src\core\instance\render.js
Vue.prototype._render = function () {
    // ...
    return vnode
}

_update函数用于将新的Vnode更新至视图

// src\core\instance\lifecycle.js
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
    // ...
    if (!prevVnode) { 
      // initial render
      vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
    } else {
      // updates
      vm.$el = vm.__patch__(prevVnode, vnode)
    }
  }

其中__patch__就是将oldVnode和newVnode进行对比,并输出结果挂载至$el更新视图。从上面的源码我们可以看到,无论是初次加载还是数据变化引发的视图更新,都会调用__patch__ ,__patch__是Diff过程的主要使用场景

patch函数

那么__patch__函数是从哪里来的呢? __patch__其实就是patch函数挂载在Vue.prototype上的别名

patch函数是Diff过程的主要入口函数,是通过createPatchFunction创建新的patch函数并挂载到Vue.prototype上

// src\platforms\web\runtime\patch.js
export const patch: Function = createPatchFunction({ nodeOps, modules })
​
// src\platforms\web\runtime\index.js
import { patch } from './patch'
Vue.prototype.__patch__ = inBrowser ? patch : noop

patch函数是对oldVnode和newVnode的差异进行区分处理,并返回处理后的DOM元素

// src\core\vdom\patch.js
export function createPatchFunction (backend) {
    // ...
    return function patch (oldVnode, vnode, hydrating, removeOnly) {
    // 如果新节点不存在,销毁旧节点
    if (isUndef(vnode)) {
      if (isDef(oldVnode)) invokeDestroyHook(oldVnode) 
      return
    }
    // 如果旧节点不存在,为新节点创建节点
    if (isUndef(oldVnode)) {
      isInitialPatch = true
      createElm(vnode, insertedVnodeQueue)
    } else {
      // 新节点和旧节点都存在
      const isRealElement = isDef(oldVnode.nodeType) // 是否为真实DOM元素
      if (!isRealElement && sameVnode(oldVnode, vnode)) {  // 如果新旧节点为同一类型,且旧节点是Vnode
        // 进行更深层次的比较
        patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly) 
      } else {  // oldVnode 为真实DOM
        // 替换已有的元素
        const oldElm = oldVnode.elm
        const parentElm = nodeOps.parentNode(oldElm)
​
        // 创建新的DOM元素
        createElm(
          vnode,
          insertedVnodeQueue,
          oldElm._leaveCb ? null : parentElm,
          nodeOps.nextSibling(oldElm)
        )
​
        // 递归更新父节点
        if (isDef(vnode.parent)) {
          let ancestor = vnode.parent
          const patchable = isPatchable(vnode)
          while (ancestor) {
            for (let i = 0; i < cbs.destroy.length; ++i) {
              cbs.destroy[i](ancestor)
            }
            ancestor.elm = vnode.elm
            if (patchable) {
              for (let i = 0; i < cbs.create.length; ++i) {
                cbs.create[i](emptyNode, ancestor)
              }
              const insert = ancestor.data.hook.insert
              if (insert.merged) {
                for (let i = 1; i < insert.fns.length; i++) {
                  insert.fns[i]()
                }
              }
            } else {
              registerRef(ancestor)
            }
            ancestor = ancestor.parent
          }
        }
​
        // 销毁旧的节点
        if (isDef(parentElm)) {
          removeVnodes([oldVnode], 0, 0)
        } else if (isDef(oldVnode.tag)) {
          invokeDestroyHook(oldVnode)
        }
      }
    }
    invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
    // 返回新节点对应的DOM元素
    return vnode.elm
  }
}

patch函数中对于新旧节点为同一类型的Vnode,使用了patchVnode函数进行更称层次的处理

patchVnode 函数

// src\core\vdom\patch.js
function patchVnode (
    oldVnode,
    vnode,
    insertedVnodeQueue,
    ownerArray,
    index,
    removeOnly
  ) {
    const elm = vnode.elm = oldVnode.elm // 获取真实DOM// 如果新旧节点都为静态且是被克隆或v-once只加载一次,直接赋值
    if (isTrue(vnode.isStatic) &&
      isTrue(oldVnode.isStatic) &&
      vnode.key === oldVnode.key &&
      (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
    ) {
      vnode.componentInstance = oldVnode.componentInstance
      return
    }
    
    
    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 (process.env.NODE_ENV !== 'production') {
          checkDuplicateKeys(ch)
        }
        if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '') // 文本节点直接setTextContent
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue) // 添加DOM
      } else if (isDef(oldCh)) { // 新子节点不存在
        removeVnodes(oldCh, 0, oldCh.length - 1) //移除DOM
      } else if (isDef(oldVnode.text)) { // 新旧子节点都不存在
        nodeOps.setTextContent(elm, '') // 删除文本
      }
    } else if (oldVnode.text !== vnode.text) { // 新节点是文本节点且文本不同
      nodeOps.setTextContent(elm, vnode.text)
    }
   
  }

patchVnode 函数对新旧节点的子节点进行处理。如果两者都有子节点,则用updateChildren函数进行处理

updateChildren 函数

updateChildren 函数是Diff算法最重要的实现函数,对新旧节点的子节点进行对比和处理,使用首尾指针法,双指针的方式进行对比,以减少对比次数,提升性能

  function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    let oldStartIdx = 0 // old首指针
    let newStartIdx = 0 // new首指针
    let oldEndIdx = oldCh.length - 1  // old尾指针
    let oldStartVnode = oldCh[0]    // old首指针当前指向的节点
    let oldEndVnode = oldCh[oldEndIdx] // old尾指针当前指向的节点
    let newEndIdx = newCh.length - 1  // new尾指针
    let newStartVnode = newCh[0]    // new首指针当前指向的节点
    let newEndVnode = newCh[newEndIdx]  // new尾指针当前指向的节点
    let oldKeyToIdx, idxInOld, vnodeToMove, refElm
​
    // removeOnly is a special flag used only by <transition-group>
    // to ensure removed elements stay in correct relative positions
    // during leaving transitions
    const canMove = !removeOnly
​
    if (process.env.NODE_ENV !== 'production') {
      checkDuplicateKeys(newCh) // 检查key是否相同
    }
    
    // 循环对比首尾节点
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { 
      // 对比过程 (*文章后面会对这部分详解*)
      if (isUndef(oldStartVnode)) {
        oldStartVnode = oldCh[++oldStartIdx]
      } else if (isUndef(oldEndVnode)) {
         oldEndVnode = oldCh[--oldEndIdx]
      } else if (sameVnode(oldStartVnode, newStartVnode)) {
        patchVnode(oldStartVnode, newStartVnode, ...)
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        patchVnode(oldEndVnode, newEndVnode, ...)
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldStartVnode, newEndVnode)) {
         patchVnode(oldStartVnode, newEndVnode, ...)
        canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
        oldStartVnode = oldCh[++oldStartIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldEndVnode, newStartVnode)) {
        patchVnode(oldEndVnode, newStartVnode, ...)
        canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
      } else {
        // ...
      }
    }
    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)
    }
  }

查看updateChildren 函数的对比过程我们可以发现vue是如何应用首尾指针法的,看源码比较抽象,我们用图片简单概括一下:

通过每一轮四个指针的两两对比发现是否有可复用节点,再根据不同情况进行节点处理,下面我们看看updateChildren 函数中对于每一种情况是如何做处理的:

1、oldStartVnode节点不存在,oldStartIdx向后移动,进行下轮比较

if (isUndef(oldStartVnode)) {
  oldStartVnode = oldCh[++oldStartIdx]
}

2、oldEndVnode节点不存在,oldEndIdx向前移动,进行下轮比较

else if (isUndef(oldEndVnode)) {
  oldEndVnode = oldCh[--oldEndIdx]
}

3、首首对比,oldStartVnode 和 newStartVnode 为相同节点 ,递归对比这两个节点的子节点,oldStartIdx和newEndIdx同时向后移动,进行下轮比较

else if (sameVnode(oldStartVnode, newStartVnode)) {
  patchVnode(oldStartVnode, newStartVnode, ...)
  oldStartVnode = oldCh[++oldStartIdx]
  newStartVnode = newCh[++newStartIdx]
}

4、尾尾对比,oldEndVnode 和 newEndVnode 为相同节点 ,递归对比这两个节点的子节点,oldEndIdx和newEndIdx同时向前移动,进行下轮比较

else if (sameVnode(oldEndVnode, newEndVnode)) {
  patchVnode(oldEndVnode, newEndVnode, ...)
  oldEndVnode = oldCh[--oldEndIdx]
  newEndVnode = newCh[--newEndIdx]
}

5、首尾对比,oldStartVnode 和 newEndVnode 为相同节点 ,递归对比这两个节点的子节点,newEndIdx向前移动,oldStartIdx向后移动,进行下轮比较

else if (sameVnode(oldStartVnode, newEndVnode)) {
  patchVnode(oldStartVnode, newEndVnode, ...)
  canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
  oldStartVnode = oldCh[++oldStartIdx]
  newEndVnode = newCh[--newEndIdx]
}

这里与之前步骤操作不同的是在递归处理子节点后做了移动处理,nodeOps为集成的真实DOM操作方法

// src\platforms\web\runtime\node-ops.js
export function insertBefore (parentNode: Node, newNode: Node, referenceNode: Node) {
  parentNode.insertBefore(newNode, referenceNode)
}
export function nextSibling (node: Node): ?Node {
  return node.nextSibling
}
export function createComment (text: string): Comment {
  return document.createComment(text)
}

6、尾首对比,oldEndVnode 和 newStartVnode 为相同节点 ,递归对比这两个节点的子节点,同步骤5做移动处理,oldEndIdx向前移动,newStartIdx向后移动,进行下轮比较

else if (sameVnode(oldEndVnode, newStartVnode)) {
  patchVnode(oldEndVnode, newStartVnode, ...)
  canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
  oldEndVnode = oldCh[--oldEndIdx]
  newStartVnode = newCh[++newStartIdx]
}

7、四个节点都不相同,使用createKeyToOldIdx将所有oldCh介于oldStartIdx和oldEndIdx之间的所有节点的key和index做一个映射,再去寻找是否有相同节点,做对应的处理

if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
// oldKeyToIdx => oldCh 中节点 key 和 index的集合 
        idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
// idxInOld => newStartVnode 在 oldCh 中相同节点(只是key相同)的index
        if (isUndef(idxInOld)) { // 如果找不到相同节点
          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] // 移动指针进行下一步

sameVnode函数

在查看updateChildren 函数源码时,我们不难发现,判断节点是否相同的方法就是sameVnode,这个方法对于我们日常开发很重要,为什么呢?让我们好好观察一下

function sameVnode (a, b) {
  return (
    // key 是否相同
    a.key === b.key &&
    // 异步组件
    a.asyncFactory === b.asyncFactory && (
      (
        // tag标签是否相同
        a.tag === b.tag &&
        // 注释节点
        a.isComment === b.isComment &&
        isDef(a.data) === isDef(b.data) &&
        // 是否为相同类型的input元素
        sameInputType(a, b) 
      ) || (
        isTrue(a.isAsyncPlaceholder) &&
        isUndef(b.asyncFactory.error)
      )
    )
  )
}

其中最重要的判断依据就是key和tag,如果某个节点key值不是固定的,当数据发生更新时,即使这个节点不需要变化,很大概率会被重新渲染。在开发中我们经常会使用v-for,当我们使用数组的index作为key值时,如果数据发生变化导致,比如在数组某个下标中插入一条数据,我们只希望页面插入对应这条数据的DOM,但现实是这个下标后的节点都会被重新渲染。举个例子

<li v-for="(item, index) in list" :key="index"> 
	{{item}}
</li>
...
data () {
    return {
        list: ['张三', '李四', '王五']
    }
}

这时渲染对应的结果是

<li key="0">张三</li>
<li key="1">李四</li>
<li key="2">王五</li>

当向list中添加一个元素时

list.unshift('六六')

渲染对应的结果是

<li key="0">六六</li>
<li key="1">张三</li>
<li key="2">李四</li>
<li key="3">王五</li>

'张三','李四','王五'因为key值改变导致对应DOM被重新渲染了!,这个不是我们想看到的,所以在开发中尽量不要使用index作为key去使用。

另外如果开发遇到这样一种情况,统一页面下相同的组件或节点使用v-if去切换使用,但页面并未重新渲染,是v-if失效了吗?并不是,是被当作可复用节点了,这种情况只需要手动加上不同的key值,vue就不会当作相同节点了