深入剖析Vue原始碼 - 選項合併(上)

不做祖國的韭菜發表於2019-03-18

1.1 Vue的引入

Vue的使用按照官方的說法支援CDNNPM兩種方式,CDN的方式是以script的方式將打包好的vue.js引入頁面指令碼中,而NPM的方式是和諸如 webpackBrowserify 模組打包器配置使用,以npm install vue的方式引入,這也是我們開發應用的主要形式。而從單純分析原始碼思路和實現細節的角度來講,打包後的vue.js在分析和提煉原始碼方面會更加方便,所以這個系列的原始碼分析,使用的是打包後的vue指令碼,版本號是v2.6.8

1.1.1 基礎使用

分析的開始當然是vue的基礎使用,我們引入了vue.js並且new了一個Vue例項,並將它掛載到#app上,這是最基礎的用法。

<div id="app"></div>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.8/dist/vue.js"></script>
<script>
var vm = new Vue({
  el: '#app',
  data: {
    message: '選項合併'
  },
})
</script>
複製程式碼

雖然這一節的重點是闡述Vue的選項配置,從選項配置入手也是我們從零開始品讀原始碼最容易開始的思路,但是為了分析的完整性,避免後續出現未知的概念,有必要先大致瞭解一下vue在指令碼引入之後分別做了什麼。

1.1.2 Vue構造器

打包後的原始碼是遵從UMD規範的,它是commonjsamd的整合。而Vue的本質是一個構造器,並且它保證了只能通過new例項的形式去呼叫,而不能直接通過函式的形式使用。

(function (global, factory) {
  // 遵循UMD規範
  typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
  typeof define === 'function' && define.amd ? define(factory) :
  (global = global || self, global.Vue = factory());
}(this, function () { 'use strict';
  ···
  // Vue 建構函式
  function Vue (options) {
    // 保證了無法直接通過Vue()去呼叫,只能通過new的方式去建立例項
    if (!(this instanceof Vue)
    ) {
      warn('Vue is a constructor and should be called with the `new` keyword');
    }
    this._init(options);
  }
  return Vue
})
複製程式碼

1.1.3 定義原型屬性方法

Vue之所以能適應基礎的開發場景,除了經常提到的支援元件化開發,以及完善的響應式系統等外,還有重要的一點是它提供了豐富的api方法,不管是靜態還是原型方法,它們都豐富到足以滿足我們日常基礎的開發需求。所以熟練閱讀vue-api文件並精準使用api方法是邁向熟練開發的前提。接下來我們看看這些方法屬性是在哪裡定義的,注意,該小節會忽略大部分屬性方法具體的實現,這些詳細的細節會貫穿在後續系列的分析中

首先是原型上的屬性方法,在建構函式的定義之後,有這樣五個函式,他們分別針對不同場景定義了Vue原型上的屬性和方法。

  // 定義Vue原型上的init方法(內部方法)
  initMixin(Vue);
  // 定義原型上跟資料相關的屬性方法
  stateMixin(Vue);
  //定義原型上跟事件相關的屬性方法
  eventsMixin(Vue);
  // 定義原型上跟生命週期相關的方法
  lifecycleMixin(Vue);
  // 定義渲染相關的函式
  renderMixin(Vue); 
複製程式碼

我們一個個看,首先initMixin定義了內部在例項化Vue時會執行的初始化程式碼,它是一個內部使用的方法。

function initMixin (Vue) {
  Vue.prototype._init = function (options) {}
}
複製程式碼

stateMixin方法會定義跟資料相關的屬性方法,例如代理資料的訪問,我們可以在例項上通過this.$datathis.$props訪問到data,props的值,並且也定義了使用頻率較高的this.$set,this.$delte等方法。

function stateMixin (Vue) {
    var dataDef = {};
    dataDef.get = function () { return this._data };
    var propsDef = {};
    propsDef.get = function () { return this._props };
    {
      dataDef.set = function () {
        warn(
          'Avoid replacing instance root $data. ' +
          'Use nested data properties instead.',
          this
        );
      };
      propsDef.set = function () {
        warn("$props is readonly.", this);
      };
    }
    // 代理了_data,_props的訪問
    Object.defineProperty(Vue.prototype, '$data', dataDef);
    Object.defineProperty(Vue.prototype, '$props', propsDef);
    // $set, $del
    Vue.prototype.$set = set;
    Vue.prototype.$delete = del;

    // $watch
    Vue.prototype.$watch = function (expOrFn,cb,options) {};
  }
複製程式碼

eventsMixin會對原型上的事件相關方法做定義,文件中提到的vm.$on,vm.$once,vm.$off,vm.$emit也就是在這裡定義的。

function eventsMixin(Vue) {
  // 自定義事件監聽
  Vue.prototype.$on = function (event, fn) {};
  // 自定義事件監聽,只觸發一次
  Vue.prototype.$once = function (event, fn) {}
  // 自定義事件解綁
  Vue.prototype.$off = function (event, fn) {}
  // 自定義事件通知
  Vue.prototype.$emit = function (event, fn) {
}
複製程式碼

lifecycleMixin,renderMixin兩個都可以算是對生命週期渲染方法的定義,例如$forceUpdate觸發例項的強制重新整理,$nextTick將回撥延遲到下次 DOM 更新迴圈之後執行等。

// 定義跟生命週期相關的方法
  function lifecycleMixin (Vue) {
    Vue.prototype._update = function (vnode, hydrating) {};

    Vue.prototype.$forceUpdate = function () {};

    Vue.prototype.$destroy = function () {}
  }

// 定義原型上跟渲染相關的方法
  function renderMixin (Vue) {
    Vue.prototype.$nextTick = function (fn) {};
    // _render函式,後面會著重講
    Vue.prototype._render = function () {};
  }
複製程式碼

1.1.4 定義靜態屬性方法

除了原型方法外,Vue還提供了豐富的全域性api方法,這些都是在initGlobalAPI中定義的。

/* 初始化構造器的api */
function initGlobalAPI (Vue) {
    // config
    var configDef = {};
    configDef.get = function () { return config; };
    {
      configDef.set = function () {
        warn(
          'Do not replace the Vue.config object, set individual fields instead.'
        );
      };
    }
    // 通過Vue.config拿到配置資訊
    Object.defineProperty(Vue, 'config', configDef);

    // 工具類不作為公共暴露的API使用
    Vue.util = {
      warn: warn,
      extend: extend,
      mergeOptions: mergeOptions,
      defineReactive: defineReactive###1
    };

    // Vue.set = Vue.prototype.$set
    Vue.set = set;
    // Vue.delete = Vue.prototype.$delete
    Vue.delete = del;
    // Vue.nextTick = Vue.prototype.$nextTick
    Vue.nextTick = nextTick;

    // 2.6 explicit observable API
    Vue.observable = function (obj) {
      observe(obj);
      return obj
    };

    // 建構函式的預設選項預設為components,directive,filter, _base
    Vue.options = Object.create(null);
    ASSET_TYPES.forEach(function (type) {
      Vue.options[type + 's'] = Object.create(null);
    });

    // options裡的_base屬性儲存Vue構造器
    Vue.options._base = Vue;
    extend(Vue.options.components, builtInComponents);
    // Vue.use()
    initUse(Vue);
    // Vue.mixin()
    initMixin$1(Vue);
    // 定義extend擴充套件子類構造器的方法
    // Vue.extend()
    initExtend(Vue);
    // Vue.components, Vue.directive, Vue.filter
    initAssetRegisters(Vue);
  }

複製程式碼

看著原始碼對靜態方法的定義做一個彙總。

  1. 為原始碼裡的config配置做一層代理,可以通過Vue.config拿到預設的配置,並且可以修改它的屬性值,具體哪些可以配置修改,可以先參照官方文件。
  2. 定義內部使用的工具方法,例如警告提示,物件合併等。
  3. 定義set,delet,nextTick方法,本質上原型上也有這些方法的定義。
  4. Vue.components,Vue.directive,Vue.filter的定義,這些是預設的資源選項,後續會重點分析。
  5. 定義Vue.use()方法
  6. 定義Vue.mixin()方法
  7. 定義Vue.extend()方法

現在我相信你已經對引入Vue的階段有了一個大致的認識,在原始碼分析的初期階段,我們不需要死磕每個方法,思路的實現細節,只需要對大致的結構有基本的認識。有了這些基礎,我們開始進入這個章節的主線。

1.2 構造器的預設選項

我們回到最開始的例子,在例項化Vue時,我們會將選項物件傳遞給構造器進行初始化,這個選項物件描述了你想要的行為,例如以data定義例項中的響應式資料,以computed描述例項中的計算屬性,以components來進行元件註冊,甚至是定義各個階段執行的生命週期鉤子等。然而Vue內部本身會自帶一些預設的選項,這些選項和使用者自定義的選項會在後續一起參與到Vue例項的初始化中。

initGlobalAPI方法中有幾行預設選項的定義。Vue內部的預設選項會保留在靜態的options屬性上,從原始碼看Vue自身有四個預設配置選項,分別是component,directive, filter以及返回自身構造器的_base

var ASSET_TYPES = [
  'component',
  'directive',
  'filter'
];
// 原型上建立了一個指向為空物件的options屬性
Vue.options = Object.create(null); 
ASSET_TYPES.forEach(function (type) {
  Vue.options[type + 's'] = Object.create(null);
});
Vue.options._base = Vue;
複製程式碼

很明顯我們開發者對這幾個選項是非常熟悉的,components是需要註冊的元件選項,directives是需要註冊的指令,而filter則代表需要註冊的過濾器。從程式碼的實現細節看,Vuecomponents提供了keepAlive,transition,transitionGroup的內建元件,為directives提供了v-model,v-show的內建指令,而過濾器則沒有預設值。

// Vue內建元件
var builtInComponents = {
  KeepAlive: KeepAlive
};
var platformComponents = {
  Transition: Transition,
  TransitionGroup: TransitionGroup
};
// Vue 內建指令,例如: v-model, v-show
var platformDirectives = {
  model: directive,
  show: show
}
extend(Vue.options.components, builtInComponents); 
extend(Vue.options.components, platformComponents); // 擴充套件內建元件
extend(Vue.options.directives, platformDirectives);  // 擴充套件內建指令
複製程式碼

其中extend方法實現了物件的合併,如果屬性相同,則用新的屬性值覆蓋舊值。

// 將_from物件合併到to物件,屬性相同時,則覆蓋to物件的屬性
function extend (to, _from) {
  for (var key in _from) {
    to[key] = _from[key];
  }
  return to
}
複製程式碼

因此做為構造器而言,Vue預設的資源選項配置如下:

Vue.options = {
  components: {
    KeepAlive: {}
    Transition: {}
    TransitionGroup: {}
  },
  directives: {
    model: {inserted: ƒ, componentUpdated: ƒ}
    show: {bind: ƒ, update: ƒ, unbind: ƒ}
  },
  filters: {}
  _base
}
複製程式碼

1.3 選項檢驗

介紹完Vue自身擁有的選項後,我們回過頭來看看,例項化Vue的階段發生了什麼。從構造器的定義我們很容易發現,例項化Vue做的核心操作便是執行_init方法進行初始化。初始化操作會經過選項合併配置,初始化生命週期,初始化事件中心,乃至構建資料響應式系統等。而關鍵的第一步就是對選項的合併。合併後的選項會掛載到例項的$options屬性中。(你可以先在例項中通過this.$options訪問最終的選項)

function initMixin (Vue) {
  Vue.prototype._init = function (options) {
    var vm = this;
    // a uid
    // 記錄例項化多少個vue物件
    vm._uid = uid$3++;

    // 選項合併,將合併後的選項賦值給例項的$options屬性
    vm.$options = mergeOptions(
      resolveConstructorOptions(vm.constructor), // 返回Vue建構函式自身的配置項
      options || {},
      vm
    );
  };
}
複製程式碼

從程式碼中可以看到,選項合併的重點是將使用者自身傳遞的options選項和Vue建構函式自身的選項配置合併。我們看看mergeOptions函式的實現。

function mergeOptions (parent,child,vm) {
    {
      checkComponents(child);
    }
    if (typeof child === 'function') {
      child = child.options;
    }
    // props,inject,directives的校驗和規範化
    normalizeProps(child, vm);
    normalizeInject(child, vm);
    normalizeDirectives(child);
    
    // 針對extends擴充套件的子類構造器
    if (!child._base) {
      // extends
      if (child.extends) {
        parent = mergeOptions(parent, child.extends, vm);
      }
      // mixins
      if (child.mixins) {
        for (var i = 0, l = child.mixins.length; i < l; i++) {
          parent = mergeOptions(parent, child.mixins[i], vm);
        }
      }
    }

    var options = {};
    var key;
    for (key in parent) {
      mergeField(key);
    }
    for (key in child) {
      if (!hasOwn(parent, key)) {
        mergeField(key);
      }
    }
    function mergeField (key) {
      // 拿到各個選擇指定的選項配置,如果沒有則用預設的配置
      var strat = strats[key] || defaultStrat;
      // 執行各自的合併策略
      options[key] = strat(parent[key], child[key], vm, key);
    }
    // console.log(options)
    return options
  }
複製程式碼

**選項合併過程中更多的不可控在於不知道使用者傳遞了哪些配置選項,這些配置是否符合規範,是否達到合併配置的要求。因此每個選項的書寫規則需要嚴格限定,原則上不允許使用者脫離規則外來傳遞選項。**因此在合併選項之前,很大的一部分工作是對選項的校驗。其中components,prop,inject,directive等都是檢驗的重點。

1.3.1 components規範檢驗

如果專案中需要使用到元件,我們會在vue例項化時傳入元件選項以此來註冊元件。因此,元件命名需要遵守很多規範,比如元件名不能用html保留的標籤(如:img,p),也不能包含非法的字元等。這些都會在validateComponentName函式做校驗。

// components規範檢查函式
function checkComponents (options) {
  // 遍歷components物件,對每個屬性值校驗。
  for (var key in options.components) {
    validateComponentName(key);
  }
}
function validateComponentName (name) {
  if (!new RegExp(("^[a-zA-Z][\\-\\.0-9_" + (unicodeRegExp.source) + "]*$")).test(name)) {
    // 正則判斷檢測是否為非法的標籤,例如數字開頭
    warn(
      'Invalid component name: "' + name + '". Component names ' +
      'should conform to valid custom element name in html5 specification.'
    );
  }
  // 不能使用Vue自身自定義的元件名,如slot, component,不能使用html的保留標籤,如 h1, svg等
  if (isBuiltInTag(name) || config.isReservedTag(name)) {
    warn(
      'Do not use built-in or reserved HTML elements as component ' +
      'id: ' + name
    );
  }
}
複製程式碼

1.3.2 props規範檢驗

Vue的官方文件規定了props選項的書寫形式有兩種,分別是

  1. 陣列形式 { props: ['a', 'b', 'c'] },
  2. 帶校驗規則的物件形式 { props: { a: { type: 'String', default: 'prop校驗' } }} 從原始碼上看,兩種形式最終都會轉換成物件的形式。
// props規範校驗
  function normalizeProps (options, vm) {
    var props = options.props;
    if (!props) { return }
    var res = {};
    var i, val, name;
    // props選項資料有兩種形式,一種是['a', 'b', 'c'],一種是{ a: { type: 'String', default: 'hahah' }}
    // 陣列
    if (Array.isArray(props)) {
      i = props.length;
      while (i--) {
        val = props[i];
        if (typeof val === 'string') {
          name = camelize(val);
          // 預設將陣列形式的props轉換為物件形式。
          res[name] = { type: null }; 
        } else {
          // 規則:保證是字串
          warn('props must be strings when using array syntax.');
        }
      }
    } else if (isPlainObject(props)) {
      for (var key in props) {
        val = props[key];
        name = camelize(key);
        res[name] = isPlainObject(val)
          ? val
          : { type: val };
      }
    } else {
      // 非陣列,非物件則判定props選項傳遞非法
      warn(
        "Invalid value for option \"props\": expected an Array or an Object, " +
        "but got " + (toRawType(props)) + ".",
        vm
      );
    }
    options.props = res;
  }
複製程式碼

1.3.3 inject的規範校驗

provide/inject這對組合在我們日常開發中可能使用得比較少,當我們需要在父元件中提供資料或者方法給後代元件使用時可以用到provide/inject,注意關鍵是後代,而不單純指子代,這是有別於props的使用場景。官方把它被稱為依賴注入,依賴注入使得元件後代都能訪問到父代注入的資料/方法,且後代不需要知道資料的來源。重要的一點,依賴提供的資料是非響應式的。

基本的使用如下:

// 父元件
var Provider = {
  provide: {
    foo: 'bar'
  },
  // ...
}
// 後代元件
var Child = {
  // 陣列寫法
  inject: ['foo'],
  // 物件寫法
  inject: {
    foo: {
      from: 'foo',
      default: 'bardefault'
    }
  }
}
複製程式碼

inject選項有兩種寫法,陣列的方式以及物件的方式,和props的校驗規則一致,最終inject都會轉換為物件的形式存在。

// inject的規範化
function normalizeInject (options, vm) {
    var inject = options.inject;
    if (!inject) { return }
    var normalized = options.inject = {};
    //陣列的形式
    if (Array.isArray(inject)) {
      for (var i = 0; i < inject.length; i++) {
        // from: 屬性是在可用的注入內容中搜尋用的 key (字串或 Symbol)
        normalized[inject[i]] = { from: inject[i] };
      }
    } else if (isPlainObject(inject)) {
      // 物件的處理
      for (var key in inject) {
        var val = inject[key];
        normalized[key] = isPlainObject(val)
          ? extend({ from: key }, val)
          : { from: val };
      }
    } else {
      // 非法規則
      warn(
        "Invalid value for option \"inject\": expected an Array or an Object, " +
        "but got " + (toRawType(inject)) + ".",
        vm
      );
    }
  }
複製程式碼

1.3.4 directive的規範校驗

我們先看看指令選項的用法,Vue允許我們自定義指令,並且它提供了五個鉤子函式bind, inserted, update, componentUpdated, unbind,具體的用法可以參考官方-自定義指令文件,而除了可以以物件的形式去定義鉤子函式外,官方還提供了一種函式的簡寫,例如:

{
  directives: {
    'color-swatch': function(el, binding) {
        el.style.backgroundColor = binding.value
    }
  }
}

複製程式碼

函式的寫法會在bind,update鉤子中觸發相同的行為,並且不關心其他鉤子。這個行為就是定義的函式。因此在對directives進行規範化時,針對函式的寫法會將行為賦予bind,update鉤子。

function normalizeDirectives (options) {
    var dirs = options.directives;
    if (dirs) {
      for (var key in dirs) {
        var def###1 = dirs[key];
        // 函式簡寫同樣會轉換成物件的形式
        if (typeof def###1 === 'function') {
          dirs[key] = { bind: def###1, update: def###1 };
        }
      }
    }
  }
複製程式碼

1.3.5 函式快取

這個內容跟選項的規範化無關,當讀到上面規範檢測的程式碼時,筆者發現有一段函式優化的程式碼值得我們學習。它將每次執行函式後的值進行快取,當再次執行的時候直接呼叫快取的資料而不是重複執行函式,以此提高前端效能,這是典型的用空間換時間的優化,也是經典的偏函式應用。

function cached (fn) {
  var cache = Object.create(null); // 建立空物件作為快取物件
  return (function cachedFn (str) {
    var hit = cache[str];
    return hit || (cache[str] = fn(str)) // 每次執行時快取物件有值則不需要執行函式方法,沒有則執行並快取起來
  })
}

var camelizeRE = /-(\w)/g;

// 快取會儲存每次進行駝峰轉換的結果
var camelize = cached(function (str) {
  // 將諸如 'a-b'的寫法統一處理成駝峰寫法'aB'
  return str.replace(camelizeRE, function (_, c) { return c ? c.toUpperCase() : ''; })
});

複製程式碼

1.4 子類構造器

選項校驗介紹完後,在正式進入合併策略之前,還需要先了解一個東西:子類構造器。為什麼需要先提到子類構造器呢?

按照前面的知識,Vue內部提供了四個預設選項,關鍵的三個是components,directives,filter。那麼當我們傳遞一個選項配置到Vue進行初始化,所需要合併的選項好像也僅僅是那關鍵的三個預設選項而已,那麼原始碼中大篇幅做的選項合併策略又是針對什麼場景呢?答案就是這個子類構造器。

Vue提供了一個Vue.extend的靜態方法,它是基於基礎的Vue構造器建立一個“子類”,而這個子類所傳遞的選項配置會和父類的選項配置進行合併。這是選項合併場景的由來。

因此有不要先了解子類構造器的實現。下面例子中,我們建立了一個Child的子類,它繼承於父類Parent,最終將子類掛載到#app元素上。最終獲取的data便是選項合併後的結果。

var Parent = Vue.extend({
  data() {
    test: '父類',
    test1: '父類1'
  }
})
var Child = Parent.extend({
  data() {
    test: '子類',
    test2: '子類1'
  }
})
var vm = new Child().$mount('#app');
console.log(vm.$data);
// 結果 
{
  test: '子類',
  test1: '父類1',
  test2: '子類1'
}
複製程式碼

Vue.extend的實現思路很清晰,建立了一個Sub的類,這個類的原型指向了父類,並且子類的options會和父類的options進行合併,mergeOptions的其他細節接下來會重點分析。

Vue.extend = function (extendOptions) {
  extendOptions = extendOptions || {};
  var Super = this;

  var name = extendOptions.name || Super.options.name;
  if (name) {
    validateComponentName(name); // 校驗子類的名稱是否符合規範
  }

  // 建立子類構造器
  var Sub = function VueComponent (options) {
    this._init(options);
  };
  Sub.prototype = Object.create(Super.prototype); // 子類繼承於父類
  Sub.prototype.constructor = Sub;
  Sub.cid = cid++;
  // 子類和父類構造器的配置選項進行合併
  Sub.options = mergeOptions(
    Super.options,
    extendOptions
  );

  return Sub // 返回子類建構函式
};
複製程式碼

相關文章