vue2.7中的dom-diff是如何做的

113 阅读8分钟

vue中的dom-diff

虚拟dom的概念最早来自react,后面vue也引入了这一编程思想。在javascript中用js对象来描述一个DOM节点来实现。通常我们所说的虚拟dom比真实dom速度快,主要是体现在更新阶段,由于虚拟dom最终还是要转换成真实DOM,因子在创建阶段并不比真实DOM快,但更新阶段,由于我们改动DOM就会立即对DOM进行真实的增删改,而没有办法服复用已有DOM,真实的DOM上是有很多属性方法的,众所周知DOM操作是很大的开销。而虚拟dom可以让我们用js预先处理DOM,明确哪些DOM需要操作修改,减少了真实的DOM操作,因而速度快。

准备阶段

为了更好的看这个diff过程,我们先创建下面的数据,初始化的时候给一组数据,更新的时候我们更换顺序,新增和删除部分数据

var app = new Vue({
  template: `<div>
    <div v-for="user in userlist" :key="user.id">{{user.name}}</div>
    <div>
      <button @click="updateUserList">更新用户数据</button>
    </div>
  </div>`,
  // app initial state
  data: {
    userlist: [
      {
        name: 'lisi',
        id: 1
      },
      {
        name: 'wangwu',
        id: 2
      },
      {
        name: 'zhaoliu',
        id: 3
      },
      {
        name: 'sunqi',
        id: 4
      }
    ]
  },
  methods: {
    updateUserList() {
      this.userlist = [
        {
          name: 'zhangsan',
          id: 5
        },
        {
          name: 'lisi',
          id: 1
        },
        {
          name: 'zhaoliu',
          id: 3
        },
        {
          name: 'wangwu333',
          id: 2
        },
        {
          name: 'sunqi444',
          id: 6
        }
      ]
    }
  }
})

// mount
app.$mount('#app')

debugger看dom-diff流程

下面先看一下debugger流程熟悉一下过程,在梳理其中的关键代码。断点直接打到下图位置,也可以从vue开始执行的位置一步步进入到这里。第一次没有prevVnode,进入patch主要是创建元素。

image-20250705152104903.png

image-20250705161713103.png

进入patch方法,进入createElm创建元素流程,这里会判断创建的元素是什么类型,元素、注释、文本操作上会不同。

image-20250705162011531.png

patch流程走完会进入渲染流程,这里不继续展开了。下面看一下更新流程。

更新操作前面一章有介绍,会触发拦截器的set操作,通知对应的watcher进行更新。

image-20250705162407723.png

会再次进入到update方法中,走更新流程

image-20250705162745331.png

image-20250705162912858.png

image-20250705163337704.png

这里是diff算法的全部过程,后面详细介绍一下

image-20250705163905859.png

在dom-diff过程中页面也就随着更新了,diff结束页面完全渲染成新的UI界面。

对应源码分析

下面详细看一下源码的位置以及对应代码的实现

// 文件位置 src/core/instance/lifecycle.ts
export function lifecycleMixin(Vue: typeof Component) {
  // 这个方法前面也有介绍过,在走更新视图是会调用该方法
  // 这次主要看dom-diff相关的patch部分,其他代码就省略了
  Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
    const vm: Component = this
    const prevEl = vm.$el
    const prevVnode = vm._vnode
    const restoreActiveInstance = setActiveInstance(vm)
    vm._vnode = vnode
    // 第一次进入prevVnode为空
    if (!prevVnode) {
      // 初始化渲染,基本上走的是patch创建流程
      vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
    } else {
      // 当更新数据需要再次更新视图时,会进入更新patch流程
      vm.$el = vm.__patch__(prevVnode, vnode)
    }
    ...
  }

}
// 文件目录 src/platforms/web/runtime/index.ts
// 在运行时 如果是浏览器环境会将patch方法挂载上
// install platform patch function
Vue.prototype.__patch__ = inBrowser ? patch : noop

// 文件目录 src/platforms/web/runtime/patch.ts
export const patch: Function = createPatchFunction({ nodeOps, modules })
// 文件目录 src/core/vdom/patch.ts
// createPatchFunction方法很大包含了很多vnode相关操作方法,
// 下面将方法名留着,先看一下结构,具体代码后面单独罗列
// 核心方法 createElm、createComponent、createChildren、updateChildren、patchVnode
// 主要返回了patch方法,这次重点先分析一下对应逻辑
export function createPatchFunction(backend) {
  let i, j
  const cbs: any = {}
  // 这里依赖运行时,将运行时封装的创建元素方法引入进来
  const { modules, nodeOps } = backend

  for (i = 0; i < hooks.length; ++i) {
    cbs[hooks[i]] = []
    for (j = 0; j < modules.length; ++j) {
      if (isDef(modules[j][hooks[i]])) {
        cbs[hooks[i]].push(modules[j][hooks[i]])
      }
    }
  }
  
  function emptyNodeAt(elm) {
    return new VNode(nodeOps.tagName(elm).toLowerCase(), {}, [], undefined, elm)
  }

  function createRmCb(childElm, listeners) {
   ...
  }

  function removeNode(el) {
    ...
  }

  function isUnknownElement(vnode, inVPre) {
   ...
  }

  let creatingElmInVPre = 0

  function createElm(
    vnode,
    insertedVnodeQueue,
    parentElm?: any,
    refElm?: any,
    nested?: any,
    ownerArray?: any,
    index?: any
  ) {
   ...
  }

  function createComponent(vnode, insertedVnodeQueue, parentElm, refElm) {
   ...
  }

  function initComponent(vnode, insertedVnodeQueue) {
    ...
  }

  function reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm) {
   ...
  }

  function insert(parent, elm, ref) {
    ...
  }

  function createChildren(vnode, children, insertedVnodeQueue) {
    ...
  }

  function isPatchable(vnode) {
    ...
  }

  function invokeCreateHooks(vnode, insertedVnodeQueue) {
   ...
  }
  // css设置样式作用域
  function setScope(vnode) {
   ...
  }

  function addVnodes(
    parentElm,
    refElm,
    vnodes,
    startIdx,
    endIdx,
    insertedVnodeQueue
  ) {
   ...
  }

  function invokeDestroyHook(vnode) {
   ...
  }

  function removeVnodes(vnodes, startIdx, endIdx) {
   ...
  }

  function removeAndInvokeRemoveHook(vnode, rm?: any) {
   ...
  }
  // diff算法的核心逻辑
  function updateChildren(
    parentElm,
    oldCh,
    newCh,
    insertedVnodeQueue,
    removeOnly
  ) {
     ...
  }

  function checkDuplicateKeys(children) {
   ...
  }

  function findIdxInOld(node, oldCh, start, end) {
    ...
  }

  function patchVnode(
    oldVnode,
    vnode,
    insertedVnodeQueue,
    ownerArray,
    index,
    removeOnly?: any
  ) {
   ...
  }

  function hydrate(elm, vnode, insertedVnodeQueue, inVPre?: boolean) {
     ...
  }

  function assertNodeMatch(node, vnode, inVPre) {
   ...
  }
  // 提供给外部需要比对vnode调用的方法
  return function patch(oldVnode, vnode, hydrating, removeOnly) {
    //判断等于undefined或者null
    if (isUndef(vnode)) {
      if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
      return
    }

    let isInitialPatch = false
    const insertedVnodeQueue: any[] = []
    // 如果oldVnode为undefined或者null,说明没有根元素
    if (isUndef(oldVnode)) {
      // 创建一个新的根元素
      isInitialPatch = true
      createElm(vnode, insertedVnodeQueue)
    } else {
      // 判断oldVnode是不是真实的元素
      const isRealElement = isDef(oldVnode.nodeType)
      if (!isRealElement && sameVnode(oldVnode, vnode)) {
        // 不是真实元素,oldVnode和vnode相同则进行patch操作
        patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
      } else {
        if (isRealElement) {
          // 服务端渲染相关,先不细看
          if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
            oldVnode.removeAttribute(SSR_ATTR)
            hydrating = true
          }
          if (isTrue(hydrating)) {
            if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
              invokeInsertHook(vnode, insertedVnodeQueue, true)
              return oldVnode
            } else if (__DEV__) {
              warn(
                'The client-side rendered virtual DOM tree is not matching ' +
                  'server-rendered content. This is likely caused by incorrect ' +
                  'HTML markup, for example nesting block-level elements inside ' +
                  '<p>, or missing <tbody>. Bailing hydration and performing ' +
                  'full client-side render.'
              )
            }
          }
          // 创建一个空的node进行替换
          oldVnode = emptyNodeAt(oldVnode)
        }

        const oldElm = oldVnode.elm
        const parentElm = nodeOps.parentNode(oldElm)

        // 创建新的节点node
        createElm(
          vnode,
          insertedVnodeQueue,
          // extremely rare edge case: do not insert if old element is in a
          // leaving transition. Only happens when combining transition +
          // keep-alive + HOCs. (#4590)
          oldElm._leaveCb ? null : parentElm,
          nodeOps.nextSibling(oldElm)
        )

        // 这段代码的作用是递归地更新父级占位节点的元素(elm),并根据节点类型执行相关的生命周期钩子。
        // update parent placeholder node element, recursively
        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)
              }
              // #6513
              // invoke insert hooks that may have been merged by create hooks.
              // e.g. for directives that uses the "inserted" hook.
              const insert = ancestor.data.hook.insert
              if (insert.merged) {
                // start at index 1 to avoid re-invoking component mounted hook
                // clone insert hooks to avoid being mutated during iteration.
                // e.g. for customed directives under transition group.
                const cloned = insert.fns.slice(1)
                for (let i = 0; i < cloned.length; i++) {
                  cloned[i]()
                }
              }
            } else {
              registerRef(ancestor)
            }
            ancestor = ancestor.parent
          }
        }

        // destroy old node
        if (isDef(parentElm)) {
          removeVnodes([oldVnode], 0, 0)
        } else if (isDef(oldVnode.tag)) {
          invokeDestroyHook(oldVnode)
        }
      }
    }

    invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
    return vnode.elm
  }
}
/*
1、key 相等
a.key === b.key
key 是区分节点的主要依据,通常用于优化列表渲染。

2、异步工厂函数相等
a.asyncFactory === b.asyncFactory
用于异步组件,确保是同一个异步组件工厂。

3、标签、注释、数据、类型一致
a.tag === b.tag:标签名一致(如都是 div)。
a.isComment === b.isComment:都是注释节点或都不是。
isDef(a.data) === isDef(b.data):data 都有定义或都没定义。
sameInputType(a, b):如果是 input 标签,还要判断 type 是否一致(比如 text 和 checkbox 不能复用)。

4、异步占位符特殊处理
isTrue(a.isAsyncPlaceholder) && isUndef(b.asyncFactory.error)
如果 a 是异步占位符,并且 b 的异步工厂没有报错,也认为是同一个节点。
*/
function sameVnode(a, b) {
  return (
    a.key === b.key &&
    a.asyncFactory === b.asyncFactory &&
    ((a.tag === b.tag &&
      a.isComment === b.isComment &&
      isDef(a.data) === isDef(b.data) &&
      sameInputType(a, b)) ||
      (isTrue(a.isAsyncPlaceholder) && isUndef(b.asyncFactory.error)))
  )
}
// 把虚拟节点转化成dom
function createElm(
    vnode,
    insertedVnodeQueue,
    parentElm?: any,
    refElm?: any,
    nested?: any,
    ownerArray?: any,
    index?: any
  ) {
    if (isDef(vnode.elm) && isDef(ownerArray)) {
     // 如果vnode有对应的DOM元素,并且属于某个数组
      // 这时需要克隆vnode,避免后续操作影响到原有vnode,防止patch过程出错
      vnode = ownerArray[index] = cloneVNode(vnode)
    }

    vnode.isRootInsert = !nested // for transition enter check
    if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
      return
    }

    const data = vnode.data
    const children = vnode.children
    const tag = vnode.tag
    if (isDef(tag)) {
      if (__DEV__) {
        if (data && data.pre) {
          creatingElmInVPre++
        }
       // 开发环境警告,不认识的自定义组件
        if (isUnknownElement(vnode, creatingElmInVPre)) {
          warn(
            'Unknown custom element: <' +
              tag +
              '> - did you ' +
              'register the component correctly? For recursive components, ' +
              'make sure to provide the "name" option.',
            vnode.context
          )
        }
      }

      vnode.elm = vnode.ns
        ? nodeOps.createElementNS(vnode.ns, tag)
        : nodeOps.createElement(tag, vnode)
      // 设置样式作用域
      setScope(vnode)
      递归创建子元素
      createChildren(vnode, children, insertedVnodeQueue)
      if (isDef(data)) {
        // create钩子(比如指令、事件)
        invokeCreateHooks(vnode, insertedVnodeQueue)
      }
      // 插入到父节点
      insert(parentElm, vnode.elm, refElm)

      if (__DEV__ && data && data.pre) {
        creatingElmInVPre--
      }
    } else if (isTrue(vnode.isComment)) {
      // 注释节点
      vnode.elm = nodeOps.createComment(vnode.text)
      insert(parentElm, vnode.elm, refElm)
    } else {
      // 文本节点
      vnode.elm = nodeOps.createTextNode(vnode.text)
      insert(parentElm, vnode.elm, refElm)
    }
  }

dom-diff算法的核心内容,所有的diff对比算法都在下面这个方法中

// 高效的对比和更新父节点下的一组子节点,以最小的DOM操作将旧的vnode列表更新为新的vnode列表
// parentElm:父 DOM 元素
// oldCh:旧的 vnode 子节点数组
// newCh:新的 vnode 子节点数组
// insertedVnodeQueue:插入 vnode 时的回调队列
// removeOnly:仅移除标志,主要用于 <transition-group>
function updateChildren(
  parentElm,
  oldCh,
  newCh,
  insertedVnodeQueue,
  removeOnly
) {

  // 通过头尾指针法,分别维护新旧数组的头尾索引和节点。
  // canMove 控制节点是否允许移动。
  let oldStartIdx = 0
  let newStartIdx = 0
  let oldEndIdx = oldCh.length - 1
  let oldStartVnode = oldCh[0]
  let oldEndVnode = oldCh[oldEndIdx]
  let newEndIdx = newCh.length - 1
  let newStartVnode = newCh[0]
  let newEndVnode = newCh[newEndIdx]
  let oldKeyToIdx, idxInOld, vnodeToMove, refElm
  const canMove = !removeOnly

  if (__DEV__) {
    // 判断新节点key是否重复了
    checkDuplicateKeys(newCh)
  }
  // 当旧节点的起始位置小于等于旧节点的结束位置
  // 且新节点的起始位置小于等于新节点的结束位置
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    // 跳过被移动或置为 undefined 的节点。
    if (isUndef(oldStartVnode)) {
      oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
    } else if (isUndef(oldEndVnode)) {
      oldEndVnode = oldCh[--oldEndIdx]
    }
    // 头头:新旧头节点相同,直接 patch,指针右移
    else if (sameVnode(oldStartVnode, newStartVnode)) {
      // 主要patch内容和子节点,后面的操作一样就不说明了
      patchVnode(
        oldStartVnode,
        newStartVnode,
        insertedVnodeQueue,
        newCh,
        newStartIdx
      )
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
    } 
    // 尾尾:新旧尾节点相同,直接 patch,指针左移
    else if (sameVnode(oldEndVnode, newEndVnode)) {
      patchVnode(
        oldEndVnode,
        newEndVnode,
        insertedVnodeQueue,
        newCh,
        newEndIdx
      )
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
    } 
    // 头尾:旧头和新尾相同,patch 并将旧头节点移动到旧尾后面。
    else if (sameVnode(oldStartVnode, newEndVnode)) {
      // Vnode moved right
      patchVnode(
        oldStartVnode,
        newEndVnode,
        insertedVnodeQueue,
        newCh,
        newEndIdx
      )
      canMove &&
        nodeOps.insertBefore(
          parentElm,
          oldStartVnode.elm,
          nodeOps.nextSibling(oldEndVnode.elm)
        )
      oldStartVnode = oldCh[++oldStartIdx]
      newEndVnode = newCh[--newEndIdx]
    } 
    // 尾头:旧尾和新头相同,patch 并将旧尾节点移动到旧头前面
    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)) {
       //  如果新头节点在旧节点中找不到(key 匹配),说明是新节点,直接创建。
        createElm(
          newStartVnode,
          insertedVnodeQueue,
          parentElm,
          oldStartVnode.elm,
          false,
          newCh,
          newStartIdx
        )
      } else {
        // 如果找到了,patch 并移动到合适位置。
        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 {
          // key 相同但节点不同,视为新节点
          createElm(
            newStartVnode,
            insertedVnodeQueue,
            parentElm,
            oldStartVnode.elm,
            false,
            newCh,
            newStartIdx
          )
        }
      }
      newStartVnode = newCh[++newStartIdx]
    }
  }
  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)
  }
}

到这里vue中的dom-diff相关的流程和核心代码逻辑就看完了,真个dom-diff算法是很高效的,静态分析、diff算法都大大提升了执行效率。总结一下diff算法:主要通过新旧头头节点比较、新旧尾尾节点比较、新旧头尾节点比较、新旧尾头节点比较,将重复dom找到通过移动以达到复用,剩余不同的情况则查找新节点在旧节点中是否存在,存在就移动旧节点到新位置,不存在就创建;当上面逻辑走完还剩余的情况就是新节点还有剩余,遍历创建剩余的新节点,或者旧节点还有剩余,遍历删除旧的节点。