Vue 源碼深入解析之 生命週期、組件註冊和異步組件

一、生命週期

  1. 每個 Vue 實例在被創建之前都要經過一系列的初始化過程。例如需要設置數據監聽、編譯模板、掛載實例到 DOM、在數據變化時更新 DOM 等。同時在這個過程中也會運行一些叫做生命週期鉤子的函數,給予用戶機會在一些特定的場景下添加他們自己的代碼。在我們實際項目開發過程中,會非常頻繁地和 Vue 組件的生命週期打交道,接下來我們就從源碼的角度來看一下這些生命週期的鉤子函數是如何被執行的。源碼中最終執行生命週期的函數都是調用 callHook 方法,它的定義在 src/core/instance/lifecycle 中:
export function callHook (vm: Component, hook: string) {
  // #7573 disable dep collection when invoking lifecycle hooks
  pushTarget()
  const handlers = vm.$options[hook]
  if (handlers) {
    for (let i = 0, j = handlers.length; i < j; i++) {
      try {
        handlers[i].call(vm)
      } catch (e) {
        handleError(e, vm, `${hook} hook`)
      }
    }
  }
  if (vm._hasHookEvent) {
    vm.$emit('hook:' + hook)
  }
  popTarget()
}

callHook 函數的邏輯很簡單,根據傳入的字符串 hook,去拿到 vm.$options[hook] 對應的回調函數數組,然後遍歷執行,執行的時候把 vm 作爲函數執行的上下文。

  1. 在之前詳細地介紹了 Vue.js 合併 options 的過程,各個階段的生命週期的函數也被合併到 vm.$options 裏,並且是一個數組。因此 callhook 函數的功能就是調用某個生命週期鉤子註冊的所有回調函數。瞭解了生命週期的執行方式後,接下來會具體介紹每一個生命週期函數它的調用時機。

  2. beforeCreate & created,如下所示:

  • beforeCreatecreated 函數都是在實例化 Vue 的階段,在 _init 方法中執行的,它的定義在 src/core/instance/init.js 中:
Vue.prototype._init = function (options?: Object) {
  // ...
  initLifecycle(vm)
  initEvents(vm)
  initRender(vm)
  callHook(vm, 'beforeCreate')
  initInjections(vm) // resolve injections before data/props
  initState(vm)
  initProvide(vm) // resolve provide after data/props
  callHook(vm, 'created')
  // ...
}
  • 可以看到 beforeCreatecreated 的鉤子調用是在 initState 的前後,initState 的作用是初始化 propsdatamethodswatchcomputed 等屬性,。那麼顯然 beforeCreate 的鉤子函數中就不能獲取到 propsdata 中定義的值,也不能調用 methods 中定義的函數。

  • 在這倆個鉤子函數執行的時候,並沒有渲染 DOM,所以我們也不能夠訪問 DOM,一般來說,如果組件在加載的時候需要和後端有交互,放在這倆個鉤子函數執行都可以,如果是需要訪問 propsdata 等數據的話,就需要使用 created 鉤子函數,之後發現它們都混合了 beforeCreate 鉤子函數。

  1. beforeMount & mounted,如下所示:
  • beforeMount 鉤子函數發生在 mount,也就是 DOM 掛載之前,它的調用時機是在 mountComponent 函數中,定義在 src/core/instance/lifecycle.js 中:
export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  vm.$el = el
  // ...
  callHook(vm, 'beforeMount')

  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 = () => {
      vm._update(vm._render(), hydrating)
    }
  }

  // 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
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
  hydrating = false

  // manually mounted instance, call mounted on self
  // mounted is called for render-created child components in its inserted hook
  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }
  return vm
}
  • 在執行 vm._render() 函數渲染 VNode 之前,執行了 beforeMount 鉤子函數,在執行完 vm._update()VNode patch 到真實 DOM 後,執行 mounted 鉤子。注意,這裏對 mounted 鉤子函數執行有一個判斷邏輯,vm.$vnode 如果爲 null,則表明這不是一次組件的初始化過程,而是我們通過外部 new Vue 初始化過程。
  • 組件的 VNode patchDOM 後,會執行 invokeInsertHook 函數,把 insertedVnodeQueue 裏保存的鉤子函數依次執行一遍,它的定義在 src/core/vdom/patch.js 中:
function invokeInsertHook (vnode, queue, initial) {
  // delay insert hooks for component root nodes, invoke them after the
  // element is really inserted
  if (isTrue(initial) && isDef(vnode.parent)) {
    vnode.parent.data.pendingInsert = queue
  } else {
    for (let i = 0; i < queue.length; ++i) {
      queue[i].data.hook.insert(queue[i])
    }
  }
}
  • 該函數會執行 insert 這個鉤子函數,對於組件而言,insert 鉤子函數的定義在 src/core/vdom/create-component.js 中的 componentVNodeHooks 中:
const componentVNodeHooks = {
  // ...
  insert (vnode: MountedComponentVNode) {
    const { context, componentInstance } = vnode
    if (!componentInstance._isMounted) {
      componentInstance._isMounted = true
      callHook(componentInstance, 'mounted')
    }
    // ...
  },
}
  • 我們可以看到,每個子組件都是在這個鉤子函數中執行 mounted 鉤子函數,並且我們之前分析過,insertedVnodeQueue 的添加順序是先子後父,所以對於同步渲染的子組件而言,mounted 鉤子函數的執行順序也是先子後父。
  1. beforeUpdate & updated,如下所示:
  • beforeUpdateupdated 的鉤子函數執行時機都應該是在數據更新的時候,beforeUpdate 的執行時機是在渲染 Watcherbefore 函數中,我們剛纔提到過:
export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  // ...

  // 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
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
  // ...
}

  • 注意這裏有個判斷,也就是在組件已經 mounted 之後,纔會去調用這個鉤子函數。

  • update 的執行時機是在flushSchedulerQueue 函數調用的時候,它的定義在 src/core/observer/scheduler.js 中:

function flushSchedulerQueue () {
  // ...
  // 獲取到 updatedQueue
  callUpdatedHooks(updatedQueue)
}

function callUpdatedHooks (queue) {
  let i = queue.length
  while (i--) {
    const watcher = queue[i]
    const vm = watcher.vm
    if (vm._watcher === watcher && vm._isMounted) {
      callHook(vm, 'updated')
    }
  }
}
  • flushSchedulerQueue 函數我們之後會詳細介紹,updatedQueue 是更新了的 wathcer 數組,那麼在 callUpdatedHooks 函數中,它對這些數組做遍歷,只有滿足當前 watchervm._watcher 以及組件已經 mounted 這兩個條件,纔會執行 updated 鉤子函數。

  • 我們之前提過,在組件 mount 的過程中,會實例化一個渲染的 Watcher 去監聽 vm 上的數據變化重新渲染,這段邏輯發生在 mountComponent 函數執行的時候:

export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  // ...
  // 這裏是簡寫
  let updateComponent = () => {
      vm._update(vm._render(), hydrating)
  }
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
  // ...
}
  • 那麼在實例化 Watcher 的過程中,在它的構造函數裏會判斷 isRenderWatcher,接着把當前 watcher 的實例賦值給 vm._watcher,定義在 src/core/observer/watcher.js 中:
export default class Watcher {
  // ...
  constructor (
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: ?Object,
    isRenderWatcher?: boolean
  ) {
    this.vm = vm
    if (isRenderWatcher) {
      vm._watcher = this
    }
    vm._watchers.push(this)
    // ...
  }
}
  • 同時,還把當前 wathcer 實例 pushvm._watchers 中,vm._watcher 是專門用來監聽 vm 上數據變化然後重新渲染的,所以它是一個渲染相關的 watcher,因此在 callUpdatedHooks 函數中,只有 vm._watcher 的回調執行完畢後,纔會執行 updated 鉤子函數。
  1. beforeDestroy & destroyed,如下所示:
  • beforeDestroydestroyed 鉤子函數的執行時機在組件銷燬的階段,最終會調用 $destroy 方法,它的定義在 src/core/instance/lifecycle.js 中:
Vue.prototype.$destroy = function () {
    const vm: Component = this
    if (vm._isBeingDestroyed) {
      return
    }
    callHook(vm, 'beforeDestroy')
    vm._isBeingDestroyed = true
    // remove self from parent
    const parent = vm.$parent
    if (parent && !parent._isBeingDestroyed && !vm.$options.abstract) {
      remove(parent.$children, vm)
    }
    // teardown watchers
    if (vm._watcher) {
      vm._watcher.teardown()
    }
    let i = vm._watchers.length
    while (i--) {
      vm._watchers[i].teardown()
    }
    // remove reference from data ob
    // frozen object may not have observer.
    if (vm._data.__ob__) {
      vm._data.__ob__.vmCount--
    }
    // call the last hook...
    vm._isDestroyed = true
    // invoke destroy hooks on current rendered tree
    vm.__patch__(vm._vnode, null)
    // fire destroyed hook
    callHook(vm, 'destroyed')
    // turn off all instance listeners.
    vm.$off()
    // remove __vue__ reference
    if (vm.$el) {
      vm.$el.__vue__ = null
    }
    // release circular reference (#6759)
    if (vm.$vnode) {
      vm.$vnode.parent = null
    }
  }
  • beforeDestroy 鉤子函數的執行時機是在 $destroy 函數執行最開始的地方,接着執行了一系列的銷燬動作,包括從 parent$children 中刪掉自身,刪除 watcher,當前渲染的 VNode 執行銷燬鉤子函數等,執行完畢後再調用 destroy 鉤子函數。

  • $destroy 的執行過程中,它又會執行 vm.__patch__(vm._vnode, null) 觸發它子組件的銷燬鉤子函數,這樣一層層的遞歸調用,所以 destroy 鉤子函數執行順序是先子後父,和 mounted 過程一樣。

  1. activated & deactivatedactivateddeactivated 鉤子函數是專門爲 keep-alive 組件定製的鉤子,在介紹 keep-alive 組件的時候會詳細介紹。

  2. 總結:Vue 生命週期中各個鉤子函數的執行時機以及順序,通過分析,我們知道了如在 created 鉤子函數中可以訪問到數據,在 mounted 鉤子函數中可以訪問到 DOM,在 destroy 鉤子函數中可以做一些定時器銷燬工作,瞭解它們有利於我們在合適的生命週期去做不同的事情。

二、組件註冊

  1. Vue.js 中,除了它內置的組件如 keep-alivecomponenttransitiontransition-group 等,其它用戶自定義組件在使用前必須註冊,在開發過程中可能會遇到如下報錯信息:
'Unknown custom element: <xxx> - did you register the component correctly?
 For recursive components, make sure to provide the "name" option.'

一般報這個錯的原因都是我們使用了未註冊的組件。Vue.js 提供了兩種組件的註冊方式,全局註冊和局部註冊。接下來我們從源碼分析的角度來分析這兩種註冊方式。

  1. 全局註冊,要註冊一個全局組件,可以使用 Vue.component(tagName, options)。例如:
Vue.component('my-component', {
  // 選項
})
  1. 那麼,Vue.component 函數的定義過程發生是在最開始初始化 Vue 的全局函數的時候,代碼在 src/core/global-api/assets.js 中,如下所示:
import { ASSET_TYPES } from 'shared/constants'
import { isPlainObject, validateComponentName } from '../util/index'

export function initAssetRegisters (Vue: GlobalAPI) {
  /**
   * Create asset registration methods.
   */
  ASSET_TYPES.forEach(type => {
    Vue[type] = function (
      id: string,
      definition: Function | Object
    ): Function | Object | void {
      if (!definition) {
        return this.options[type + 's'][id]
      } else {
        /* istanbul ignore if */
        if (process.env.NODE_ENV !== 'production' && type === 'component') {
          validateComponentName(id)
        }
        if (type === 'component' && isPlainObject(definition)) {
          definition.name = definition.name || id
          definition = this.options._base.extend(definition)
        }
        if (type === 'directive' && typeof definition === 'function') {
          definition = { bind: definition, update: definition }
        }
        this.options[type + 's'][id] = definition
        return definition
      }
    }
  })
}
  1. 函數首先遍歷 ASSET_TYPES,得到 type 後掛載到 Vue 上,ASSET_TYPES 的定義在 src/shared/constants.js 中:
export const ASSET_TYPES = [
  'component',
  'directive',
  'filter'
]
  1. 所以實際上 Vue 是初始化了三個全局函數,並且如果 typecomponentdefinition 是一個對象的話,通過 this.opitons._base.extend, 相當於 Vue.extend 把這個對象轉換成一個繼承於 Vue 的構造函數,最後通過 this.options[type + 's'][id] = definition 把它掛載到 Vue.options.components 上。

  2. 由於我們每個組件的創建都是通過 Vue.extend 繼承而來,我們之前分析過在繼承的過程中有這麼一段邏輯:

Sub.options = mergeOptions(
  Super.options,
  extendOptions
)
  1. 這也就是說它會把 Vue.options 合併到 Sub.options,也就是組件的 options 上, 然後在組件的實例化階段,會執行 merge options 邏輯,把 Sub.options.components 合併到 vm.$options.components 上。然後在創建 vnode 的過程中,會執行 _createElement 方法,我們再來回顧一下這部分的邏輯,它的定義在 src/core/vdom/create-element.js 中:
export function _createElement (
  context: Component,
  tag?: string | Class<Component> | Function | Object,
  data?: VNodeData,
  children?: any,
  normalizationType?: number
): VNode | Array<VNode> {
  // ...
  let vnode, ns
  if (typeof tag === 'string') {
    let Ctor
    ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
    if (config.isReservedTag(tag)) {
      // platform built-in elements
      vnode = new VNode(
        config.parsePlatformTagName(tag), data, children,
        undefined, undefined, context
      )
    } else if (isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
      // component
      vnode = createComponent(Ctor, data, context, children, tag)
    } else {
      // unknown or unlisted namespaced elements
      // check at runtime because it may get assigned a namespace when its
      // parent normalizes children
      vnode = new VNode(
        tag, data, children,
        undefined, undefined, context
      )
    }
  } else {
    // direct component options / constructor
    vnode = createComponent(tag, data, context, children)
  }
  // ...
}

這裏有一個判斷邏輯 isDef(Ctor = resolveAsset(context.$options, 'components', tag)),先來看一下 resolveAsset 的定義,在 src/core/utils/options.js 中:

/**
 * Resolve an asset.
 * This function is used because child instances need access
 * to assets defined in its ancestor chain.
 */
export function resolveAsset (
  options: Object,
  type: string,
  id: string,
  warnMissing?: boolean
): any {
  /* istanbul ignore if */
  if (typeof id !== 'string') {
    return
  }
  const assets = options[type]
  // check local registration variations first
  if (hasOwn(assets, id)) return assets[id]
  const camelizedId = camelize(id)
  if (hasOwn(assets, camelizedId)) return assets[camelizedId]
  const PascalCaseId = capitalize(camelizedId)
  if (hasOwn(assets, PascalCaseId)) return assets[PascalCaseId]
  // fallback to prototype chain
  const res = assets[id] || assets[camelizedId] || assets[PascalCaseId]
  if (process.env.NODE_ENV !== 'production' && warnMissing && !res) {
    warn(
      'Failed to resolve ' + type.slice(0, -1) + ': ' + id,
      options
    )
  }
  return res
}
  1. 這段邏輯很簡單,先通過 const assets = options[type] 拿到 assets,然後再嘗試拿 assets[id],這裏有個順序,先直接使用 id 拿,如果不存在,則把 id 變成駝峯的形式再拿,如果仍然不存在則在駝峯的基礎上把首字母再變成大寫的形式再拿,如果仍然拿不到則報錯。這樣說明了我們在使用 Vue.component(id, definition) 全局註冊組件的時候,id 可以是連字符、駝峯或首字母大寫的形式。

  2. 那麼回到我們的調用 resolveAsset(context.$options, 'components', tag),即拿 vm.$options.components[tag],這樣我們就可以在 resolveAsset 的時候拿到這個組件的構造函數,並作爲 createComponent 的鉤子的參數。

  3. 局部註冊,Vue.js 也同樣支持局部註冊,我們可以在一個組件內部使用 components 選項做組件的局部註冊,例如:

import HelloWorld from './components/HelloWorld'

export default {
  components: {
    HelloWorld
  }
}
  1. 其實理解了全局註冊的過程,局部註冊是非常簡單的。在組件的 Vue 的實例化階段有一個合併 option 的邏輯,之前我們也分析過,所以就把 components 合併到 vm.$options.components 上,這樣我們就可以在 resolveAsset 的時候拿到這個組件的構造函數,並作爲 createComponent 的鉤子的參數。

  2. 注意,局部註冊和全局註冊不同的是,只有該類型的組件纔可以訪問局部註冊的子組件,而全局註冊是擴展到 Vue.options 下,所以在所有組件創建的過程中,都會從全局的 Vue.options.components 擴展到當前組件的 vm.$options.components 下,這就是全局註冊的組件能被任意使用的原因。

  3. 總結:我們對組件的註冊過程有了認識,並理解了全局註冊和局部註冊的差異。其實在平時的工作中,當我們使用到組件庫的時候,往往更通用基礎組件都是全局註冊的,而編寫的特例場景的業務組件都是局部註冊的。瞭解了它們的原理,對我們在工作中到底使用全局註冊組件還是局部註冊組件是有這非常好的指導意義的。

三、異步組件

  1. 在我們平時的開發工作中,爲了減少首屏代碼體積,往往會把一些非首屏的組件設計成異步組件,按需加載。Vue 也原生支持了異步組件的能力,如下所示:
Vue.component('async-example', function (resolve, reject) {
   // 這個特殊的 require 語法告訴 webpack
   // 自動將編譯後的代碼分割成不同的塊,
   // 這些塊將通過 Ajax 請求自動下載。
   require(['./my-async-component'], resolve)
})
  1. 在示例中可以看到,Vue 註冊的組件不再是一個對象,而是一個工廠函數,函數有兩個參數 resolvereject,函數內部用 setTimout 模擬了異步,實際使用可能是通過動態請求異步組件的 JS 地址,最終通過執行 resolve 方法,它的參數就是我們的異步組件對象。

  2. 在瞭解了異步組件如何註冊後,我們從源碼的角度來分析一下它的實現。之前我們分析了組件的註冊邏輯,由於組件的定義並不是一個普通對象,所以不會執行 Vue.extend 的邏輯把它變成一個組件的構造函數,但是它仍然可以執行到 createComponent 函數,我們再來對這個函數做回顧,它的定義在 src/core/vdom/create-component/js 中:

export function createComponent (
  Ctor: Class<Component> | Function | Object | void,
  data: ?VNodeData,
  context: Component,
  children: ?Array<VNode>,
  tag?: string
): VNode | Array<VNode> | void {
  if (isUndef(Ctor)) {
    return
  }

  const baseCtor = context.$options._base

  // plain options object: turn it into a constructor
  if (isObject(Ctor)) {
    Ctor = baseCtor.extend(Ctor)
  }
  
  // ...

  // async component
  let asyncFactory
  if (isUndef(Ctor.cid)) {
    asyncFactory = Ctor
    Ctor = resolveAsyncComponent(asyncFactory, baseCtor, context)
    if (Ctor === undefined) {
      // return a placeholder node for async component, which is rendered
      // as a comment node but preserves all the raw information for the node.
      // the information will be used for async server-rendering and hydration.
      return createAsyncPlaceholder(
        asyncFactory,
        data,
        context,
        children,
        tag
      )
    }
  }
}
  1. 由於我們這個時候傳入的 Ctor 是一個函數,那麼它也並不會執行 Vue.extend 邏輯,因此它的 cidundefiend,進入了異步組件創建的邏輯。這裏首先執行了 Ctor = resolveAsyncComponent(asyncFactory, baseCtor, context) 方法,它的定義在 src/core/vdom/helpers/resolve-async-component.js 中:
export function resolveAsyncComponent (
  factory: Function,
  baseCtor: Class<Component>,
  context: Component
): Class<Component> | void {
  if (isTrue(factory.error) && isDef(factory.errorComp)) {
    return factory.errorComp
  }

  if (isDef(factory.resolved)) {
    return factory.resolved
  }

  if (isTrue(factory.loading) && isDef(factory.loadingComp)) {
    return factory.loadingComp
  }

  if (isDef(factory.contexts)) {
    // already pending
    factory.contexts.push(context)
  } else {
    const contexts = factory.contexts = [context]
    let sync = true

    const forceRender = () => {
      for (let i = 0, l = contexts.length; i < l; i++) {
        contexts[i].$forceUpdate()
      }
    }

    const resolve = once((res: Object | Class<Component>) => {
      // cache resolved
      factory.resolved = ensureCtor(res, baseCtor)
      // invoke callbacks only if this is not a synchronous resolve
      // (async resolves are shimmed as synchronous during SSR)
      if (!sync) {
        forceRender()
      }
    })

    const reject = once(reason => {
      process.env.NODE_ENV !== 'production' && warn(
        `Failed to resolve async component: ${String(factory)}` +
        (reason ? `\nReason: ${reason}` : '')
      )
      if (isDef(factory.errorComp)) {
        factory.error = true
        forceRender()
      }
    })

    const res = factory(resolve, reject)

    if (isObject(res)) {
      if (typeof res.then === 'function') {
        // () => Promise
        if (isUndef(factory.resolved)) {
          res.then(resolve, reject)
        }
      } else if (isDef(res.component) && typeof res.component.then === 'function') {
        res.component.then(resolve, reject)

        if (isDef(res.error)) {
          factory.errorComp = ensureCtor(res.error, baseCtor)
        }

        if (isDef(res.loading)) {
          factory.loadingComp = ensureCtor(res.loading, baseCtor)
          if (res.delay === 0) {
            factory.loading = true
          } else {
            setTimeout(() => {
              if (isUndef(factory.resolved) && isUndef(factory.error)) {
                factory.loading = true
                forceRender()
              }
            }, res.delay || 200)
          }
        }

        if (isDef(res.timeout)) {
          setTimeout(() => {
            if (isUndef(factory.resolved)) {
              reject(
                process.env.NODE_ENV !== 'production'
                  ? `timeout (${res.timeout}ms)`
                  : null
              )
            }
          }, res.timeout)
        }
      }
    }

    sync = false
    // return in case resolved synchronously
    return factory.loading
      ? factory.loadingComp
      : factory.resolved
  }
}
  1. resolveAsyncComponent 函數的邏輯略複雜,因爲它實際上處理了三種異步組件的創建方式,除了剛纔示例的組件註冊方式,還支持兩種,一種是支持 Promise 創建組件的方式,如下所示:
Vue.component(
  'async-webpack-example',
  // 該 `import` 函數返回一個 `Promise` 對象。
  () => import('./my-async-component')
)

另一種是高級異步組件,如下所示:

const AsyncComp = () => ({
  // 需要加載的組件。應當是一個 Promise
  component: import('./MyComp.vue'),
  // 加載中應當渲染的組件
  loading: LoadingComp,
  // 出錯時渲染的組件
  error: ErrorComp,
  // 渲染加載中組件前的等待時間。默認:200ms。
  delay: 200,
  // 最長等待時間。超出此時間則渲染錯誤組件。默認:Infinity
  timeout: 3000
})
Vue.component('async-example', AsyncComp)

那麼我們就根據這三種異步組件的情況,來分別去分析 resolveAsyncComponent 的邏輯。

  1. 普通函數異步組件,針對普通函數的情況,前面幾個 if 判斷可以忽略,它們是爲高級組件所用,對於 factory.contexts 的判斷,是考慮到多個地方同時初始化一個異步組件,那麼它的實際加載應該只有一次。接着進入實際加載邏輯,定義了 forceRenderresolvereject 函數,注意 resolvereject 函數用 once 函數做了一層包裝,它的定義在 src/shared/util.js 中:
/**
 * Ensure a function is called only once.
 */
export function once (fn: Function): Function {
  let called = false
  return function () {
    if (!called) {
      called = true
      fn.apply(this, arguments)
    }
  }
}

once 邏輯非常簡單,傳入一個函數,並返回一個新函數,它非常巧妙地利用閉包和一個標誌位保證了它包裝的函數只會執行一次,也就是確保 resolvereject 函數只執行一次。

  1. 接下來執行 const res = factory(resolve, reject) 邏輯,這塊兒就是執行我們組件的工廠函數,同時把 resolvereject 函數作爲參數傳入,組件的工廠函數通常會先發送請求去加載我們的異步組件的 JS 文件,拿到組件定義的對象 res 後,執行 resolve(res) 邏輯,它會先執行 factory.resolved = ensureCtor(res, baseCtor)
function ensureCtor (comp: any, base) {
  if (
    comp.__esModule ||
    (hasSymbol && comp[Symbol.toStringTag] === 'Module')
  ) {
    comp = comp.default
  }
  return isObject(comp)
    ? base.extend(comp)
    : comp
}

這個函數目的是爲了保證能找到異步組件 JS 定義的組件對象,並且如果它是一個普通對象,則調用 Vue.extend 把它轉換成一個組件的構造函數。

  1. resolve 邏輯最後判斷了 sync,顯然我們這個場景下 syncfalse,那麼就會執行 forceRender 函數,它會遍歷 factory.contexts,拿到每一個調用異步組件的實例 vm, 執行 vm.$forceUpdate() 方法,它的定義在 src/core/instance/lifecycle.js 中:
Vue.prototype.$forceUpdate = function () {
  const vm: Component = this
  if (vm._watcher) {
    vm._watcher.update()
  }
}
  1. $forceUpdate 的邏輯非常簡單,就是調用渲染 watcherupdate 方法,讓渲染 watcher 對應的回調函數執行,也就是觸發了組件的重新渲染。之所以這麼做是因爲 Vue 通常是數據驅動視圖重新渲染,但是在整個異步組件加載過程中是沒有數據發生變化的,所以通過執行 $forceUpdate 可以強制組件重新渲染一次。

  2. Promise 異步組件,如下所示:

Vue.component(
  'async-webpack-example',
  // 該 `import` 函數返回一個 `Promise` 對象。
  () => import('./my-async-component')
)
  1. webpack 2+ 支持了異步加載的語法糖:() => import('./my-async-component'),當執行完 res = factory(resolve, reject),返回的值就是 import('./my-async-component') 的返回值,它是一個 Promise 對象。接着進入 if 條件,又判斷了 typeof res.then === 'function'),條件滿足,執行:
if (isUndef(factory.resolved)) {
  res.then(resolve, reject)
}

當組件異步加載成功後,執行 resolve,加載失敗則執行 reject,這樣就非常巧妙地實現了配合 webpack 2+ 的異步加載組件的方式(Promise)加載異步組件。

  1. 高級異步組件,由於異步加載組件需要動態加載 JS,有一定網絡延時,而且有加載失敗的情況,所以通常我們在開發異步組件相關邏輯的時候需要設計 loading 組件和 error 組件,並在適當的時機渲染它們。Vue.js 2.3+ 支持了一種高級異步組件的方式,它通過一個簡單的對象配置,幫你搞定 loading 組件和 error 組件的渲染時機,你完全不用關心細節,非常方便。接下來我們就從源碼的角度來分析高級異步組件是怎麼實現的,如下所示:
const AsyncComp = () => ({
  // 需要加載的組件。應當是一個 Promise
  component: import('./MyComp.vue'),
  // 加載中應當渲染的組件
  loading: LoadingComp,
  // 出錯時渲染的組件
  error: ErrorComp,
  // 渲染加載中組件前的等待時間。默認:200ms。
  delay: 200,
  // 最長等待時間。超出此時間則渲染錯誤組件。默認:Infinity
  timeout: 3000
})
Vue.component('async-example', AsyncComp)
  1. 高級異步組件的初始化邏輯和普通異步組件一樣,也是執行 resolveAsyncComponent,當執行完 res = factory(resolve, reject),返回值就是定義的組件對象,顯然滿足 else if (isDef(res.component) && typeof res.component.then === 'function') 的邏輯,接着執行 res.component.then(resolve, reject),當異步組件加載成功後,執行 resolve,失敗執行 reject

  2. 因爲異步組件加載是一個異步過程,它接着又同步執行了如下邏輯,如下所示:

if (isDef(res.error)) {
  factory.errorComp = ensureCtor(res.error, baseCtor)
}

if (isDef(res.loading)) {
  factory.loadingComp = ensureCtor(res.loading, baseCtor)
  if (res.delay === 0) {
    factory.loading = true
  } else {
    setTimeout(() => {
      if (isUndef(factory.resolved) && isUndef(factory.error)) {
        factory.loading = true
        forceRender()
      }
    }, res.delay || 200)
  }
}

if (isDef(res.timeout)) {
  setTimeout(() => {
    if (isUndef(factory.resolved)) {
      reject(
        process.env.NODE_ENV !== 'production'
          ? `timeout (${res.timeout}ms)`
          : null
      )
    }
  }, res.timeout)
}

先判斷 res.error 是否定義了 error 組件,如果有的話則賦值給 factory.errorComp
接着判斷 res.loading 是否定義了 loading 組件,如果有的話則賦值給 factory.loadingComp,如果設置了 res.delay 且爲 0,則設置 factory.loading = true,否則延時 delay 的時間執行,如下所示:

if (isUndef(factory.resolved) && isUndef(factory.error)) {
    factory.loading = true
    forceRender()
}

最後判斷 res.timeout,如果配置了該項,則在 res.timout 時間後,如果組件沒有成功加載,執行 reject

  1. resolveAsyncComponent 的最後有一段邏輯,如下所示:
sync = false
return factory.loading
  ? factory.loadingComp
  : factory.resolved
  1. 如果 delay 配置爲 0,則這次直接渲染 loading 組件,否則則延時 delay 執行 forceRender,那麼又會再一次執行到 resolveAsyncComponent。那麼這時候我們有幾種情況,按邏輯的執行順序,對不同的情況做判斷。

  2. 異步組件加載失敗,當異步組件加載失敗,會執行 reject 函數:

const reject = once(reason => {
  process.env.NODE_ENV !== 'production' && warn(
    `Failed to resolve async component: ${String(factory)}` +
    (reason ? `\nReason: ${reason}` : '')
  )
  if (isDef(factory.errorComp)) {
    factory.error = true
    forceRender()
  }
})

這個時候會把 factory.error 設置爲 true,同時執行 forceRender() 再次執行到 resolveAsyncComponent

if (isTrue(factory.error) && isDef(factory.errorComp)) {
  return factory.errorComp
}

那麼這個時候就返回 factory.errorComp,直接渲染 error 組件。

  1. 異步組件加載成功,當異步組件加載成功,會執行 resolve 函數:
const resolve = once((res: Object | Class<Component>) => {
  factory.resolved = ensureCtor(res, baseCtor)
  if (!sync) {
    forceRender()
  }
})

首先把加載結果緩存到 factory.resolved 中,這個時候因爲 sync 已經爲 false,則執行 forceRender() 再次執行到 resolveAsyncComponent

if (isDef(factory.resolved)) {
  return factory.resolved
}

那麼這個時候直接返回 factory.resolved,渲染成功加載的組件。

  1. 異步組件加載中,如果異步組件加載中並未返回,這時候會走到這個邏輯:
if (isTrue(factory.loading) && isDef(factory.loadingComp)) {
  return factory.loadingComp
}

那麼則會返回 factory.loadingComp,渲染 loading 組件。

  1. 異步組件加載超時,如果超時,則走到了 reject 邏輯,之後邏輯和加載失敗一樣,渲染 error 組件。

  2. 異步組件 patch,回到 createComponent 的邏輯:

Ctor = resolveAsyncComponent(asyncFactory, baseCtor, context)
if (Ctor === undefined) {
  return createAsyncPlaceholder(
    asyncFactory,
    data,
    context,
    children,
    tag
  )
}
  1. 如果是第一次執行 resolveAsyncComponent,除非使用高級異步組件 0 delay 去創建了一個 loading 組件,否則返回是 undefiend,接着通過 createAsyncPlaceholder 創建一個註釋節點作爲佔位符。它的定義在 src/core/vdom/helpers/resolve-async-components.js 中:
export function createAsyncPlaceholder (
  factory: Function,
  data: ?VNodeData,
  context: Component,
  children: ?Array<VNode>,
  tag: ?string
): VNode {
  const node = createEmptyVNode()
  node.asyncFactory = factory
  node.asyncMeta = { data, context, children, tag }
  return node
}

實際上就是就是創建了一個佔位的註釋 VNode,同時把 asyncFactoryasyncMeta 賦值給當前 vnode

  1. 當執行 forceRender 的時候,會觸發組件的重新渲染,那麼會再一次執行 resolveAsyncComponent,這時候就會根據不同的情況,可能返回 loading、error 或成功加載的異步組件,返回值不爲 undefined,因此就走正常的組件 renderpatch 過程,與組件第一次渲染流程不一樣,這個時候是存在新舊 vnode 的,後面會分析組件更新的 patch 過程。

  2. 總結:我們對 Vue 的異步組件的實現有了深入的瞭解,知道了三種異步組件的實現方式,並且看到高級異步組件的實現是非常巧妙的,它實現了 loading、resolve、reject、timeout 四種狀態。異步組件實現的本質是兩次渲染,除了 0 delay 的高級異步組件第一次直接渲染成 loading 組件外,其它都是第一次渲染生成一個註釋節點,當異步獲取組件成功後,再通過 forceRender 強制重新渲染,這樣就能正確渲染出我們異步加載的組件了。

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