Vue源碼探究-虛擬DOM的渲染

Vue源碼探究-虛擬DOM的渲染

虛擬節點的實現一篇中,除了知道了 VNode 類的實現之外,還簡要地整理了一下DOM渲染的路徑。在這一篇中,主要來分析一下兩條路徑的具體實現代碼。

按照創建 Vue 實例後的一般執行流程,首先來看看實例初始化時對渲染模塊的初始處理。這也是開始 mount 路徑的前一步。初始包括兩部分,一是向 Vue 類原型對象上掛載渲染相關的方法,而是初始化渲染相關的屬性。

渲染的初始化

下面代碼位於vue/src/core/instance/render.js

相關屬性初始化

// 定義並導出initRender函數,接受vm
export function initRender (vm: Component) {
  // 初始化實例的根虛擬節點
  vm._vnode = null // the root of the child tree
  // 定義實例的靜態樹節點
  vm._staticTrees = null // v-once cached trees
  // 獲取配置對象
  const options = vm.$options
  // 設置父佔位符節點
  const parentVnode = vm.$vnode = options._parentVnode // the placeholder node in parent tree
  // renderContext存儲父節點有無聲明上下文
  const renderContext = parentVnode && parentVnode.context
  // 將子虛擬節點轉換成格式化的對象結構存儲在實例的$slots屬性
  vm.$slots = resolveSlots(options._renderChildren, renderContext)
  // 初始化$scopedSlots屬性爲空對象
  vm.$scopedSlots = emptyObject

  // 爲實例綁定渲染虛擬節點函數_c和$createElement
  // 內部實際調用createElement函數,並獲得恰當的渲染上下文
  // 參數按順序分別是:標籤、數據、子節點、標準化類型、是否標準化標識
  // bind the createElement fn to this instance
  // so that we get proper render context inside it.
  // args order: tag, data, children, normalizationType, alwaysNormalize

  // 內部版本_c被從模板編譯的渲染函數使用
  // internal version is used by render functions compiled from templates
  vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
  // 用戶寫的渲染函數會總是應用執行標準化的公共版本
  // normalization is always applied for the public version, used in
  // user-written render functions.
  vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)

  // 爲了更容易創建高階組件,暴露了$attrs 和 $listeners
  // 並且需要保持屬性的響應性以便能夠實現更新,以下是對屬性的響應處理
  // $attrs & $listeners are exposed for easier HOC creation.
  // they need to be reactive so that HOCs using them are always updated
  const parentData = parentVnode && parentVnode.data

  // 對屬性和事件監聽器進行響應處理,建立觀察狀態
  /* istanbul ignore else */
  if (process.env.NODE_ENV !== 'production') {
    // 在非生產環境時檢測是否屬於可讀併發出警告
    defineReactive(vm, '$attrs', parentData && parentData.attrs || emptyObject, () => {
      !isUpdatingChildComponent && warn(`$attrs is readonly.`, vm)
    }, true)
    defineReactive(vm, '$listeners', options._parentListeners || emptyObject, () => {
      !isUpdatingChildComponent && warn(`$listeners is readonly.`,  vm)
    }, true)
  } else {
    defineReactive(vm, '$attrs', parentData && parentData.attrs || emptyObject, null, true)
    defineReactive(vm, '$listeners', options._parentListeners || emptyObject, null, true)
  }
}

initRender 函數爲實例進行了初始化處理,主要有三件事:

  • 初始化相關屬性
  • 設置綁定了上下文的生成虛擬節點的私有和共有版函數
  • 對節點的屬性和事件監聽器進行狀態觀察

生成虛擬節點函數主要會在流程中的 render 函數中使用。對節點屬性和事件監聽器的響應處理保證了在生命週期過程中節點屬性和事件狀態的更新。

掛載方法初始化

// 導出renderMixin函數,接收形參Vue,
// 使用Flow進行靜態類型檢查指定爲Component類
export function renderMixin (Vue: Class<Component>) {
  // 爲Vue原型對象綁定運行時相關的輔助方法
  // install runtime convenience helpers
  installRenderHelpers(Vue.prototype)

  // 掛載Vue原型對象的$nextTick方法,接收函數類型的fn形參
  Vue.prototype.$nextTick = function (fn: Function) {
    // 返回nextTick函數的執行結果
    return nextTick(fn, this)
  }
  // 掛載Vue原型對象的_render方法,期望返回虛擬節點對象
  // _render方法即是根據配置對象在內部生成虛擬節點的方法
  Vue.prototype._render = function (): VNode {
    // 將實例賦值給vm變量
    const vm: Component = this
    // 導入vm的$options對象的render方法和_parentVnode對象
    const { render, _parentVnode } = vm.$options

    // 非生產環境下重置插槽上的_rendered標誌以進行重複插槽檢查
    // reset _rendered flag on slots for duplicate slot check
    if (process.env.NODE_ENV !== 'production') {
      for (const key in vm.$slots) {
        // $flow-disable-line
        vm.$slots[key]._rendered = false
      }
    }

    // 如果有父級虛擬節點,定義並賦值實例的$scopedSlots屬性
    if (_parentVnode) {
      vm.$scopedSlots = _parentVnode.data.scopedSlots || emptyObject
    }

    // 設置實例的父虛擬節點,允許render函數訪問佔位符節點的數據
    // set parent vnode. this allows render functions to have access
    // to the data on the placeholder node.
    vm.$vnode = _parentVnode
    // 定義渲染節點
    // render self
    let vnode
    // 在實例的渲染代理對象上調用render方法,並傳入$createElement參數
    try {
      vnode = render.call(vm._renderProxy, vm.$createElement)
    } catch (e) {
      // 處理錯誤
      handleError(e, vm, `render`)
      // 返回錯誤渲染結果或者前一虛擬節點,防止渲染錯誤導致的空白組件
      // return error render result,
      // or previous vnode to prevent render error causing blank component
      // 非生產環境特殊處理渲染錯誤
      /* istanbul ignore else */
      if (process.env.NODE_ENV !== 'production') {
        if (vm.$options.renderError) {
          try {
            vnode = vm.$options.renderError.call(vm._renderProxy, vm.$createElement, e)
          } catch (e) {
            handleError(e, vm, `renderError`)
            vnode = vm._vnode
          }
        } else {
          vnode = vm._vnode
        }
      } else {
        vnode = vm._vnode
      }
    }
    // 在渲染函數出錯時返回空虛擬節點
    // return empty vnode in case the render function errored out
    if (!(vnode instanceof VNode)) {
      // 非生產環境報錯
      if (process.env.NODE_ENV !== 'production' && Array.isArray(vnode)) {
        warn(
          'Multiple root nodes returned from render function. Render function ' +
          'should return a single root node.',
          vm
        )
      }
      // 創建空的虛擬節點
      vnode = createEmptyVNode()
    }
    // 設置父虛擬節點
    // set parent
    vnode.parent = _parentVnode
    // 返回虛擬節點
    return vnode
  }
}

渲染模塊掛載了兩個方法 $nextTick 公共方法和 _render 私有方法$nextTick 是實例的公有方法,這個很常見,就不多說;_render 是內部用來生成 VNode 的方法,內部調用了 initRender 函數中綁定的 createElement 函數,初始化實例一般會調用實例的公共版方法,如果是創建組件則會調用私有版方法。

renderMixin 函數在執行時還爲Vue實例綁定了一些處理渲染的工具函數,具體可查看源代碼

mount 路徑的具體實現

按照創建Vue實例的一般流程,初始化處理好之後,最後一步執行的 vm.$mount(vm.$options.el) 就宣告 mount 渲染路徑的開始。記得好像還沒有見過 $mount 的定義,因爲這個函數是在運行時掛在到原型對象上的,web端的源代碼在 platforms/web 中,同樣要值得注意的是原型的 __patch__ 方法也是在運行時定義的。代碼片段如下所示:

// install platform patch function
Vue.prototype.__patch__ = inBrowser ? patch : noop

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

雖然這兩個方法都是在運行時才定義,但各自都是引用了核心代碼中定義的實際實現函數:mountComponentpatch,下面就按照執行的流程一步步來解析這些實現渲染功能的函數。

mountComponent

源代碼位於core/instance/lifecycle.js中。

// 定義並導出mountComponent函數
// 接受Vue實例vm,DOM元素el、布爾標識hydrating參數
// 後兩參數可選,返回組件實例
export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  // 設置實例的$el屬性
  vm.$el = el
  // 檢測實例屬性$options對象的render方法,未定義則設置爲創建空節點
  if (!vm.$options.render) {
    vm.$options.render = createEmptyVNode
    // 非生產環境檢測構建版本並警告
    if (process.env.NODE_ENV !== 'production') {
      /* istanbul ignore if */
      if ((vm.$options.template && vm.$options.template.charAt(0) !== '#') ||
        vm.$options.el || el) {
        warn(
          'You are using the runtime-only build of Vue where the template ' +
          'compiler is not available. Either pre-compile the templates into ' +
          'render functions, or use the compiler-included build.',
          vm
        )
      } else {
        warn(
          'Failed to mount component: template or render function not defined.',
          vm
        )
      }
    }
  }
  // 調用生命週期鉤子函數beforeMount,準備首次加載
  callHook(vm, 'beforeMount')

  // 定義updateComponent方法
  let updateComponent
  // 非生產環境加入性能評估
  /* istanbul ignore if */
  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
    updateComponent = () => {
      const name = vm._name
      const id = vm._uid
      const startTag = `vue-perf-start:${id}`
      const endTag = `vue-perf-end:${id}`

      mark(startTag)
      const vnode = vm._render()
      mark(endTag)
      measure(`vue ${name} render`, startTag, endTag)

      mark(startTag)
      vm._update(vnode, hydrating)
      mark(endTag)
      measure(`vue ${name} patch`, startTag, endTag)
    }
  } else {
    // 定義updateComponent內部調用實例的_update方法
    // 參數爲按實例狀態生成的新虛擬節點樹和hydrating標識
    updateComponent = () => {
      vm._update(vm._render(), hydrating)
    }
  }

  // 在Watcher類內部將此監聽器設置到實例的_watcher上。
  // 由於初次patch可能調用$forceUpdate方法(例如在子組件的mounted鉤子),
  // 這依賴於已經定義好的vm._watcher
  // we set this to vm._watcher inside the watcher's constructor
  // since the watcher's initial patch may call $forceUpdate (e.g. inside child
  // component's mounted hook), which relies on vm._watcher being already defined
  // 建立對渲染的觀察,最末參數聲明爲渲染監聽器,並傳入監視器的before方法,
  // 在初次渲染之後,實例的_isMounted爲true,在每次渲染更新之前會調用update鉤子
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
  // 設置hydrating標識爲false
  hydrating = false

  // 手動安裝的實例,mounted調用掛載在自身
  // 渲染創建的子組件在其插入的鉤子中調用了mounted
  // manually mounted instance, call mounted on self
  // mounted is called for render-created child components in its inserted hook
  // vm.$vnode爲空設置_isMounted屬性爲true,並調用mounted鉤子
  // vm.$vnode爲空是因爲實例是根組件,沒有父級節點。
  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }
  // 返回實例
  return vm
}

updateComponent

updateComponent 函數在上一流程中定義,在執行過程中傳入爲待觀察屬性創建的監視器中,並在首次渲染時被調用。可以在上述代碼中看出,其內部是執行了實例的 _update 方法,並傳入實例 _render 方法的執行結果和 hydrating 參數,hydrating 似乎是與服務器端渲染有關的標識屬性,暫時不太清楚具體的作用。

_render

在文首的 renderMixin 函數中定義,返回虛擬節點作爲傳入下一流程 _update 的第一個參數。

_update

在前文生命週期中的 lifecycleMixin 函數中定義,正是在這個方法中,發生了執行路徑的分流,在 mount 路徑中,執行首次渲染分支,將掛載的DOM元素和 _render 首次生成的虛擬節點傳入 patch 函數中。

patch

patch 方法定義在 platforms/web/runtime/patch.js中:

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

從最後一句代碼可以看出,patch 得到的是 createPatchFunction 執行後內部返回的 patch 函數,傳入的是平臺特有的參數。在 createPatchFunction 函數執行過程中定義了一系列閉包函數來實現最終的DOM渲染,具體代碼非常多,簡單解釋一下其內部定義的各種函數的用途,最後詳細探索一下 patch 函數的具體實現。

// 定義並導出createPatchFunction函數,接受backend參數
// backend參數是一個含有平臺相關BOM操作的對象方法集
export function createPatchFunction (backend) {

  // 創建空虛擬節點函數
  function emptyNodeAt (elm) {}

  // 創建移除DOM節點回調
  function createRmCb (childElm, listeners) {}

  // 移除DOM節點
  function removeNode (el) {}

  // 判斷是否是未知元素
  function isUnknownElement (vnode, inVPre) {}

  // 創建並插入DOM元素
  function createElm (
    vnode,
    insertedVnodeQueue,
    parentElm,
    refElm,
    nested,
    ownerArray,
    index
  ) {}

  // 初始化組件
  function initComponent (vnode, insertedVnodeQueue) {}

  // 激活組件
  function reactivateComponent (vnode, insertedVnodeQueue, parentElm, refElm) {}

  // 插入DOM節點
  function insert (parent, elm, ref) {}

  // 創建子DOM節點
  function createChildren (vnode, children, insertedVnodeQueue) {}

  // 判斷節點是否可對比更新
  function isPatchable (vnode) {}

  // 調用創建鉤子
  function invokeCreateHooks (vnode, insertedVnodeQueue) {}

  // 爲組件作用域CSS設置範圍id屬性。
  // 這是作爲一種特殊情況實現的,以避免通過正常的屬性修補過程的開銷。
  // set scope id attribute for scoped CSS.
  // this is implemented as a special case to avoid the overhead
  // of going through the normal attribute patching process.
  // 設置CSS作用域ID
  function setScope (vnode) {}

  // 添加虛擬節點,內部調用createElm
  function addVnodes () {}

  // 調用銷燬鉤子
  function invokeDestroyHook (vnode) {}

  // 移除虛擬節點,內部調用removeNode或removeAndInvokeRemoveHook
  function removeVnodes (parentElm, vnodes, startIdx, endIdx) {}

  // 調用移除事件回調函數並移除節點
  function removeAndInvokeRemoveHook (vnode, rm) {}

  // 更新子節點
  function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {}

  // 檢查重複key
  function checkDuplicateKeys (children) {}

  // 尋找舊子節點索引
  function findIdxInOld (node, oldCh, start, end) {}

  // 對比並更新虛擬節點
  function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {}

  // 調用插入鉤子
  function invokeInsertHook (vnode, queue, initial) {}

  // 渲染混合
  // 注意:這是一個僅限瀏覽器的函數,因此我們可以假設elms是DOM節點。
  // Note: this is a browser-only function so we can assume elms are DOM nodes.
  function hydrate (elm, vnode, insertedVnodeQueue, inVPre) {}

  // 判斷節點匹配
  function assertNodeMatch (node, vnode, inVPre) {}

  // 節點補丁函數
  // 接受舊新虛擬節點,hydrating和removeOnly標識
  return function patch (oldVnode, vnode, hydrating, removeOnly) {
    // 如果新虛擬節點未定義且存在舊節點,則調用銷燬節點操作並返回
    // 這一步的判斷是因爲在舊虛擬節點存時,變動後沒有生成新虛擬節點
    // 則說明新結構是不存在的,所以要清空舊節點。
    if (isUndef(vnode)) {
      if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
      return
    }

    // 初始化isInitialPatch標識和insertedVnodeQueue隊列
    let isInitialPatch = false
    const insertedVnodeQueue = []

    // 以下分兩種情況構建節點:
    // 如果不存在舊虛擬節點
    if (isUndef(oldVnode)) {
      // 空掛載(比如組件),會創建新的根元素
      // empty mount (likely as component), create new root element
      // 這種情況說明時首次渲染,設置isInitialPatch爲true
      isInitialPatch = true
      // 根據虛擬節點創建新DOM節點
      createElm(vnode, insertedVnodeQueue)
    } else {
      // 存在舊虛擬節點
      // 判斷舊虛擬節點是否是真實的DOM元素
      const isRealElement = isDef(oldVnode.nodeType)
      // 如果不是真實DOM節點並且新舊虛擬節點根節點相同
      if (!isRealElement && sameVnode(oldVnode, vnode)) {
        // 執行比較新舊節點更新DOM操作
        // patch existing root node
        patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly)
      } else {
        // 新舊節點不相同的情況
        // 舊節點是DOM元素時先將舊節點轉換成虛擬節點
        if (isRealElement) {
          // 掛在到真實DOM元素
          // 檢查是否是服務器渲染,然後執行合併操作
          // mounting to a real element
          // check if this is server-rendered content and if we can perform
          // a successful hydration.
          // 下面這兩個if語句裏的操作都是服務器渲染相關,暫不去了解
          if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
            oldVnode.removeAttribute(SSR_ATTR)
            hydrating = true
          }
          if (isTrue(hydrating)) {
            if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
              invokeInsertHook(vnode, insertedVnodeQueue, true)
              return oldVnode
            } else if (process.env.NODE_ENV !== 'production') {
              warn(
                'The client-side rendered virtual DOM tree is not matching ' +
                'server-rendered content. This is likely caused by incorrect ' +
                'HTML markup, for example nesting block-level elements inside ' +
                '<p>, or missing <tbody>. Bailing hydration and performing ' +
                'full client-side render.'
              )
            }
          }
          // 如果不是服務器渲染或合併失敗,生成空的虛擬節點
          // either not server-rendered, or hydration failed.
          // create an empty node and replace it
          oldVnode = emptyNodeAt(oldVnode)
        }

        // 定義舊元素oldElm和其父元素
        // replacing existing element
        const oldElm = oldVnode.elm
        const parentElm = nodeOps.parentNode(oldElm)

        // 根據新虛擬節點創建新DOM元素,並且會插入到DOM樹中
        // create new node
        createElm(
          vnode,
          insertedVnodeQueue,
          // 以下參數是#4590問題的解決處理
          // 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)
        )

        // 如果新的虛擬節點有父級則以遞歸方式更新父佔位符節點元素
        // cbs是在生成patch函數時初始化好的事件監聽器
        // 在此條件中也會被逐一觸發
        // update parent placeholder node element, recursively
        if (isDef(vnode.parent)) {
          let ancestor = vnode.parent
          const patchable = isPatchable(vnode)
          while (ancestor) {
            for (let i = 0; i < cbs.destroy.length; ++i) {
              cbs.destroy[i](ancestor)
            }
            ancestor.elm = vnode.elm
            if (patchable) {
              for (let i = 0; i < cbs.create.length; ++i) {
                cbs.create[i](emptyNode, ancestor)
              }
              // #6513
              // invoke insert hooks that may have been merged by create hooks.
              // e.g. for directives that uses the "inserted" hook.
              const insert = ancestor.data.hook.insert
              if (insert.merged) {
                // start at index 1 to avoid re-invoking component mounted hook
                for (let i = 1; i < insert.fns.length; i++) {
                  insert.fns[i]()
                }
              }
            } else {
              registerRef(ancestor)
            }
            ancestor = ancestor.parent
          }
        }

        // 銷燬舊節點
        // destroy old node
        // 如果舊節點的父級元素存在,則從其上移除舊節點
        if (isDef(parentElm)) {
          removeVnodes(parentElm, [oldVnode], 0, 0)
        } else if (isDef(oldVnode.tag)) {
          // 否則視爲不存在舊DOM節點,此時如果虛擬節點有標籤名
          // 則調用舊虛擬節點銷燬鉤子
          invokeDestroyHook(oldVnode)
        }
      }
    }

    // 最後調用新節點的插入鉤子
    invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
    // 返回虛擬節點的真實DOM元素
    return vnode.elm
  }
}

createPatchFunction 函數內容非常多,但大多數函數都是輔助性的,與節點處理和回調函數鉤子相關。大致上瞭解作用即可。

patch 方法的執行首先分了兩條路線:

  • 不存在舊虛擬節點直接創建新節點插入到DOM樹,這是首次渲染的執行路徑,這種情況簡單。
  • 存在舊虛擬節點時需進行對比再更新,這種情況比較複雜,其中又要分舊節點是否是真實DOM的情況,是虛擬節點並且與新生成虛擬節點相等(這裏的相等是指同樣的虛擬根節點,具體可參照sameVnode的代碼查看條件)則直接進行對比更新;若是真實節點要先進行到虛擬節點的轉換還有與服務器渲染相關的判斷,然後再根據得到的結果創建新的DOM節點插入頁面,最後還要分情況進行父節點的遞歸更新和移除舊節點。

patch 方法的實現方式是有跡可循的,在這源代碼中,可以看出之前劃分的 mountupdate 的執行流程,但要注意的是,上述的條件判斷劃分的路線和邏輯上劃分的流程是稍有區別的,mount 路徑其實在代碼裏體現爲 !oldVnodeoldVnode 路線中是真實DOM元素的情況,跨越了兩個條件,主要體現在直接調用了 createElm 創建並插入新節點,這是因爲在渲染時分爲有無聲明掛載的真實DOM元素兩種情況。而 update 直接進入的是 patchVnode 對比操作。雖然有點繞但是需要分清楚這種區別。然而具體如何實現節點的創建和對比更新還是得繼續往裏層看,由於這一條路徑是講 mount 情況,所以往下先看看與之接續的 createElm 函數。

createElm

// 定義createElm函數,一系列參數主要記住vnode,parentElm
function createElm (
  vnode,
  insertedVnodeQueue,
  parentElm,
  refElm,
  nested,
  ownerArray,
  index
) {
  // 如果新虛擬節點存在真實DOM元素和ownerArray,
  // 則代表它在之前的渲染中用過。
  // 現在要被用作新節點時有潛在的錯誤
  // 所以將它改爲從本身克隆的節點
  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)
  }

  // 設置isRootInsert,爲檢查過度動畫入口
  vnode.isRootInsert = !nested // for transition enter check
  // 下面判斷用於keep-alive組件,若是普通組件則會返回undefined繼續往下執行
  if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
    return
  }

  // 獲取虛擬節點信息、子節點和標籤名稱
  const data = vnode.data
  const children = vnode.children
  const tag = vnode.tag
  // 下面三種情況創建普通節點、註釋節點和文字節點
  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
        )
      }
    }

    // 根據ns屬性選擇創建節點的方式創建節點
    vnode.elm = vnode.ns
      ? nodeOps.createElementNS(vnode.ns, tag)
      : nodeOps.createElement(tag, vnode)
    // 設置節點的作用域ID
    setScope(vnode)

    // 如果是weex平臺,可以根據參數調整節點樹插入DOM的具體實現
    /* 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 {
      // web平臺則先創建子節點插入父級後再一次插入DOM中
      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)) {
    // 如果是註釋節點,則創建註釋節點並插入到DOM中
    vnode.elm = nodeOps.createComment(vnode.text)
    insert(parentElm, vnode.elm, refElm)
  } else {
    // 如果是文字節點,則創建文字節點並插入到DOM
    vnode.elm = nodeOps.createTextNode(vnode.text)
    insert(parentElm, vnode.elm, refElm)
  }
}

createElm 函數包含了節點的創建和插入兩部分,創建了虛擬節點對應的DOM元素之後,就會調用 insert 方法將它插入到頁面DOM結構中。創建功能在這裏遵循DOM的三種節點類型,即元素、註釋和文字節點,實際與插入和移除方法一樣都是使用了對應的原生方法 ,nodeops 對象即是在返回 patch 函數時預先導入了的原生DOM操作方法的集合,具體可以在運行時的處理中確認。之前生成的 vnode 決定了最終應該生成何種節點,在這個函數中就能夠發現,最終生成的真實DOM節點是多麼依賴於 vnode 所攜帶的信息,所以說虛擬節點是實現生成真實DOM的基礎。

這個流程中最後一步再調用 removeVnodes 方法移除掉DOM樹中的舊節點,到此爲止 mount 路徑的執行就結束了。

update 路徑的具體實現

根據 update 的執行流程,前一部分是由 watcher 來響應的,就不再討論,然後進入 updateComponent 流程,直至返回 patch 函數都與 mount 流程的實現一致,只是要執行不同的分支,整個流程中只有最後一步生成真實DOM的過程有所區別,就是 patchVnode 函數的執行。上面已經說過 update 流程中最後是要對比新舊節點然後再實現更新,這個功能即由 patchVnode 來完成,它的內部調用 updateChildren 來完成對比,實現邏輯非常有借鑑性,值得玩味。下面來看看這兩個函數,

patchVnode

// 定義patchVnode函數,接收四個參數
function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
  // 如果新舊虛擬節點相同則結束對比
  if (oldVnode === vnode) {
    return
  }

  // 獲取並設置新虛擬節點的真實DOM元素
  const elm = vnode.elm = oldVnode.elm

  // 異步佔位符節點的特殊處理
  if (isTrue(oldVnode.isAsyncPlaceholder)) {
    if (isDef(vnode.asyncFactory.resolved)) {
      hydrate(oldVnode.elm, vnode, insertedVnodeQueue)
    } else {
      vnode.isAsyncPlaceholder = true
    }
    return
  }

  // 爲靜態樹重用元素
  // 只在克隆虛擬節點時使用,如非克隆節點則需要重新渲染
  // reuse element for static trees.
  // note we only do this if the vnode is cloned -
  // if the new node is not cloned it means the render functions have been
  // reset by the hot-reload-api and we need to do a proper re-render.
  if (isTrue(vnode.isStatic) &&
    isTrue(oldVnode.isStatic) &&
    vnode.key === oldVnode.key &&
    (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
  ) {
    vnode.componentInstance = oldVnode.componentInstance
    return
  }

  // 如果存在內聯預處理鉤子則調用
  let i
  const data = vnode.data
  if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
    i(oldVnode, vnode)
  }

  // 下面是對一般情況的DOM更新處理
  // 獲取虛擬節點子節點
  const oldCh = oldVnode.children
  const ch = vnode.children
  // 如果存在更新鉤子則調用
  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)
  }
  // 當新虛擬節點不存在text屬性值,即不是文字節點時
  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, '')
      // 再向DOM元素插入新虛擬節點內容
      addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
    } else if (isDef(oldCh)) {
      // 情況三,只有舊虛擬節點子節點存在,則移除DOM元素內容
      removeVnodes(elm, oldCh, 0, oldCh.length - 1)
    } else if (isDef(oldVnode.text)) {
      // 情況四,新舊虛擬節點子節點不存在且舊虛擬節點是文字節點
      // 置空DOM元素文本內容
      nodeOps.setTextContent(elm, '')
    }
  } else if (oldVnode.text !== vnode.text) {
    // 新虛擬節點是文字節點時,除非舊節點也是文字節點且內容相等
    // 直接將新文本內容設置到DOM元素中
    nodeOps.setTextContent(elm, vnode.text)
  }
  // 如果存在後處理鉤子則調用
  if (isDef(data)) {
    if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
  }
}

patchVnode 的內容主要有三點,第一是處理異步虛擬節點;第二是處理靜態可重用元素;第三是處理一般情況下的新舊節點更新。

一般情況下的新舊節點更新首先是按照新虛擬節點是否文字節點來分情況,因爲DOM的更新決定權在於新的虛擬節點內容,如果是新節點是文字節點,則可以不用在意舊節點的情況,除非舊節點也是文本內容且內容無異時不需要處理,其他情況下都直接爲DOM元素內容重置爲新虛擬節點的文本。如果新節點不是文字節點,處理會再細分爲四種情況:第一是新舊虛擬子節點都存在且不相等時,執行patch核心的更新操作 updateChildren。第二是隻有新子節點存在而舊子節點不存在,如果舊節點是文字節點,先要置空就節點的文本內容,再向DOM元素添加新字節點的內容。第三是隻有舊子節點存在而新子節點不存在時,說明更新後沒有節點了,執行移除操作。第四是新舊子節點不存在而舊節點是文字節點時,清空DOM元素的文本內容。

這裏要十分注意理清虛擬節點和其子節點的比較。只有當新舊虛擬節點與其各自子虛擬節點都存儲的是元素節點時,才需要調用 updateChildren 函數來進行深入比較,其他的情況都可以比較簡便的處理DOM節點的更新,這也避免了不必要的處理提高了渲染的性能。

最後來看看整個DOM節點對比更新的核心邏輯函數:

updateChildren

// 定義updateChildren函數,接受5個參數
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

  // removeOnly是僅用於<transition-group>情況下的特殊標識,
  // 確保移除的元素在離開過渡期間保持在正確的相對位置。
  // removeOnly is a special flag used only by <transition-group>
  // to ensure removed elements stay in correct relative positions
  // during leaving transitions
  const canMove = !removeOnly

  // 檢查新節點中有無重複key
  if (process.env.NODE_ENV !== 'production') {
    checkDuplicateKeys(newCh)
  }

  // 以增加索引值模擬移動指針,逐一對比對應索引位置的節點
  // 循環僅在在新舊開始索引同時小於各自結束索引時才繼續進行
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    // 對比具體分爲7種情況:
    if (isUndef(oldStartVnode)) {
      // 當前舊首節點不存在時,遞增舊開始索引指向後一節點
      oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
    } else if (isUndef(oldEndVnode)) {
      // 當前舊尾節點不存在時,遞減舊結束索引指向前一節點
      oldEndVnode = oldCh[--oldEndIdx]
    } else if (sameVnode(oldStartVnode, newStartVnode)) {
      // 當前新舊首節點相同,遞歸調用patchVnode對比子級
      patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
      // 遞增新舊開始索引,當前新舊節點指向各自後一節點
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      // 當前新舊尾節點相同,遞歸調用patchVnode對比子級
      patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
      // 遞減新舊結束索引,當前新舊尾節點指向前一節點
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
      // 當前舊首節點與當前新尾節點相同,遞歸調用patchVnode對比
      patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
      // canMove爲真則將當前舊首節點移動到下一兄弟節點前
      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
      patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
      // canMove爲真則將當前舊尾節點移動到當前舊首節點前
      canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
      // 遞減舊節點結束索引,當前舊尾節點指向前一節點
      oldEndVnode = oldCh[--oldEndIdx]
      // 遞增新節點開始索引,當前新首節點指向後一節點
      newStartVnode = newCh[++newStartIdx]
    } else {
      // 其他情況下
      // oldKeyToIdx未定義時根據舊節點創建key和索引鍵值對集合
      if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
      // 如果當前新首節點的key存在,則idxInOld等於oldKeyToIdx中對應key的索引
      // 否則尋找舊節點數組中與當前新首節點相同的節點索引賦予idxInOld
      idxInOld = isDef(newStartVnode.key)
        ? oldKeyToIdx[newStartVnode.key]
        : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
      //  如果idxInOld不存在,則說明當前對比的新節點是新增節點
      if (isUndef(idxInOld)) { // New element
        // 創建新節點插入到父級對應位置
        createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
      } else {
        // 在舊節點數組中找到了相應的節點的索引時
        // 將vnodeToMove賦值爲相應的節點
        vnodeToMove = oldCh[idxInOld]
        // 對比此節點和當前新首節點
        if (sameVnode(vnodeToMove, newStartVnode)) {
          // 如果相同,則繼續對比子級
          patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)
          // 將舊節點數組中的該節點設置爲undefined
          oldCh[idxInOld] = undefined
          // 移動找到的節點到當前舊首節點之前
          canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
        } else {
          // 如不同,則說明雖然key相同,但是不同元素,當作新元素處理
          // same key but different element. treat as new element
          // 創建新元素闖入父級相應位置
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        }
      }
      // 遞增新節點開始索引,當前新首節點指向下一節點
      newStartVnode = newCh[++newStartIdx]
    }
  }
  // 新舊節點開始索引任一方大於其結束索引時結束循環
  // 當舊節點開始索引大於舊節點結束索引時
  if (oldStartIdx > oldEndIdx) {
    // 判斷新節點數組中newEndIdx索引後的節點是否存在,若不存在refElm爲null
    // 若存在則refElm爲相應節點的elm值
    refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
    // 向父節點相應位置添加該節點
    addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
  } else if (newStartIdx > newEndIdx) {
    // 當新節點開始索引大於新節點結束索引時
    // 在父級中移除未處理的剩餘舊節點,範圍是oldStartIdx~oldEndIdx
    removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
  }
}

updateChildren 函數的主要邏輯是利用索引來替換當前節點的引用,有如模擬指針移動指向的對象,來逐一進行對比,並且是遞歸進行的。指針移動的基準是參照新節點,條件滿足下,根據當前的新節點來尋找舊節點中對應的節點,如果相等會遞歸進入子級,如果不相等當作新增節點處理,在處理之後會移動到下一個節點,繼續新一輪的對比。在舊節點數組中將對比過的節點設置成 undefined 標誌節點已處理過,避免了以後的多餘對比。這裏的處理邏輯是相當巧妙的,這就是節點對比更新的最基礎的實現。


終於把我認爲Vue最核心的另一個主要功能給攻略了下來,真是激動人心。比起數據綁定,這一部分的實現也着實不簡單,光是處理流就讓人凌亂不堪。patch 所實際對應的 createPatchFunction 函數是這一模塊的重中之重,理順了更新渲染的流程,繼而理解了這一函數的具體實現後,基本上能對Vue的渲染功能有了一定深度的把握。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章