Vue.js原始碼解析-Vue初始化流程之動態建立DOM

駱三瘋 發表於 2021-07-21
Vue

前言

各位道友大家好,我是LSF,在上一篇博文 中,分析了Vue初始化的整體流程,最後到了 update 動態建立 DOM 階段。接下來這篇博文,會對這個流程進行分析,重點需要掌握 createElm 函式的執行邏輯。

Vue.js原始碼解析-Vue初始化流程之動態建立DOM

一、_update 如何判斷是初始化還是更新操作?

_update 是在Vue例項化之前,通過prototype混入的一個例項方法。主要目的是將vnode轉化成真實DOM,它定義在 core/instance/lifecycle.js 檔案中。

  Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
    const vm: Component = this  // vm -> this
    const prevEl = vm.$el
    
    // 儲存上一個vnode。
    const prevVnode = vm._vnode  

    // 設定 activeInstance 當前活動的vm,返回方法。
    const restoreActiveInstance = setActiveInstance(vm)  

    vm._vnode = vnode  // 賦值 _vnode 屬性為新傳入的 vnode。
    // Vue.prototype.__patch__ is injected in entry points
    // based on the rendering backend used.
    if (!prevVnode) {
      // initial render  初始化渲染,如果有子元件,會遞迴初始化
      vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
    } else {
      // updates 更新
      vm.$el = vm.__patch__(prevVnode, vnode)
    }

    // activeInstance 恢復到當前的vm
    restoreActiveInstance()  

    // update __vue__ reference
    if (prevEl) {
      prevEl.__vue__ = null
    }
    if (vm.$el) {
      vm.$el.__vue__ = vm
    }
    // if parent is an HOC, update its $el as well
    if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
      vm.$parent.$el = vm.$el
    }
    // updated hook is called by the scheduler to ensure that children are
    // updated in a parent's updated hook.
  }

程式碼中可以看到,通過 prevVnode 是否為 null 來判斷的是否是初始化 patch。由於是初始化操作,開始的時候 vm._vnode 沒有被賦值成 vnode,從而 vm._vnode 為 null。所以程式碼的執行邏輯會走到初始化 patch。

Vue.js原始碼解析-Vue初始化流程之動態建立DOM

二、patch

2.1 patch 定義

web端的 Vue.prototype.__patch__ 方法,它定義的入口在 src/platforms/web/runtime/index.js 檔案中。

import { patch } from './patch'
...
// install platform runtime directives & components
extend(Vue.options.directives, platformDirectives)
extend(Vue.options.components, platformComponents)

// install platform patch function
// 安裝web端的 patch 方法。
Vue.prototype.__patch__ = inBrowser ? patch : noop
...

如果是瀏覽器環境下,被賦值為 patch 方法,該方法定義在 src/platforms/web/runtime/patch.js中。如果是非瀏覽器環境,patch 被賦值成一個空函式。

/* @flow */

import * as nodeOps from 'web/runtime/node-ops'
import { createPatchFunction } from 'core/vdom/patch'
import baseModules from 'core/vdom/modules/index'
import platformModules from 'web/runtime/modules/index'

// the directive module should be applied last, after all
// built-in modules have been applied.
const modules = platformModules.concat(baseModules)
// 呼叫 createPatchFunction 函式,返回 patch。
export const patch: Function = createPatchFunction({ nodeOps, modules })

通過程式碼可以看到,最終 vue 是呼叫了 createPatchFunction 函式,它定義在 src/core/vdom/patch.js 中。createPatchFunction 函式內部定義瞭如 emptyNodeAt、removeNode、createElement、createChildren 等一系列的輔助函式,通過這些輔助函式,完成了對 patch 函式的程式碼邏輯的封裝。

Vue.js原始碼解析-Vue初始化流程之動態建立DOM

2.2 初始化的 patch

建立Vue例項,或者元件例項的,patch 都會被執行。

  • 如果是建立vue例項執行 patch

    • isRealElement:判斷是否是真實的DOM節點。

    • patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly):負責DOM的更新。

    • oldVnode = emptyNodeAt(oldVnode):對容器DOM進行vnode的轉化。

    • createElm():建立新節點,初始化建立需要重點關注的函式。

Vue.js原始碼解析-Vue初始化流程之動態建立DOM

  • 如果是建立元件例項執行的 patch

    • isInitialPatch:使用者判斷子元件否初次執行 patch,進行建立。

    • insertedVnodeQueue:新建立子元件節點,元件 vnode 會被push到這個佇列中。

    • invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)。

Vue.js原始碼解析-Vue初始化流程之動態建立DOM

具體程式碼註釋如下

export function createPatchFunction (backend) {
...

  return function patch (oldVnode, vnode, hydrating, removeOnly) {
    // 如果新的 vnode 為空,呼叫 destory 鉤子,銷燬oldVnode
    if (isUndef(vnode)) {
      if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
      return
    }

    // 使用者判斷子元件否初次執行 patch,進行建立。
    let isInitialPatch = false

    // 新建立子元件節點,元件 vnode 會被push到這個佇列中
    const insertedVnodeQueue = []

    if (isUndef(oldVnode)) {
      // empty mount (likely as component), create new root element
      // 空掛載(可能作為元件),建立新的根元素
      isInitialPatch = true
      // 建立元件節點的子元素
      createElm(vnode, insertedVnodeQueue)
    } else {
      // 1.作為判斷是否是真實的DOM節點條件
      const isRealElement = isDef(oldVnode.nodeType)
      if (!isRealElement && sameVnode(oldVnode, vnode)) {
        // patch existing root node
        // 更新操作
        patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
      } else {
        if (isRealElement) {
          ...
          // either not server-rendered, or hydration failed.
          // create an empty node and replace it
          // 2. 傳入的容器DOM(如 el: "#app"),會在這裡被轉化成 vnode。
          oldVnode = emptyNodeAt(oldVnode)
        }

        // replacing existing element
        const oldElm = oldVnode.elm
        const parentElm = nodeOps.parentNode(oldElm)

        // create new node
        // 3. 建立新節點
        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)
        )

        // update parent placeholder node element, recursively
        // 遞迴的更新父佔位節點元素。
        if (isDef(vnode.parent)) {...
        }

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

    // 呼叫 insertedVnodeQueue 佇列中所有子元件的 insert 鉤子。
    invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
    return vnode.elm
  }

三、createElm 動態建立DOM

createElm 函式是動態建立 DOM 的核心,作用是通過 vnode 建立真實的 DOM,並插入到它的父 DOM 節點中。它定義在 src/core/vdom/patch.js
的 createPatchFunction 方法中。createElm 內部建立 DOM 的主要判斷邏輯,可以概括為下面幾種情況。

1、如果建立元件節點

  • 如果碰到子元件標籤,走建立元件節點邏輯。

  • 建立完成,插入到父親元素中。

2、如果建立標籤元素節點

  • 如果 vnode.tag 不為空,先建立標籤元素, 賦值 vnode.elm 進行佔位。

  • 呼叫 createChildren 建立子節點,最終這些子節點會 append 到 vnode.elm 標籤元素中。

  • 將 vnode.elm 標籤元素插入到父親元素中。

3、如果建立註釋節點

  • 如果 vnode.isComment 不為空,建立註釋節點,賦值 vnode.elm。

  • 將註釋節點插入到父親元素中。

4、如果建立文字節點

  • 上面三種情況都不是,則建立文字節點,賦值 vnode.elm。

  • 將文字節點插入到父親元素中。

Vue.js原始碼解析-Vue初始化流程之動態建立DOM

function createElm (
    vnode,
    insertedVnodeQueue,
    parentElm,
    refElm,
    nested,
    ownerArray,
    index
  ) {
    if (isDef(vnode.elm) && isDef(ownerArray)) {
      // This vnode was used in a previous render!
      // now it's used as a new node, overwriting its elm would cause
      // potential patch errors down the road when it's used as an insertion
      // reference node. Instead, we clone the node on-demand before creating
      // associated DOM element for it.
      vnode = ownerArray[index] = cloneVNode(vnode)
    }

    vnode.isRootInsert = !nested // for transition enter check

    // 1、如果碰到子元件標籤,走建立元件節點邏輯,插入父親節點。
    if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
      return
    }

    const data = vnode.data
    const children = vnode.children
    const tag = vnode.tag

    // 2、如果是標籤標記,先建立標籤元素進行佔位。
    //    呼叫 createChildren 建立子節點(遞迴呼叫createElm)。
    //    將標籤元素,插入父親元素。
    if (isDef(tag)) {
      if (process.env.NODE_ENV !== 'production') {
        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
          )
        }
      }

      // 通過上面的tag,建立標籤元素,賦值給 vnode.elm 進行佔位
      vnode.elm = vnode.ns
        ? nodeOps.createElementNS(vnode.ns, tag)
        : nodeOps.createElement(tag, vnode)

      setScope(vnode)

      /* istanbul ignore if */
      if (__WEEX__) {
        // in Weex, the default insertion order is parent-first.
        // List items can be optimized to use children-first insertion
        // with append="tree".
        const appendAsTree = isDef(data) && isTrue(data.appendAsTree)
        if (!appendAsTree) {
          if (isDef(data)) {
            invokeCreateHooks(vnode, insertedVnodeQueue)
          }
          insert(parentElm, vnode.elm, refElm)
        }
        createChildren(vnode, children, insertedVnodeQueue)
        if (appendAsTree) {
          if (isDef(data)) {
            invokeCreateHooks(vnode, insertedVnodeQueue)
          }
          insert(parentElm, vnode.elm, refElm)
        }
      } else {
        // 建立子節點
        createChildren(vnode, children, insertedVnodeQueue)
        if (isDef(data)) {
          invokeCreateHooks(vnode, insertedVnodeQueue)
        }
        // 將建立的標籤元素節點,插入父親元素
        insert(parentElm, vnode.elm, refElm)
      }

      if (process.env.NODE_ENV !== 'production' && data && data.pre) {
        creatingElmInVPre--
      }
    } else if (isTrue(vnode.isComment)) {
      // 3、建立註釋節點,插入到父親元素
      vnode.elm = nodeOps.createComment(vnode.text)
      insert(parentElm, vnode.elm, refElm)
    } else {
      // 4、建立文字節點,插入到父親元素
      vnode.elm = nodeOps.createTextNode(vnode.text)
      insert(parentElm, vnode.elm, refElm)
    }
  }

下面對動態建立的幾種情況分別進行說明。

3.1 建立元件節點

建立元件節點和 vue 的元件系統息息相關,這裡先不具體展開,之後的博文中單獨分析 vue 元件系統。只需要記住 vue 模板裡的子元件初始化建立,是在這一步進行即可。

function createElm (
  vnode,
  insertedVnodeQueue,
  parentElm,
  refElm,
  nested,
  ownerArray,
  index
) {
  ...
  // 建立元件節點
  if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
    return
  }  
  ...
}  

createComponent 這個方法也定義在 src/core/vdom/patch.js 的 createPatchFunction 的方法中,這裡先簡單的介紹一下這個方法的內部邏輯。

  • 通過 vnode.data 中是否包含元件相關的 hook,來判斷當前 vnode 是否是子元件 vnode(元件的 vnode,會包含 init 等鉤子方法)。

  • 呼叫 init,執行子元件的初始化流程,建立子元件例項,進行子元件掛載。

  • 將生成的子元件 DOM 賦值給 vnode.elm。

  • 通過 vnode.elm 將建立的子元件節點,插入到父親元素中。

  function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
    let i = vnode.data
    if (isDef(i)) {
      const isReactivated = isDef(vnode.componentInstance) && i.keepAlive

      // 呼叫元件 init 鉤子後,會執行子元件的**初始化流程**
      // 建立子元件例項,進行子元件掛載。
      if (isDef(i = i.hook) && isDef(i = i.init)) {
        i(vnode, false /* hydrating */)
      }
      
      // after calling the init hook, if the vnode is a child component
      // it should've created a child instance and mounted it. the child
      // component also has set the placeholder vnode's elm.
      // in that case we can just return the element and be done.

      // 如果是元件例項,將建立 vnode.elm 佔位符
      // 將生成的元件節點,插入到父親元素中
      if (isDef(vnode.componentInstance)) {
        initComponent(vnode, insertedVnodeQueue)
        insert(parentElm, vnode.elm, refElm)
        if (isTrue(isReactivated)) {
          reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm)
        }
        return true
      }
    }
  }
  • 如果是建立元件節點,並且成功,createComponent 函式返回 true。createElm 函式執行到 return。

  • 如果是其他型別的節點,createComponent 函式返回 undefined,createElm 函式,會向下執行建立其他型別節點(標籤元素、註釋、文字)的程式碼邏輯。

綜上所述,createElm 函式執行,只要碰到元件標籤,會遞迴的去初始化建立子元件,簡圖如下所示(綠色線路部分)。

Vue.js原始碼解析-Vue初始化流程之動態建立DOM

再呼叫 insert(parentElm, vnode.elm, refElm),將生成的元件節點插入到父親元素中(遵從先子後父)。

3.2 建立標籤元素節點

createElm 判斷如果 vnode 不是元件的 vnode,它會判斷是否是標籤元素,從而進行建立標籤元素節點的程式碼邏輯, 主要邏輯分析如下。

  • vnode.tag 標籤屬性存在,通過 tag 建立對應的標籤元素,賦值給 vnode.elm 進行佔位。

  • 呼叫 createChildren 建立子節點(遍歷子vnode,遞迴呼叫 createElm 函式)。

  • 將建立的標籤元素節點,插入父親元素。

function createElm (
    vnode,
    insertedVnodeQueue,
    parentElm,
    refElm,
    nested,
    ownerArray,
    index
  ) {

    ...

    const data = vnode.data
    const children = vnode.children
    const tag = vnode.tag

    // 2、如果是標籤標記,先建立標籤元素進行佔位。
    //    呼叫 createChildren 建立子節點(遍歷子vnode,遞迴呼叫 createElm 函式)。
    //    將標籤元素,插入父親元素。

    // 如果標籤屬性不為空
    if (isDef(tag)) {
      if (process.env.NODE_ENV !== 'production') {
        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
          )
        }
      }

      // 通過上面的tag,建立標籤元素,賦值給 vnode.elm 進行佔位
      vnode.elm = vnode.ns
        ? nodeOps.createElementNS(vnode.ns, tag)
        : nodeOps.createElement(tag, vnode)

      setScope(vnode)

      /* istanbul ignore if */
      if (__WEEX__) {
        ...
      } else {

        // 建立子節點
        createChildren(vnode, children, insertedVnodeQueue)
        if (isDef(data)) {
          // vnode.data 不為空,呼叫所有create的鉤子。
          invokeCreateHooks(vnode, insertedVnodeQueue)
        }
        // 將建立的標籤元素節點,插入父親元素
        insert(parentElm, vnode.elm, refElm)
      }

      if (process.env.NODE_ENV !== 'production' && data && data.pre) {
        creatingElmInVPre--
      }
    
    ...

  }

createChildren 函式主要邏輯如下

  • 如果 vnode.children 是子 vnode 陣列,遍歷 vnode.children 中的每個子 vnode,遞迴的呼叫了 createElm 函式,建立對應的子節點,並插入到父親元素中(此時的父親元素 parentElm 為 vnode.elm)。

  • 如果 vnode.text 為空字串。就建立一個空文字節點,插入到 vnode.elm 元素中。

  function createChildren (vnode, children, insertedVnodeQueue) {
    if (Array.isArray(children)) {
      if (process.env.NODE_ENV !== 'production') {
        checkDuplicateKeys(children)
      }
      // 遍歷子vnode陣列,遞迴呼叫 createElm 
      for (let i = 0; i < children.length; ++i) {
        createElm(children[i], insertedVnodeQueue, vnode.elm, null, true, children, i)
      }
    } else if (isPrimitive(vnode.text)) {
      // 建立空文字節點,appendChildren 到 vnode.elm 中
      nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(String(vnode.text)))
    }
  }

上面已經建立完成子標籤節點,invokeCreateHooks 呼叫執行所有子元件相關的 create 鉤子。這個方法createElm、
initComponent 中都會被呼叫。如果在 initComponent 中呼叫,說明建立的子節點中有元件節點,還會將元件 vnode 新增到 insertedVnodeQueue 佇列中。

    // createElm 中 
    if (isDef(data)) {
      // vnode.data 不為空,呼叫所有create的鉤子。
      invokeCreateHooks(vnode, insertedVnodeQueue)
    }
  function initComponent (vnode, insertedVnodeQueue) {
    if (isDef(vnode.data.pendingInsert)) {
      insertedVnodeQueue.push.apply(insertedVnodeQueue, vnode.data.pendingInsert)
      vnode.data.pendingInsert = null
    }
    vnode.elm = vnode.componentInstance.$el
    if (isPatchable(vnode)) {
      invokeCreateHooks(vnode, insertedVnodeQueue)
      setScope(vnode)
    } else {
      // empty component root.
      // skip all element-related modules except for ref (#3455)
      registerRef(vnode)
      // make sure to invoke the insert hook
      insertedVnodeQueue.push(vnode)
    }
  }
  function invokeCreateHooks (vnode, insertedVnodeQueue) {
   // 所有元件相關的create鉤子都呼叫
   // initComponent呼叫的話,還會將各個子元件的 vnode 新增到 insertedVnodeQueue 佇列中。
    for (let i = 0; i < cbs.create.length; ++i) {
      cbs.create[i](emptyNode, vnode)
    }
    i = vnode.data.hook // Reuse variable
    if (isDef(i)) {
      if (isDef(i.create)) i.create(emptyNode, vnode)
      if (isDef(i.insert)) insertedVnodeQueue.push(vnode)
    }
  }

綜上所述,createElm 建立標籤節點內部通過 createChildren 實現了對 createElm 的遍歷遞迴呼叫,實現了深度優先遍歷,簡圖如下所示(藍色線路部分)。

Vue.js原始碼解析-Vue初始化流程之動態建立DOM

再呼叫 insert(parentElm, vnode.elm, refElm),將生成的元素節點插入到父親元素中(遵從先子後父)。

3.3 建立註釋節點

如果不是建立元件節點和元素節點,vue 就通過 vnode.isComment 屬性判斷,是否建立註釋節點。建立完成之後,插入到父親元素中(遵從先子後父)

  vnode.elm = nodeOps.createComment(vnode.text)
  insert(parentElm, vnode.elm, refElm)

3.3 建立文字節點

如果不是建立元件節點、元素節點、註釋節點,vue 就建立文字節點,建立完成之後,插入到父親元素中(遵從先子後父)。

  vnode.elm = nodeOps.createTextNode(vnode.text)
  insert(parentElm, vnode.elm, refElm)

四、銷燬舊節點

通過前面章節的分析,知道了 patch 函式,主要通過 createElm 動態的建立好了 DOM,並且已經成功新增到了舊DOM的後面,所以下一步操作,就只需要將舊 DOM 進行刪除即可。

  // destroy old node
  // 銷燬舊的節點(如 el: "app" 這個DOM)
  // 建立完成的整個dom會append到 el: "app", 的父親元素(如 parentElm 為 body)上
  if (isDef(parentElm)) {
    removeVnodes([oldVnode], 0, 0)
  } else if (isDef(oldVnode.tag)) {
    invokeDestroyHook(oldVnode)
  }

Vue.js原始碼解析-Vue初始化流程之動態建立DOM

五、總結

  • vue 通過呼叫 patch 函式進行初始化 DOM 的建立。

  • patch 的關鍵是理解內部 createElm 這個函式,它會判斷元件、元素、註釋、文字這些型別的節點,來建立相應的DOM,完成之後新增到父元素。

  • vue 的元件系統實現,關鍵在於動態建立元件節點的邏輯當中。

  • 新 DOM 建立新增過程是從子到父的,而元件的例項化是從父到子的。