vue虚拟dom如何转为真实dom (简述vue中虚拟dom和diff算法)

vue虚拟dom如何转为真实dom,vue源码揭秘视频

找到 patch 出处

首先在 Vue.prototype._update 中会调用 vm._ patch_ 方法:

//source-code\vue\src\core\instance\lifecycle.js

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

vm._ patch_ 对应就是 Vue.prototype._ patch_ 方法:

Vue.prototype.__patch__ = inBrowser ? patch : noop

export const patch: Function = createPatchFunction({ nodeOps, modules })

最后在 createPatchFunction 方法中找到 patch 方法:

//source-code\vue\src\core\vdom\patch.js
export function createPatchFunction (backend) {
  const { modules, nodeOps } = backend
  //...
  return function patch (oldVnode, vnode, hydrating, removeOnly) {
    //...
    return vnode.elm
  }
}

oldVnode 和 vnode 在哪里定义?

oldVnode 的定义

在最初的 init 方法中,会向 $mount 方法传入 $options.el 属性:

Vue.prototype._init = function (options?: Object) {
	//...
  vm.$mount(vm.$options.el)
}

通过 query (querySelector) 选择器方法,获取对应的 dom 内容:

Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && query(el)
  //...
  return mount.call(this, el, hydrating)
}

el 赋值给 vm.$el

export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  vm.$el = el
  //...
}

再回到最初的 update 方法,看到 oldVnode 就是 vm.$el

if (!prevVnode) {
  // initial render
  vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
  // updates
  vm.$el = vm.__patch__(prevVnode, vnode)
}

当然首次 prevVnode 变量的值为 null,则会走到第一个 _ patch_ 方法进行首次渲染。

vnode 的定义

再回到 _update 方法,我们能看到它的入参列表中就有 vnode,再把 vnode 传给 _ patch_ 方法:

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

这个 vnode 对象 就是由 vm._render() 方法执行后得到:

Vue.prototype._render = function (): VNode {
  //...
	vnode = render.call(vm._renderProxy, vm.$createElement)
  //...
  return vnode
}
updateComponent = () => {
  vm._update(vm._render(), hydrating)
}

初始 elm 的挂载

emptyNodeAt 生成 oldVnode 节点

现在我们正式进入 patch 方法,看它最后 vnode.elm 的 dom 元素如何产生?

return function patch (oldVnode, vnode, hydrating, removeOnly) {
	//...
  return vnode.elm
}

我们的 vnode 目前是有定义的,之前已经看过了 render 方法的解析过程,并且 oldVnode 也是存在的(通过 query 选择器取得),所以将进入 else 判断:

return function patch (oldVnode, vnode, hydrating, removeOnly) {
	if (isUndef(vnode)) {
    if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
    return
  }
  //...
  if (isUndef(oldVnode)) {
  }else{
  	// patchNode
  }
  return vnode.elm
}

这里是 else 中的逻辑:

根据 oldVnode.nodeType 来判断是否是个真实元素 isRealElement,因为我们 AST 解析出来的虚拟节点是不包括 nodeType 属性的,所以 isRealElement=false

另外,hydrating 为 undefined,所以将直接通过 emptyNodeAt 添加一个空节点,vnode tag 属性为当前 dom 的 tagName

const isRealElement = isDef(oldVnode.nodeType)
if (!isRealElement && sameVnode(oldVnode, vnode)) {
  //...
} else {
  if (isRealElement) {
    if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
      //...
    }
    if (isTrue(hydrating)) {
      //...
    }
    // either not server-rendered, or hydration failed.
    // create an empty node and replace it
    oldVnode = emptyNodeAt(oldVnode)
  }
  //...
}

createElm 创建节点

之后会将原来 oldVnode 的 dom 数据保存到 oldVnode.elm 中,并获取父级元素:

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

之后将使用 vnode,通过 createElm 创建新的虚拟节点,并挂载到 parentElm 上:

// create new node
createElm(
  vnode,
  insertedVnodeQueue,
  oldElm._leaveCb ? null : parentElm,
  nodeOps.nextSibling(oldElm)
)

当然 createElm 也略复杂,但我们能看到其中的 createChildren insert 方法,也能猜到在做子节点的循环遍历,以及子节点的插入操作:

function createElm (
    vnode,
    insertedVnodeQueue,
    parentElm,
    refElm,
    nested,
    ownerArray,
    index
  ) {
	//...
  const data = vnode.data
  const children = vnode.children
  const tag = vnode.tag
  //...
  createChildren(vnode, children, insertedVnodeQueue)
  if (isDef(data)) {
    invokeCreateHooks(vnode, insertedVnodeQueue)
  }
  insert(parentElm, vnode.elm, refElm)
  //...
}

最后将 oldVnode 上的一些旧的事件监听、各种 hook 钩子一并移除掉,再销毁当前元素:

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

如此,将新生成的 vnode.elm 树渲染到 html 页面上了。

新 vnode 的更新

patch 中的 updateChildren

_update 方法里,第一个 if 判断中 patch 已经在首次渲染的时候走过了,对 vm._vnode 初始化了值(即,prevVnode 非 false),所以当后续数据触发更新,再次执行 _update,则会进入 else 中的 patch 方法:

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

顺着之前的 patch 逻辑,会发现 isRealElement 不再是 false 了,因为此时的 oldVnode 是虚拟节点,就不包含标准的 nodeType 属性,则会跳过 emptyNodeAt 方法,从而进入对应的 if 逻辑条件,进入到 patchVnode 方法:

if (!isRealElement && sameVnode(oldVnode, vnode)) {
  // patch existing root node
  patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
}

patchVnode 方法中,我们跳过多数目前不太关心的条件,直接进到解析子节点 updateChildren 的判断中,它会解析我们 oldVnode 和 vnode 中子节点,并逐层解析

function patchVnode (
oldVnode,
 vnode,
 insertedVnodeQueue,
 ownerArray,
 index,
 removeOnly
) {
  //...
  const elm = vnode.elm = oldVnode.elm
  //...
  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 (oldVnode.text !== vnode.text) {
    nodeOps.setTextContent(elm, vnode.text)
  }
  //...
}

updateChildren 的逻辑相当复杂,可以说是整个 vnode oldVnode 之间 diff 对比的核心:

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

    const canMove = !removeOnly
    //...

    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      if (isUndef(oldStartVnode)) {
        oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
      } 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)) { // 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]
      } 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 {
        //...
        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)
  }
}

所以下面我会根据一个简单的 demo,来示例新老节点更新的 diff 对比过程。

详解 updateChildren 中的 diff 过程

准备新老 node

通过 v-if 来切换的两个不同标签模板内容,来模拟新老节点(oldVnode 和 vnode):

vue虚拟dom如何转为真实dom,vue源码揭秘视频

通过 updateChildren 剥离出子节点

注意 text 第二级的子元素本篇不做判断。

vue虚拟dom如何转为真实dom,vue源码揭秘视频

4种解析条件判断过程

updateChildren 方法中,一般都会通过如下 4种核心判断,对不同子元素进行比较:

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
  if (isUndef(oldStartVnode)) {
  	//...
  }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)) { // 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]
  } 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 {
    //...
  }
}

首先,会用 oldStartIdxoldEndIdx newStartIdxoldEndIdx 来分别表示 oldCh newCh 子节点队列的起始和结束位置。

然后,依次对比 oldStartVnode newStartVnodeoldEndVnode newEndVnodeoldStartVnode newEndVnodeoldEndVnode newStartVnode 四种位置的对比方式来判断比较节点是否是"相同的"虚拟节点?

当然 sameVnode 方法是有特殊条件的,必须节点上 key 属性一致并且 tag、isComment、data、inputType 一致,或者和其他一些属性一致才算相同元素

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

下面展示就目前的 demo 节点是如何在这几种判断中"游走的":

首先 oldCh ch 的 start 坐标都在第一个元素。第一次:先对比新老节点的首个元素(1 vs 3):

vue虚拟dom如何转为真实dom,vue源码揭秘视频

目前,我们这两个元素分别是 span 标签和注释标签,肯定不符合 sameVnode 的判断。

然后第二次:判断两个末尾元素是否相同(2 vs 5):

vue虚拟dom如何转为真实dom,vue源码揭秘视频

很不凑巧,这两者也是不一致。

第三次,判断老节点的首个元素新节点的末元素(1 vs 5):

vue虚拟dom如何转为真实dom,vue源码揭秘视频

这两个 tag 都是 span 标签,虽然他们的内容 text 不一致,当然这会交给内部调用的 patchVnode 继续往下一级比较(这里第二级的 children 元素不做展开,最终他们是替换 text 内容):

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]
}

这里运气比较好,第三次就匹配到了,如果这次没有匹配中,将拿老节点的末个元素新节点的首个元素对比(2 vs 3,这里不做展开)

该判断内会对 oldStartIdx+1,newEndIdx-1 做偏移操作,并且挪到新位置:

vue虚拟dom如何转为真实dom,vue源码揭秘视频

这一轮 while 结束了,注意 oldStatrIdx 和 newEndIdx 已经指向定位置(如果以上被其他条件匹配中,是会有不同偏移方式的)。

之后,将通过 while 再一次进入这四种条件的判断,还是从首个元素开始判断,因为 1 和 5 已在上次判断中匹配过了,对应索引已经变更,这里将从 2 和 3 两个新的首个元素开始

vue虚拟dom如何转为真实dom,vue源码揭秘视频

由于这两个元素都是注释元素,所以 sameVnode 匹配一致。

之后,再次偏移 oldStartIdx newStartIdx 位置,最后 oldStartIdx 溢出到 oldCh 之外,之后进入 while 循环将不再符合条件

vue虚拟dom如何转为真实dom,vue源码揭秘视频

但是我们 节点4 还未被处理过,这将走到 while 外的那段逻辑:

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)
}

目前会进入 addVnodes 方法,将 节点4 作为 refElm 挂载到 parentElm 中(oldCh 解析完毕);

相反,如果 newStartIdx > newEndIdx ,则会通过 removeVnodes 方法,移除 oldCh 中的部分元素。(ch 解析完毕。不全面的理解:四种判断中,如果多数匹配到 oldEndVnode vs newStartVnode 条件,将使得 newStartIdx 增大,导致大于 newEndIdx,或者 oldCh 数量大于 endCh 等),

如此,整个 oldVnode.elm 将更新至最新结果,return 给 vm.$el 渲染到页面上。

有关 key 属性的判断

有注意到,上面将 4种条件判断时,没有讲 while 中 else 的逻辑,这块内容将涉及 key 属性的逻辑:

while(){
	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]
   }      
}

平时我们编写业务代码时,一定会遇到不同 input 标签在做 v-if/else 之类的切换时,导致 value 还停留在上面,vue 官方也给了示例:使用 key 来解决;同时 key 也建议在 v-for 中使用,以便提升性能。

现在我们逐步看下 vnode 上设置 key 属性是如何工作的?

在 oldCh 中解析 key,组成老节点的 key 集合

oldCh 子节点集合中,从 oldStartIdx 开始,到 oldEndIdx 结束,取存在 key 的元素,对应 value 为索引值:

oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
function createKeyToOldIdx (children, beginIdx, endIdx) {
  let i, key
  const map = {}
  for (i = beginIdx; i <= endIdx; ++i) {
    key = children[i].key
    if (isDef(key)) map[key] = i
  }
  return map
}

判断老节点 key 集合中是否存在新节点 key

如果当前新节点 newStartVnode 存在 key 属性,则会判断该 key 是否是老节点 key 集合之一?如果不是,则会通过 sameVnode 依次比较 oldCh 中所有元素,哪个和 newStartVnode 为"相同元素":

idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
function findIdxInOld (node, oldCh, start, end) {
  for (let i = start; i < end; i++) {
    const c = oldCh[i]
    if (isDef(c) && sameVnode(node, c)) return i
  }
}

idxInOld 不存在

如果 idxInOld 不存在,则会把 newStartVnode 节点作为新节点,通过 createElm 加入到 parentElm 中:

createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)

idxInOld 存在

通过 oldCh[idxInOld] 取得将要移动的元素节点 vnodeToMove:

vnodeToMove = oldCh[idxInOld]

vnodeToMove newStartVnode sameVnode 比较:

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)
}

如果是"相同元素",则会通过 patchVnode 来做节点间的交换操作。之后为 oldCh[idxInOld] 做置空,以免后续 findIdxInOld 方法还会匹配到结果。

接着把 vnodeToMove.elm 节点加入到 oldStartVnode.elm 之前,结束。

相反 else,通过 createElm newStartVnode 加入到 parentElm 中。

demo 示意

对此过程也做了一个 demo 用于示意:

首先是我们模板中,新老节点树的说明:

vue虚拟dom如何转为真实dom,vue源码揭秘视频

然后在 while 判断中,命中于:oldEndVnode, 和 newStartVnode 的判断:

vue虚拟dom如何转为真实dom,vue源码揭秘视频

并且偏移 oldStartIdx 和 newEndIdx 的索引位置,再次进入 while 循环,这次将直接进入 else 的逻辑:

vue虚拟dom如何转为真实dom,vue源码揭秘视频

分别提取出每个 oldCh 子节点集合的 key 属性,整合到 oldKeyToIdx 中:

vue虚拟dom如何转为真实dom,vue源码揭秘视频

然后判断当前 newStartIdx.key 是否属于 oldKeyToIdx 之中:

vue虚拟dom如何转为真实dom,vue源码揭秘视频

这里的 节点5节点2 的 key 相同(key=a)。

最后比较这两个节点,是否是 sameVnode,是的话:通过 patchNode 做节点更新操作,将 oldCh[idxInOld] 新节点插入至 parentElm,清空老节点 oldCh[idxInOld]

反之,根据 newStartIVnode 创建新节点,挂载到 parentElm 中。

可能会问 节点1 怎么办?因为每次 else 执行后,会对 newStartIdx 做+1操作,所以以上步骤结束后,newStartIdx 将溢出于 ch 队列;之后会匹配到 newStartIdx > newEndIdx 的判断,从而通过 removeVnodes 溢出 oldCh 中相关元素。

补充:有关 document 的 node 操作

insertBefore 和 nextSibling

基于如下 vue 涉及的源码:

nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))

通过一个 demo 来熟悉下 insertBefore 和 nextSibling 有什么用?(一段简单的 html,ul 下依次显示三个 li 标签)

<ul id="app">
  <li id="one">1</li>
  <li id="two">2</li>
  <li>3</li>
</ul>

接下来我在 id=app 父节点下,提取出 id=one 的 dom 节点,并把它插入到最后个 li 标签之前:

let parentElm = document.getElementById('app');
let startNode = document.getElementById('one');
let restNode = document.getElementById('two').nextSibling;
parentElm.insertBefore(startNode, restNode);

一顿操作后,页面就会如下显示:

vue虚拟dom如何转为真实dom,vue源码揭秘视频