Vue组件渲染机制源码浅析

第二次写文章,写得不对的地方望各位大神指正~javascript

以前研究Vue的响应式原理有提到, 当数据发生变化时, Watcher会调用 vm._update(vm._render(), hydrating)来进行DOM更新, 接下来咱们看看这个具体的更新过程是如何实现的。vue

//摘自core\instance\lifecycle.js
Vue.prototype._update = function(vnode: VNode, hydrating ? : boolean) {
  const vm: Component = this
  if (vm._isMounted) {
    callHook(vm, 'beforeUpdate')
  }
  const prevEl = vm.$el
  const prevVnode = vm._vnode
  const prevActiveInstance = activeInstance
  activeInstance = vm
  vm._vnode = vnode
  if (!prevVnode) {
    // initial render
    vm.$el = vm.__patch__(
      vm.$el, vnode, hydrating, false /* removeOnly */ ,
      vm.$options._parentElm,
      vm.$options._refElm
    )
    vm.$options._parentElm = vm.$options._refElm = null
  } else {
    // updates
    vm.$el = vm.__patch__(prevVnode, vnode)
  }
  activeInstance = prevActiveInstance
  if (prevEl) {
    prevEl.__vue__ = null
  }
  if (vm.$el) {
    vm.$el.__vue__ = vm
  }
  if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
    vm.$parent.$el = vm.$el
  }
}复制代码

( 这里咱们就将一些不过重要的代码忽略掉不讲了, 好比callHook调用钩子函数之类的, 咱们只关注实现组件渲染相关代码。)java

这里面最重要的代码就是经过 vm.__patch__进行DOM更新。 若是以前没有渲染过, 就直接调用 vm.__patch__生成真正的DOM并将生成的DOM挂载到vm.$el上, 不然会调用 vm.__patch__(prevVnode, vnode)将当前vnode与以前的vnode进行diff比较, 最小化更新。node

接下来咱们就看一下这个最重要的 vm.__patch__到底作了些什么。web

//摘自platforms\web\runtime\patch.js
const modules = platformModules.concat(baseModules)

export const patch: Function = createPatchFunction({ nodeOps, modules })复制代码

能够看到patch方法主要就是调用了createPatchFunction这个函数。 一步步看看它主要干了些什么。数组

顾名思义, 这个函数的做用是建立并返回一个patch函数。app

//摘自core\vdom\patch.js

//......

return function patch (oldVnode, vnode, hydrating, removeOnly, parentElm, refElm) {
  if (isUndef(vnode)) {
    if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
    return
  }

  let isInitialPatch = false
  const insertedVnodeQueue = []

  if (isUndef(oldVnode)) {
    isInitialPatch = true
    createElm(vnode, insertedVnodeQueue, parentElm, refElm)
  } else {
    const isRealElement = isDef(oldVnode.nodeType)
    if (!isRealElement && sameVnode(oldVnode, vnode)) {
      patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly)
    } else {

      //......

      const oldElm = oldVnode.elm
      const parentElm = nodeOps.parentNode(oldElm)
      createElm(
        vnode,
        insertedVnodeQueue,
        oldElm._leaveCb ? null : parentElm,
        nodeOps.nextSibling(oldElm)
      )

      //......

    }
  }

  invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
  return vnode.elm
}复制代码

在这个返回的patch函数里, 会进行许多的判断:dom

  1. 判断vnode和oldVnode是否isDef( 即非undefined且非null, 下面简称已定义), 若vnode未定义且oldVnode已定义, 没有新的vnode就意味着要将组件销毁掉, 就会循环调用invokeDestroyHook函数将oldVnode销毁掉。
  2. 若是oldVnode未定义, 意味着这是第一次patch, 就会调用 createElm(vnode, insertedVnodeQueue, parentElm, refElm)建立一个新的DOM。
  3. 若是oldVnode跟vnode是同一个vnode, 且oldVnode.nodeType未定义, 就调用 patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly)来更新oldVnode并生成新的DOM。( 这里判断nodeType是否认义是由于vnode是没有nodeType的, 当进行服务端渲染时会有nodeType, 这样能够排除掉服务端渲染的状况。 )
  4. 若是oldVnode跟vnode不一样, 会调用createElm函数来建立新的DOM来替换掉原来的DOM。

咱们分别看一下上面的两种状况:函数

if (!prevVnode) {
  // initial render
  vm.$el = vm.__patch__(
    vm.$el, vnode, hydrating, false /* removeOnly */ ,
    vm.$options._parentElm,
    vm.$options._refElm
  )
  vm.$options._parentElm = vm.$options._refElm = null
} else {
  // updates
  vm.$el = vm.__patch__(prevVnode, vnode)
}复制代码

若是没有prevVnode(也就是第一次渲染), 这时vm.$el若是为undefined则知足 isUndef(oldVnode),会调用createElm函数;若是vm.$el存在,但其不知足 sameVnode(oldVnode, vnode),一样会调用createElm函数。也就是说若是是首次渲染,就会调用createElm函数建立新的DOM。post

若是有prevVnode(也就是进行视图的更新),这时若是知足 sameVnode(oldVnode, vnode)(即vnode相同),则会调用patchVnode对vnode进行更新;若是vnode不相同,则会调用createElm函数建立新的DOM节点替换掉原来的DOM节点。

那么接下来分别看看这两个函数。

//摘自\core\vdom\patch.js
function createElm (vnode, insertedVnodeQueue, parentElm, refElm, nested) {
  vnode.isRootInsert = !nested // for transition enter check

  //......

  vnode.elm = vnode.ns
    ? nodeOps.createElementNS(vnode.ns, tag)
    : nodeOps.createElement(tag, vnode)

  //......

  createChildren(vnode, children, insertedVnodeQueue)

  insert(parentElm, vnode.elm, refElm)

  //......

}复制代码

能够看到, createElm中主要会根据vnode.ns(vnode的命名空间)是否存在调用createElementNS函数或createElmement函数生成真正的DOM节点并赋给vnode.elm保存。而后经过createChildren函数建立vnode的子节点,而且经过insert函数将vnode.elm插入到父节点中。

//摘自\core\vdom\patch.js
function createChildren (vnode, children, insertedVnodeQueue) {
  if (Array.isArray(children)) {
    for (let i = 0; i < children.length; ++i) {
      createElm(children[i], insertedVnodeQueue, vnode.elm, null, true)
    }
  } else if (isPrimitive(vnode.text)) {
    nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(vnode.text))
  }
}复制代码

createChildren函数会判断vnode的children是不是数组,若是是,则代表vnode有子节点,循环调用createElm函数为子节点建立DOM;若是是text节点,则会调用createTextNode为其建立文本节点。

//摘自\core\vdom\patch.js
function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {

  //......

  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(elm, 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)
  }
}复制代码

patchVnode主要是对oldVnode和vnode进行必定的对比:

  1. 首先判断vnode.text未定义,意味着vnode可能有children(具备text的vnode不会有children)。
    1. 若是vnode和oldVnode都有children,则用updateChildren对二者的children进行对比。
    2. 若是vnode有children而oldVnode没有,则经过addVnodes函数给elm加上子节点。
    3. 若是oldVnode有children而vnode没有,则经过removeVnodes函数将elm的子节点删除。
    4. 同时若是oldVnode.text已定义,则经过setTextContent将elm的text设为空(由于vnode.text未定义)。
  2. 若是vnode.text已定义而且不等于oldVnode.text的话,则将elm的text设为vnode.text。

咱们先来看下比较简单的当vnode和oldVnode只有其中一个有children时调用的addVnodes和removeVnodes函数。

//摘自\core\vdom\patch.js
function addVnodes (parentElm, refElm, vnodes, startIdx, endIdx, insertedVnodeQueue) {
  for (; startIdx <= endIdx; ++startIdx) {
    createElm(vnodes[startIdx], insertedVnodeQueue, parentElm, refElm)
  }
}复制代码

addVnodes函数经过循环调用createElm分别对vnode的children中的每一个子vnode建立子节点并挂载到DOM上。

function removeVnodes (parentElm, vnodes, startIdx, endIdx) {
  for (; startIdx <= endIdx; ++startIdx) {
    const ch = vnodes[startIdx]
    if (isDef(ch)) {
      if (isDef(ch.tag)) {
        removeAndInvokeRemoveHook(ch)
        invokeDestroyHook(ch)
      } else { // Text node
        removeNode(ch.elm)
      }
    }
  }
}复制代码

removeVnodes函数经过调用removeNode函数(removeAndInvokeRemoveHook函数最终也是调用removeNode函数)将oldVnode的children节点所有移除。

接下来就看一下当vnode和oldVnode都有children时调用的updateChildren函数。

function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, 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)
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
      patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
      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)
      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] : null
      if (isUndef(idxInOld)) { // New element
        createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
        newStartVnode = newCh[++newStartIdx]
      } else {
        elmToMove = oldCh[idxInOld]
        if (sameVnode(elmToMove, newStartVnode)) {
          patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
          oldCh[idxInOld] = undefined
          canMove && nodeOps.insertBefore(parentElm, elmToMove.elm, oldStartVnode.elm)
          newStartVnode = newCh[++newStartIdx]
        } else {
          // same key but different element. treat as new element
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
          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(parentElm, oldCh, oldStartIdx, oldEndIdx)
  }
}复制代码

在这里咱们主要须要关注三个数组:oldCh、newCh和parentElm.children。oldCh就是oldVnode.children,newCh就是vnode.children,parentElm就是oldVnode.elm。

而oldStartIdx、oldEndIdx、newStartIdx和newEndIdx这四个是用于标志当前关注的vnode的头指针和尾指针。

简单来讲,咱们会将oldCh和newCh进行比较,将oldCh跟newCh差别的部分patch到parentElm中,最终获得一个根据newCh所对应的elm.children。接下来咱们一步步分析这个函数究竟是如何进行diff的。

  1. 首先咱们会进行一个循环,当知足 oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx 时继续进行循环。
  2. 在循环中,先判断oldStartVnode跟oldEndVnode是否存在,不存在则指针跳到下一个。在后面会讲到为何须要这一步。
  3. 接下来会进行四个判断。
    1. 若是知足sameVnode(oldStartVnode, newStartVnode),则递归调用patchVnode对二者进行比较,同时头指针往右走。由于咱们最终想要获得的是newCh所对应的elm,而这个elm是oldVnode.elm,它的children一开始是根据oldCh生成的。那么当oldStartVnode跟newStartVnode相同时,意味着elm.children中这个位置的子节点已是跟newCh所对应的。
    2. 若是知足sameVnode(oldEndVnode, newEndVnode),同理,递归调用patchVnode对二者进行比较,同时尾指针往左走。
    3. 若是知足sameVnode(oldStartVnode, newEndVnode),意味着newEndVnode跟oldStartVnode相同,这个时候递归调用patchVnode对二者进行比较后咱们须要经过nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm)),将oldStartVnode.elm移动到parentElm.children中newEndVnode所对应的位置,也就是oldEndVnode.elm后面。
    4. 若是知足sameVnode(oldEndVnode, newStartVnode),同理,经过递归调用patchVnode对二者进行比较后经过nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)将oldEndVnode.elm移动到parentElm.children中newStartVnode所对应的位置,也就是oldStartVnode.elm前面。
  4. 若是以上判断都不知足,咱们就直接经过key去寻找oldCh中与newStartVnode相对应的vnode。
    1. 若是没找到对应的vnode,意味着这是一个新的节点,咱们经过createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)建立一个新的DOM节点并插入到oldStartVnode.elm前面。
    2. 若是找到了oldCh中对应的vnode,咱们用elmToMove将这个vnode保存起来,经过递归调用patchVnode对这个vnode跟newStartVnode进行对比,而后将oldCh中对应的vnode设为undefined,同时经过nodeOps.insertBefore(parentElm, elmToMove.elm, oldStartVnode.elm)将elmToMove.elm移动到oldStartVnode.elm前面。能够看到,咱们将这个节点设为了undefined,这样当指针移动到这里的时候发现是undefined就会继续移动,由于这个节点已经被复用了,这个就是上面第2步判断的做用。
  5. 当再也不知足oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx时,循环结束。这时候咱们就要判断究竟是oldStartIdx > oldEndIdx仍是newStartIdx > newEndIdx
    1. 若是oldStartIdx > oldEndIdx,由于只有当oldCh中的节点被复用时,oldCh的指针才会移动,当oldCh的头指针大于尾指针时,意味着oldCh已经没有节点能够被复用了,这样咱们就须要直接将newCh中还未添加到parentElm.children的节点经过addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)添加到parentElm.children中。
    2. 若是newStartIdx > newEndIdx,意味着newCh中的全部节点都已经在parentElm.children中了,也就意味着OldCh中若是oldStartIdx到oldEndIdx之间(包括oldStartIdx和oldEndIdx)指针所指向的节点在newCh中没有对应的节点,也就是说剩下的都是多余的节点,因此咱们须要经过removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)将多余的节点都移除。

通过这样的一个过程以后,parentElm.children就变成了与newCh相对应了。

总的来讲,updateChildren的做用是根据newCh生成相应的parentElm.children,同时尽可能复用其中的节点。因此对于每个newCh的节点,会先在oldCh中找相应的节点,找到了就将其移动到parentElm.children中与newCh对应的位置,没找到就建立一个新的节点插入到对应的位置。最后将parentElm.children中多余的节点移除或者将newCh中还未添加到parentElm.children中的节点添加上去。

文字描述仍是有点比较难理解,用图例来进一步解释。

首先,假设咱们的oldCh有四个节点,用数字表示,分别为一、二、三、4,newCh五个节点,分别为五、二、六、三、1。因为parentElm.children是根据oldCh生成的,因此也有四个节点一、二、三、4。oldCh的头尾指针分别指向1和4,newCh的头尾指针分别指向五、1。

parentElm.children 1 2 3 4 -
oldCh指针
oldCh 1 2 3 4
newCh 5 2 6 3 1
newCh指针

根据上面咱们说到的updateChildren的判断过程,判断到oldCh的头节点和newCh的尾节点相同,因而就将parentElm.children中的oldCh头节点移动到oldCh尾节点后面。而后oldCh跟newCh的指针分别移动,因而就变成了下面这样。

parentElm.children 2 3 4 1 -
oldCh指针
oldCh 1 2 3 4
newCh 5 2 6 3 1
newCh指针

继续进行循环判断,发现头尾的节点都没有相同的,这个时候咱们就要去oldCh中根据key找与newCh头节点相同的节点。可是没有找到,因此咱们会建立一个新的节点插入到parentElm.children中头节点前面,而后指针移动。结果以下。

parentElm.children 5 2 3 4 1
oldCh指针
oldCh 1 2 3 4
newCh 5 2 6 3 1
newCh指针

继续进行循环。发现头节点相同,无需移动,直接对头节点进行patch,指针移动。结果以下。

parentElm.children 5 2 3 4 1
oldCh指针
oldCh 1 2 3 4
newCh 5 2 6 3 1
newCh指针

继续进行循环。发现newCh尾节点和oldCh头节点相同,将parentElm.children中的3节点移动到parentElm.children的尾指针后面,指针移动。结果以下。

parentElm.children 5 2 4 3 1
oldCh指针 ↓↓
oldCh 1 2 3 4
newCh 5 2 6 3 1
newCh指针 ↑↑

如今两个头尾指针都相等了,但仍是符合循环的条件,因而继续进行循环。因为两个节点不相同,因而会建立一个新的节点插入到parentElm.children的头指针前面,指针移动。结果以下。

parentElm.children 5 2 6 4 3 1
oldCh指针 ↓↓
oldCh 1 2 3 4
newCh 5 2 6 3 1
newCh指针

这样以后newStartIdx > newEndIdx,循环结束。由于newStartIdx > newEndIdx,意味着parentElm.children中可能还有多余的节点,咱们再调用removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)将多余的节点移除。结果以下。

parentElm.children 5 2 6 3 1
oldCh指针 ↓↓
oldCh 1 2 3 4
newCh 5 2 6 3 1
newCh指针

这样,咱们就完成了整一个updateChildren的过程,parentElm.children已经变成了与newCh相对应了。整一个patch的递归完成后,vnode.elm就变成全新的elm了,视图也就更新完毕啦。

相关文章
相关标签/搜索