Vue 源碼解讀(5)—— 全局 API

目標

深入理解以下全局 API 的實現原理。

  • Vue.use

  • Vue.mixin

  • Vue.component

  • Vue.filter

  • Vue.directive

  • Vue.extend

  • Vue.set

  • Vue.delete

  • Vue.nextTick

源碼解讀

從該系列的第一篇文章 Vue 源碼解讀(1)—— 前言 中的 源碼目錄結構 介紹中可以得知,Vue 的眾多全局 API 的實現大部分都放在 /src/core/global-api 目錄下。這些全局 API 源碼閱讀入口則是在 /src/core/global-api/index.js 文件中。

入口

/src/core/global-api/index.js

/**
 * 初始化 Vue 的眾多全局 API,比如:
 *   默認配置:Vue.config
 *   工具方法:Vue.util.xx
 *   Vue.set、Vue.delete、Vue.nextTick、Vue.observable
 *   Vue.options.components、Vue.options.directives、Vue.options.filters、Vue.options._base
 *   Vue.use、Vue.extend、Vue.mixin、Vue.component、Vue.directive、Vue.filter
 *   
 */
export function initGlobalAPI (Vue: GlobalAPI) {
  // config
  const configDef = {}
  // Vue 的眾多默認配置項
  configDef.get = () => config

  if (process.env.NODE_ENV !== 'production') {
    configDef.set = () => {
      warn(
        'Do not replace the Vue.config object, set individual fields instead.'
      )
    }
  }

  // Vue.config
  Object.defineProperty(Vue, 'config', configDef)

  /**
   * 暴露一些工具方法,輕易不要使用這些工具方法,處理你很清楚這些工具方法,以及知道使用的風險
   */
  Vue.util = {
    // 警告日誌
    warn,
    // 類似選項合併
    extend,
    // 合併選項
    mergeOptions,
    // 設置響應式
    defineReactive
  }

  // Vue.set / delete / nextTick
  Vue.set = set
  Vue.delete = del
  Vue.nextTick = nextTick

  // 響應式方法
  Vue.observable = <T>(obj: T): T => {
    observe(obj)
    return obj
  }

  // Vue.options.compoents/directives/filter
  Vue.options = Object.create(null)
  ASSET_TYPES.forEach(type => {
    Vue.options[type + 's'] = Object.create(null)
  })

  // 將 Vue 構造函數掛載到 Vue.options._base 上
  Vue.options._base = Vue

  // 在 Vue.options.components 中添加內置組件,比如 keep-alive
  extend(Vue.options.components, builtInComponents)

  // Vue.use
  initUse(Vue)
  // Vue.mixin
  initMixin(Vue)
  // Vue.extend
  initExtend(Vue)
  // Vue.component/directive/filter
  initAssetRegisters(Vue)
}

Vue.use

/src/core/global-api/use.js

/**
 * 定義 Vue.use,負責為 Vue 安裝插件,做了以下兩件事:
 *   1、判斷插件是否已經被安裝,如果安裝則直接結束
 *   2、安裝插件,執行插件的 install 方法
 * @param {*} plugin install 方法 或者 包含 install 方法的對象
 * @returns Vue 實例
 */
Vue.use = function (plugin: Function | Object) {
  // 已經安裝過的插件列表
  const installedPlugins = (this._installedPlugins || (this._installedPlugins = []))
  // 判斷 plugin 是否已經安裝,保證不重複安裝
  if (installedPlugins.indexOf(plugin) > -1) {
    return this
  }

  // 將 Vue 構造函數放到第一個參數位置,然後將這些參數傳遞給 install 方法
  const args = toArray(arguments, 1)
  args.unshift(this)

  if (typeof plugin.install === 'function') {
    // plugin 是一個對象,則執行其 install 方法安裝插件
    plugin.install.apply(plugin, args)
  } else if (typeof plugin === 'function') {
    // 執行直接 plugin 方法安裝插件
    plugin.apply(null, args)
  }
  // 在 插件列表中 添加新安裝的插件
  installedPlugins.push(plugin)
  return this
}

Vue.mixin

/src/core/global-api/mixin.js

/**
 * 定義 Vue.mixin,負責全局混入選項,影響之後所有創建的 Vue 實例,這些實例會合併全局混入的選項
 * @param {*} mixin Vue 配置對象
 * @returns 返回 Vue 實例
 */
Vue.mixin = function (mixin: Object) {
  // 在 Vue 的默認配置項上合併 mixin 對象
  this.options = mergeOptions(this.options, mixin)
  return this
}

mergeOptions

src/core/util/options.js

/**
 * 合併兩個選項,出現相同配置項時,子選項會覆蓋父選項的配置
 */
export function mergeOptions (
  parent: Object,
  child: Object,
  vm?: Component
): Object {
  if (process.env.NODE_ENV !== 'production') {
    checkComponents(child)
  }

  if (typeof child === 'function') {
    child = child.options
  }

  // 標準化 props、inject、directive 選項,方便後續程式的處理
  normalizeProps(child, vm)
  normalizeInject(child, vm)
  normalizeDirectives(child)

  // 處理原始 child 對象上的 extends 和 mixins,分別執行 mergeOptions,將這些繼承而來的選項合併到 parent
  // mergeOptions 處理過的對象會含有 _base 屬性
  if (!child._base) {
    if (child.extends) {
      parent = mergeOptions(parent, child.extends, vm)
    }
    if (child.mixins) {
      for (let i = 0, l = child.mixins.length; i < l; i++) {
        parent = mergeOptions(parent, child.mixins[i], vm)
      }
    }
  }

  const options = {}
  let key
  // 遍歷 父選項
  for (key in parent) {
    mergeField(key)
  }

  // 遍歷 子選項,如果父選項不存在該配置,則合併,否則跳過,因為父子擁有同一個屬性的情況在上面處理父選項時已經處理過了,用的子選項的值
  for (key in child) {
    if (!hasOwn(parent, key)) {
      mergeField(key)
    }
  }

  // 合併選項,childVal 優先順序高於 parentVal
  function mergeField (key) {
    // strat 是合併策略函數,如何 key 衝突,則 childVal 會 覆蓋 parentVal
    const strat = strats[key] || defaultStrat
    // 值為如果 childVal 存在則優先使用 childVal,否則使用 parentVal
    options[key] = strat(parent[key], child[key], vm, key)
  }
  return options
}

Vue.component、Vue.filter、Vue.directive

/src/core/global-api/assets.js

這三個 API 實現比較特殊,但是原理又很相似,所以就放在了一起實現。

const ASSET_TYPES = ['component', 'directive', 'filter']

/**
 * 定義 Vue.component、Vue.filter、Vue.directive 這三個方法
 * 這三個方法所做的事情是類似的,就是在 this.options.xx 上存放對應的配置
 * 比如 Vue.component(compName, {xx}) 結果是 this.options.components.compName = 組件構造函數
 * ASSET_TYPES = ['component', 'directive', 'filter']
 */
ASSET_TYPES.forEach(type => {
  /**
   * 比如:Vue.component(name, definition)
   * @param {*} id name
   * @param {*} definition 組件構造函數或者配置對象 
   * @returns 返回組件構造函數
   */
  Vue[type] = function (
    id: string,
    definition: Function | Object
  ): Function | Object | void {
    if (!definition) {
      return this.options[type + 's'][id]
    } else {
      if (type === 'component' && isPlainObject(definition)) {
        // 如果組件配置中存在 name,則使用,否則直接使用 id
        definition.name = definition.name || id
        // extend 就是 Vue.extend,所以這時的 definition 就變成了 組件構造函數,使用時可直接 new Definition()
        definition = this.options._base.extend(definition)
      }
      if (type === 'directive' && typeof definition === 'function') {
        definition = { bind: definition, update: definition }
      }
      // this.options.components[id] = definition
      // 在實例化時通過 mergeOptions 將全局註冊的組件合併到每個組件的配置對象的 components 中
      this.options[type + 's'][id] = definition
      return definition
    }
  }
})

Vue.extend

/src/core/global-api/extend.js

/**
 * Each instance constructor, including Vue, has a unique
 * cid. This enables us to create wrapped "child
 * constructors" for prototypal inheritance and cache them.
 */
Vue.cid = 0
let cid = 1

/**
 * 基於 Vue 去擴展子類,該子類同樣支援進一步的擴展
 * 擴展時可以傳遞一些默認配置,就像 Vue 也會有一些默認配置
 * 默認配置如果和基類有衝突則會進行選項合併(mergeOptions)
 */
Vue.extend = function (extendOptions: Object): Function {
  extendOptions = extendOptions || {}
  const Super = this
  const SuperId = Super.cid

  /**
   * 利用快取,如果存在則直接返回快取中的構造函數
   * 什麼情況下可以利用到這個快取?
   *   如果你在多次調用 Vue.extend 時使用了同一個配置項(extendOptions),這時就會啟用該快取
   */
  const cachedCtors = extendOptions._Ctor || (extendOptions._Ctor = {})
  if (cachedCtors[SuperId]) {
    return cachedCtors[SuperId]
  }

  const name = extendOptions.name || Super.options.name
  if (process.env.NODE_ENV !== 'production' && name) {
    validateComponentName(name)
  }

  // 定義 Sub 構造函數,和 Vue 構造函數一樣
  const Sub = function VueComponent(options) {
    // 初始化
    this._init(options)
  }
  // 通過原型繼承的方式繼承 Vue
  Sub.prototype = Object.create(Super.prototype)
  Sub.prototype.constructor = Sub
  Sub.cid = cid++
  // 選項合併,合併 Vue 的配置項到 自己的配置項上來
  Sub.options = mergeOptions(
    Super.options,
    extendOptions
  )
  // 記錄自己的基類
  Sub['super'] = Super

  // 初始化 props,將 props 配置代理到 Sub.prototype._props 對象上
  // 在組件內通過 this._props 方式可以訪問
  if (Sub.options.props) {
    initProps(Sub)
  }

  // 初始化 computed,將 computed 配置代理到 Sub.prototype 對象上
  // 在組件內可以通過 this.computedKey 的方式訪問
  if (Sub.options.computed) {
    initComputed(Sub)
  }

  // 定義 extend、mixin、use 這三個靜態方法,允許在 Sub 基礎上再進一步構造子類
  Sub.extend = Super.extend
  Sub.mixin = Super.mixin
  Sub.use = Super.use

  // 定義 component、filter、directive 三個靜態方法
  ASSET_TYPES.forEach(function (type) {
    Sub[type] = Super[type]
  })

  // 遞歸組件的原理,如果組件設置了 name 屬性,則將自己註冊到自己的 components 選項中
  if (name) {
    Sub.options.components[name] = Sub
  }

  // 在擴展時保留對基類選項的引用。
  // 稍後在實例化時,我們可以檢查 Super 的選項是否具有更新
  Sub.superOptions = Super.options
  Sub.extendOptions = extendOptions
  Sub.sealedOptions = extend({}, Sub.options)

  // 快取
  cachedCtors[SuperId] = Sub
  return Sub
}

function initProps (Comp) {
  const props = Comp.options.props
  for (const key in props) {
    proxy(Comp.prototype, `_props`, key)
  }
}

function initComputed (Comp) {
  const computed = Comp.options.computed
  for (const key in computed) {
    defineComputed(Comp.prototype, key, computed[key])
  }
}

Vue.set

/src/core/global-api/index.js

Vue.set = set

set

/src/core/observer/index.js

/**
 * 通過 Vue.set 或者 this.$set 方法給 target 的指定 key 設置值 val
 * 如果 target 是對象,並且 key 原本不存在,則為新 key 設置響應式,然後執行依賴通知
 */
export function set (target: Array<any> | Object, key: any, val: any): any {
  if (process.env.NODE_ENV !== 'production' &&
    (isUndef(target) || isPrimitive(target))
  ) {
    warn(`Cannot set reactive property on undefined, null, or primitive value: ${(target: any)}`)
  }
  // 更新數組指定下標的元素,Vue.set(array, idx, val),通過 splice 方法實現響應式更新
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.length = Math.max(target.length, key)
    target.splice(key, 1, val)
    return val
  }
  // 更新對象已有屬性,Vue.set(obj, key, val),執行更新即可
  if (key in target && !(key in Object.prototype)) {
    target[key] = val
    return val
  }
  const ob = (target: any).__ob__
  // 不能向 Vue 實例或者 $data 添加動態添加響應式屬性,vmCount 的用處之一,
  // this.$data 的 ob.vmCount = 1,表示根組件,其它子組件的 vm.vmCount 都是 0
  if (target._isVue || (ob && ob.vmCount)) {
    process.env.NODE_ENV !== 'production' && warn(
      'Avoid adding reactive properties to a Vue instance or its root $data ' +
      'at runtime - declare it upfront in the data option.'
    )
    return val
  }
  // target 不是響應式對象,新屬性會被設置,但是不會做響應式處理
  if (!ob) {
    target[key] = val
    return val
  }
  // 給對象定義新屬性,通過 defineReactive 方法設置響應式,並觸發依賴更新
  defineReactive(ob.value, key, val)
  ob.dep.notify()
  return val
}

Vue.delete

/src/core/global-api/index.js

Vue.delete = del

del

/src/core/observer/index.js

/**
 * 通過 Vue.delete 或者 vm.$delete 刪除 target 對象的指定 key
 * 數組通過 splice 方法實現,對象則通過 delete 運算符刪除指定 key,並執行依賴通知
 */
export function del (target: Array<any> | Object, key: any) {
  if (process.env.NODE_ENV !== 'production' &&
    (isUndef(target) || isPrimitive(target))
  ) {
    warn(`Cannot delete reactive property on undefined, null, or primitive value: ${(target: any)}`)
  }

  // target 為數組,則通過 splice 方法刪除指定下標的元素
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.splice(key, 1)
    return
  }
  const ob = (target: any).__ob__

  // 避免刪除 Vue 實例的屬性或者 $data 的數據
  if (target._isVue || (ob && ob.vmCount)) {
    process.env.NODE_ENV !== 'production' && warn(
      'Avoid deleting properties on a Vue instance or its root $data ' +
      '- just set it to null.'
    )
    return
  }
  // 如果屬性不存在直接結束
  if (!hasOwn(target, key)) {
    return
  }
  // 通過 delete 運算符刪除對象的屬性
  delete target[key]
  if (!ob) {
    return
  }
  // 執行依賴通知
  ob.dep.notify()
}

Vue.nextTick

/src/core/global-api/index.js

Vue.nextTick = nextTick

nextTick

/src/core/util/next-tick.js

關於 nextTick 方法更加詳細解析,可以查看上一篇文章 Vue 源碼解讀(4)—— 非同步更新

const callbacks = []
/**
 * 完成兩件事:
 *   1、用 try catch 包裝 flushSchedulerQueue 函數,然後將其放入 callbacks 數組
 *   2、如果 pending 為 false,表示現在瀏覽器的任務隊列中沒有 flushCallbacks 函數
 *     如果 pending 為 true,則表示瀏覽器的任務隊列中已經被放入了 flushCallbacks 函數,
 *     待執行 flushCallbacks 函數時,pending 會被再次置為 false,表示下一個 flushCallbacks 函數可以進入
 *     瀏覽器的任務隊列了
 * pending 的作用:保證在同一時刻,瀏覽器的任務隊列中只有一個 flushCallbacks 函數
 * @param {*} cb 接收一個回調函數 => flushSchedulerQueue
 * @param {*} ctx 上下文
 * @returns 
 */
export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  // 用 callbacks 數組存儲經過包裝的 cb 函數
  callbacks.push(() => {
    if (cb) {
      // 用 try catch 包裝回調函數,便於錯誤捕獲
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  if (!pending) {
    pending = true
    // 執行 timerFunc,在瀏覽器的任務隊列中(首選微任務隊列)放入 flushCallbacks 函數
    timerFunc()
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

總結

  • 面試官 問:Vue.use(plugin) 做了什麼?

    負責安裝 plugin 插件,其實就是執行插件提供的 install 方法。

    • 首先判斷該插件是否已經安裝過

    • 如果沒有,則執行插件提供的 install 方法安裝插件,具體做什麼有插件自己決定


  • 面試官 問:Vue.mixin(options) 做了什麼?

    負責在 Vue 的全局配置上合併 options 配置。然後在每個組件生成 vnode 時會將全局配置合併到組件自身的配置上來。

    • 標準化 options 對象上的 props、inject、directive 選項的格式

    • 處理 options 上的 extends 和 mixins,分別將他們合併到全局配置上

    • 然後將 options 配置和全局配置進行合併,選項衝突時 options 配置會覆蓋全局配置


  • 面試官 問:Vue.component(compName, Comp) 做了什麼?

    負責註冊全局組件。其實就是將組件配置註冊到全局配置的 components 選項上(options.components),然後各個子組件在生成 vnode 時會將全局的 components 選項合併到局部的 components 配置項上。

    • 如果第二個參數為空,則表示獲取 compName 的組件構造函數

    • 如果 Comp 是組件配置對象,則使用 Vue.extend 方法得到組件構造函數,否則直接進行下一步

    • 在全局配置上設置組件資訊,this.options.components.compName = CompConstructor


  • 面試官 問:Vue.directive(‘my-directive’, {xx}) 做了什麼?

    在全局註冊 my-directive 指令,然後每個子組件在生成 vnode 時會將全局的 directives 選項合併到局部的 directives 選項中。原理同 Vue.component 方法:

    • 如果第二個參數為空,則獲取指定指令的配置對象

    • 如果不為空,如果第二個參數是一個函數的話,則生成配置對象 { bind: 第二個參數, update: 第二個參數 }

    • 然後將指令配置對象設置到全局配置上,this.options.directives['my-directive'] = {xx}


  • 面試官 問:Vue.filter(‘my-filter’, function(val) {xx}) 做了什麼?

    負責在全局註冊過濾器 my-filter,然後每個子組件在生成 vnode 時會將全局的 filters 選項合併到局部的 filters 選項中。原理是:

    • 如果沒有提供第二個參數,則獲取 my-filter 過濾器的回調函數

    • 如果提供了第二個參數,則是設置 this.options.filters['my-filter'] = function(val) {xx}


  • 面試官 問:Vue.extend(options) 做了什麼?

    Vue.extend 基於 Vue 創建一個子類,參數 options 會作為該子類的默認全局配置,就像 Vue 的默認全局配置一樣。所以通過 Vue.extend 擴展一個子類,一大用處就是內置一些公共配置,供子類的子類使用。

    • 定義子類構造函數,這裡和 Vue 一樣,也是調用 _init(options)

    • 合併 Vue 的配置和 options,如果選項衝突,則 options 的選項會覆蓋 Vue 的配置項

    • 給子類定義全局 API,值為 Vue 的全局 API,比如 Sub.extend = Super.extend,這樣子類同樣可以擴展出其它子類

    • 返回子類 Sub


  • 面試官 問:Vue.set(target, key, val) 做了什麼

    由於 Vue 無法探測普通的新增 property (比如 this.myObject.newProperty = ‘hi’),所以通過 Vue.set 為向響應式對象中添加一個 property,可以確保這個新 property 同樣是響應式的,且觸發視圖更新。

    • 更新數組指定下標的元素:Vue.set(array, idx, val),內部通過 splice 方法實現響應式更新

    • 更新對象已有屬性:Vue.set(obj, key ,val),直接更新即可 => obj[key] = val

    • 不能向 Vue 實例或者 $data 動態添加根級別的響應式數據

    • Vue.set(obj, key, val),如果 obj 不是響應式對象,會執行 obj[key] = val,但是不會做響應式處理

    • Vue.set(obj, key, val),為響應式對象 obj 增加一個新的 key,則通過 defineReactive 方法設置響應式,並觸發依賴更新


  • 面試官 問:Vue.delete(target, key) 做了什麼?

    刪除對象的 property。如果對象是響應式的,確保刪除能觸發更新視圖。這個方法主要用於避開 Vue 不能檢測到 property 被刪除的限制,但是你應該很少會使用它。當然同樣不能刪除根級別的響應式屬性。

    • Vue.delete(array, idx),刪除指定下標的元素,內部是通過 splice 方法實現的

    • 刪除響應式對象上的某個屬性:Vue.delete(obj, key),內部是執行 delete obj.key,然後執行依賴更新即可


  • 面試官 問:Vue.nextTick(cb) 做了什麼?

    Vue.nextTick(cb) 方法的作用是延遲回調函數 cb 的執行,一般用於 this.key = newVal 更改數據後,想立即獲取更改過後的 DOM 數據:

    this.key = 'new val'
    
    Vue.nextTick(function() {
      // DOM 更新了
    })
    

    其內部的執行過程是:

    • this.key = 'new val,觸發依賴通知更新,將負責更新的 watcher 放入 watcher 隊列

    • 將刷新 watcher 隊列的函數放到 callbacks 數組中

    • 在瀏覽器的非同步任務隊列中放入一個刷新 callbacks 數組的函數

    • Vue.nextTick(cb) 來插隊,將 cb 函數放入 callbacks 數組

    • 待將來的某個時刻執行刷新 callbacks 數組的函數

    • 然後執行 callbacks 數組中的眾多函數,觸發 watcher.run 的執行,更新 DOM

    • 由於 cb 函數是在後面放到 callbacks 數組,所以這就保證了先完成的 DOM 更新,再執行 cb 函數

鏈接

感謝各位的:點贊收藏評論,我們下期見。


當學習成為了習慣,知識也就變成了常識。 感謝各位的 點贊收藏評論

新影片和文章會第一時間在微信公眾號發送,歡迎關註:李永寧lyn

文章已收錄到 github 倉庫 liyongning/blog,歡迎 Watch 和 Star。