Vue元件渲染機制原始碼淺析

RaUnicorn發表於2017-11-12

第二次寫文章,寫得不對的地方望各位大神指正~

之前研究Vue的響應式原理有提到, 當資料發生變化時, Watcher會呼叫 vm._update(vm._render(), hydrating)來進行DOM更新, 接下來我們看看這個具體的更新過程是如何實現的。

//摘自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呼叫鉤子函式之類的, 我們只關注實現元件渲染相關程式碼。)

這裡面最重要的程式碼就是通過 vm.__patch__進行DOM更新。 如果之前沒有渲染過, 就直接呼叫 vm.__patch__生成真正的DOM並將生成的DOM掛載到vm.$el上, 否則會呼叫 vm.__patch__(prevVnode, vnode)將當前vnode與之前的vnode進行diff比較, 最小化更新。

接下來我們就看一下這個最重要的 vm.__patch__到底做了些什麼。

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

export const patch: Function = createPatchFunction({ nodeOps, modules })複製程式碼

可以看到patch方法主要就是呼叫了createPatchFunction這個函式。 一步步看看它主要乾了些什麼。

顧名思義, 這個函式的作用是建立並返回一個patch函式。

//摘自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函式裡, 會進行許多的判斷:

  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。

如果有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有四個節點,用數字表示,分別為1、2、3、4,newCh五個節點,分別為5、2、6、3、1。由於parentElm.children是根據oldCh生成的,所以也有四個節點1、2、3、4。oldCh的頭尾指標分別指向1和4,newCh的頭尾指標分別指向5、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了,檢視也就更新完畢啦。

相關文章