1. 程式人生 > >Vue原始碼探究-虛擬DOM的渲染

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的渲染功能有了一定深度的把握。

原文地址:https://segmentfault.com/a/1190000017231906