Vue 原始碼解讀(5)—— 全域性 API

李永寧發表於2022-02-25

目標

深入理解以下全域性 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。

相關文章