虚拟dom源码解析

174 阅读8分钟

本文将通过源码解析当组件挂载到页面上时以及数据更新触发组件重新渲染时,虚拟dom的patch以及生成真实dom的整个过程,并详细介绍diff算法。

入口文件 $mount src\platforms\web\runtime\index.js

Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && inBrowser ? query(el) : undefined
  // $mount挂载时执行mountComponent
  return mountComponent(this, el, hydrating)
}

当我们执行$mount挂载组件或组件依赖的数据更新时,执行mountComponent(),src\core\instance\lifecycle.js
来看看mountComponent做了什么事?

// 创建组件更新函数
updateComponent = () => {
  // 首先执行vm._render()返回vnode
  // 然后vnode作为参数执行update做dom更新
  vm._update(vm._render(), hydrating)
}

// 创建组件实例watcher,一个组件创建一个watcher
// $watcher/watcher选项都会额外创建watcher
new Watcher(vm, updateComponent, noop, {
before () {
  if (vm._isMounted && !vm._isDestroyed) {
    callHook(vm, 'beforeUpdate')
  }
}
  • _render() src\core\instance\render.js
// 获取render
const { render, _parentVnode } = vm.$options
// 获取vnode
vnode = render.call(vm._renderProxy, vm.$createElement)
  • _update() src\core\instance\lifecycle.js
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
    // 获得真实dom
    const prevEl = vm.$el
    // 获得虚拟dom
    const prevVnode = vm._vnode
    vm._vnode = vnode
    if (!prevVnode) {
      // 如果没有老vnode说明在初始化,传入vm.$el(真实dom)返回一个vnode
      vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
    } else {
      // 跟新周期直接diff(新老dom比对),返回新的dom
      vm.$el = vm.__patch__(prevVnode, vnode)
    }
  • --pacth-- src\platforms\web\runtime\index.js
// 定义组件实例补丁方法
Vue.prototype.__patch__ = inBrowser ? patch : noop

  • createPatchFunction 创建浏览器平台特有patch函数 "src\platforms\web\runtime\patch.js"
// 扩展操作:把通用模块和浏览器中特有的模块合并
// platformModules浏览器特有的操作
const modules = platformModules.concat(baseModules)

// 工厂函数   创建浏览器特有的patch函数,主要解决跨平台问题
// nodeOps所有的节点操作
export const patch: Function = createPatchFunction({ nodeOps, modules })
  • patch是如何工作的? "src\core\vdom\patch.js" 首先说一下patch的核心diff算法:通过同层的树节点进行比较,而非对树进行逐层搜索遍历的方式,所以时间复杂度为O(n)。 同级比较只做三件事:增删改。 具体操作为新节点不存在就删;旧节点不存在就增;都存在就比较类型,类型不同直接替换,类型相同执行更新。
  return function patch (oldVnode, vnode, hydrating, removeOnly) {
    // 如果新节点不存在
    if (isUndef(vnode)) {
      // 删除老节点
      if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
      return
    }

    // 老节点不存在
    if (isUndef(oldVnode)) {
      // 创建新节点
      createElm(vnode, insertedVnodeQueue)
    } else {  //新老节点都存在(改)
      // oldVnode有nodeType说明是dom元素
      const isRealElement = isDef(oldVnode.nodeType)
      // 不是元素(是虚拟节点)且相同节点(同一个key)   这是组件的更新
      if (!isRealElement && sameVnode(oldVnode, vnode)) {
        // 打补丁
        patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
      } else {
        //没有oldVnode为真实dom:说明不存在老虚拟dom,即当前是初始化过程,则将新的虚拟dom转化为真实dom并替换掉宿主元素(实际执行过程为将转化好的真实dom添加到页面中,并将宿主元素的dom删掉)
        if (isRealElement) {
          // 将该dom元素清空
          oldVnode = emptyNodeAt(oldVnode)
        }
        // 替换元素
        const oldElm = oldVnode.elm //拿到之前的元素 #demo
        const parentElm = nodeOps.parentNode(oldElm)  //父元素  body
        // 创建一个新元素,这时新老模板在界面同时出现
        createElm(
          vnode,
          insertedVnodeQueue,
          oldElm._leaveCb ? null : parentElm,
          nodeOps.nextSibling(oldElm)
        )
        
        // 如果parentElm存在
        if (isDef(parentElm)) {
          // 把之前老的节点删掉
          removeVnodes([oldVnode], 0, 0)
        } else if (isDef(oldVnode.tag)) {
        //调用destroy钩子
          invokeDestroyHook(oldVnode)
        }
      }
    }
  • patchVnode vue\src\core\vdom\patch.js
    两个vnode类型相同,就执行更新操作,包括三种类型操作:属性更新PROPS,文本更新TEXT,子节点更新REORDER
    patchVnode具体规则如下:
  1. 如果新旧Vnode都是静态的,同时它们的key相同(代表同一节点),静态节点可复用,更新componentInstance跳过。
  2. 新老节点均有children子节点,则对子节点进行diff操作,调用updateChildren,这个updateChildren也是diff的核心。
  3. 如果老节点没有子节点而新节点存在子节点,先清空老节点DOM的文本内容,然后为当前DOM节点加入子节点。
  4. 当新节点没有子节点而老节点有子节点的时候,则移除该DOM节点的所有子节点。
  5. 当新老节点都无子节点的时候,只是文本的替换。
function patchVnode (
    oldVnode,
    vnode,
    insertedVnodeQueue,
    ownerArray,
    index,
    removeOnly
  ) {
  
    // 两个vnode节点相同则直接返回
    if (oldVnode === vnode) {
      return
    }
    
    // 异步组件特殊处理
    if (isTrue(oldVnode.isAsyncPlaceholder)) {
      if (isDef(vnode.asyncFactory.resolved)) {
        hydrate(oldVnode.elm, vnode, insertedVnodeQueue)
      } else {
        vnode.isAsyncPlaceholder = true
      }
      return
    }
    
    // 静态节点可复用,更新componentInstance    <h1>abc</h1>
    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
    // 每次patch前,先执行属性、事件、样式等等更新操作
    if (isDef(data) && isPatchable(vnode)) {
      for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
      if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
    }
    
    // 开始判断children的各种情况
    // 如果这个vnode节点没有text文本时
    if (isUndef(vnode.text)) {
      if (isDef(oldCh) && isDef(ch)) {// 新老节点均有children子节点,则对子节点进行diff操作,调用updateChildren
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
      } else if (isDef(ch)) {// 如果老节点没有子节点而新节点有子节点,先清空elm的文本内容,然后为当前节点加入子节点
        if (process.env.NODE_ENV !== 'production') {
          checkDuplicateKeys(ch)
        }
        if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
      } else if (isDef(oldCh)) {//当新节点没有子节点而老节点有子节点的时候,则删除所有elm的子节点
        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)
    }
    if (isDef(data)) {
      if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
    }
  }

*updateChildren 子节点重排算法 vue\src\core\vdom\patch.js 主要作用是用一种高效的方式比对新旧两个Vnode的children得出最小操作补丁。执行出一个双循环是传统方式,vue针对web场景特点做了特别的算法优化,如下图:

在新老两组Vnode节点的左右头尾两侧都有一个变量标记,在遍历过程中这几个变量都会向中间靠拢。当oldStartldx>oldEndldx或者newStartldx>newEndldx时结束循环。
遍历规则: 首先,oldStartVnode、oldEndVnode与newStartVnode、oldEndVnode两两交叉比较,共有四种比较方法。

  • 当oldStartVnode和newStartVnode或者oldEndVnode和newEndVnode是相同节点,直接将该vnode节点进行patchVnode即可,不需要在遍历就完成了一次循环。

  • 如果oldStartVnode与newEndVnode相同节点,说明oldStartVnode已经跑到了newEndVnode后面去了,进行patchVnode的同时还需要将真实DOM节点移动到oldEndVnode后面。

  • 如果oldEndVnode与newStartVnode为相同节点,说明oldEndVnode已经跑到了newStartVnode前面去了,进行patchVnode的同时要将oldEndVnode对应的DOM移动到oldStartVnode对应DOM的前面。

  • 如果上述4种比较情况均不符合,则在oldVnode中找到与newStartVnode相同的节点,若存在执行patchVnode,同时将oldVnode中对应的DOM移动到oldStartVnode对应的DOM前面。

  • 如果newStartVnode在oldVnode节点中找不到相同节点,这时候会调用createElm创建一个新的DOM节点。

  • 当结束时oldStartIdx > oldEndIdx,这个时候旧的Vnode节点已经遍历完了,但是新的节点还没有。说明新的vnode节点实际上比老的vnode节点多,需要将剩下的新的vnode对应的dom插入到真实DOM最后。此时调用addVnodes(批量调用createElm接口)。

  • 当结束时,newStartIdx > newEndIdx时,说明新的vnode已经遍历完,但是老的节点还有剩余,需要从文档中把多余的节点删除。

function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    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
    
    //循环条件:任意起始索引超过结束索引就结束
    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, 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打补丁,老开移动到队尾。老开游标向后移动一位,新结束游标向前移动一位
        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打补丁,老开移动到队首。老开游标向前移动一位,新结束游标向后移动一位
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
      } else {//上述4种没有相同的,开始循环查找,在old vnode中找新开
       
        if (isUndef(idxInOld)) { //没有相同节点则创建一个新节点添加到队首
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        } else {
        //获取同key的老节点
          vnodeToMove = oldCh[idxInOld]
          if (sameVnode(vnodeToMove, newStartVnode)) {
          //如果新开节点与同key的老节点是同一个节点则进行pacthVnode,并将该老节点移动到队首
            patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
            oldCh[idxInOld] = undefined
            canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
          } else {
            //当新节点找到同样key的老节点,但是不是sameVnode的时候(比如tag不一样),创建新节点
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
          }
        }
        newStartVnode = newCh[++newStartIdx]
      }
    }
    //全部比较完成后,发现oldStartIdx > oldEndIdx的话,说明老节点已经遍历完了,新节点比老节点多。多出来的新节点批量创建加入到真实dom
    if (oldStartIdx > oldEndIdx) {
      refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
      addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
    } else if (newStartIdx > newEndIdx) {
    //全部比较完发现newStartIdx > newEndIdx,则说明新节点已经遍历完了,老节点比新节点多,需要将多余的老节点从dom中删除
      removeVnodes(oldCh, oldStartIdx, oldEndIdx)
    }
  }