「試著讀讀 Vue 原始碼」new Vue()發生了什麼 ❓

cllemon發表於2019-06-08

說明

  • 首先這篇文章是讀 vue.js 原始碼的梳理性文章,文章分塊梳理,記錄著自己的一些理解及大致過程;更重要的一點是希望在 vue.js 3.0 釋出前深入的瞭解其原理。

  • 如果你從未看過或者接觸過 vue.js 原始碼,建議你參考以下列出的 vue.js 解析的相關文章,因為這些文章更細緻的講解了這個工程,本文只是以一些 demo 演示某一功能點或 API 實現,力求簡要梳理過程。

  • 如果搞清楚了工程目錄及入口,建議直接去看程式碼,這樣比較高效 ( 遇到難以理解對應著回來看看別人的講解,加以理解即可 )

  • 文章所涉及到的程式碼,基本都是縮減版,具體還請參閱 vue.js - 2.5.17

  • 如有任何疏漏和錯誤之處歡迎指正、交流。

Vue 建構函式

/**
 * Vue建構函式
 *
 * @param {*} options 選項引數
 */
function Vue(options) {
  if (process.env.NODE_ENV !== 'production' && !(this instanceof Vue)) {
    warn('Vue是一個建構函式,應該用“new”關鍵字呼叫');
  }
  this._init(options);
}
複製程式碼

我們知道 new Vue()將執行 Vue 建構函式, 進而執行 _init(), 那 _init 方法從何處而來?答案是Vue在初始化時新增了該方法,如果你對初始化還不是很清楚,建議你參考上文對初始化過程的梳理性文章:「試著讀讀 Vue 原始碼」初始化前後做了哪些事情❓

_init()

import config from '../config';
import { initProxy } from './proxy';
import { initState } from './state';
import { initRender } from './render';
import { initEvents } from './events';
import { mark, measure } from '../util/perf';
import { initLifecycle, callHook } from './lifecycle';
import { initProvide, initInjections } from './inject';
import { extend, mergeOptions, formatComponentName } from '../util/index';

let uid = 0;
export function initMixin(Vue: Class<Component>) {
  Vue.prototype._init = function(options?: Object) {
    const vm: Component = this; // 當前 Vue 例項
    vm._uid = uid++; // 當前 Vue 例項唯一標識

    /**************************** 非生產環境下進行效能監控 --- start ****************************/
    let startTag, endTag;
    if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
      startTag = `vue-perf-start:${vm._uid}`;
      endTag = `vue-perf-end:${vm._uid}`;
      mark(startTag);
    }

    vm._isVue = true; // 一個標誌,避免該物件被響應系統觀測

    /****************** 對 Vue 提供的 props、data、methods等選項進行合併處理 ******************/
    // _isComponent 內部選項:在 Vue 建立元件的時候才會生成
    if (options && options._isComponent) {
      initInternalComponent(vm, options); // 優化內部元件例項化,因為動態選項合併非常慢,而且沒有一個內部元件選項需要特殊處理。
    } else {
      vm.$options = mergeOptions(
        resolveConstructorOptions(vm.constructor), // parentVal
        options || {}, // childVal
        vm
      );
    }

    // 設定渲染函式的作用域代理,其目的是提供更好的提示資訊(如:在模板內訪問例項不存在的屬性,則會在非生產環境下提供準確的報錯資訊)
    if (process.env.NODE_ENV !== 'production') {
      initProxy(vm);
    } else {
      vm._renderProxy = vm;
    }

    vm._self = vm; // 暴露真實的例項本身

    /**************************** 執行相關初始化程式及呼叫初期生命週期函式 ****************************/
    initLifecycle(vm); // 初始化生命週期
    initEvents(vm); // 初始化事件
    initRender(vm); // 初始化渲染
    callHook(vm, 'beforeCreate'); // 呼叫生命週期鉤子函式 -- beforeCreate
    initInjections(vm); // resolve injections before data/props
    initState(vm); // 初始化 initProps、initMethods、initData、initComputed、initWatch
    initProvide(vm); // resolve provide after data/props
    callHook(vm, 'created'); // 此時還沒有任何掛載的操作,所以在 created 中是不能訪問DOM的,即不能訪問 $el

    /**************************** 非生產環境下進行效能監控 --- end ****************************/
    if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
      vm._name = formatComponentName(vm, false);
      mark(endTag);
      measure(`vue ${vm._name} init`, startTag, endTag);
    }

    /**************************** 根據掛載點,呼叫掛載函式 ****************************/
    if (vm.$options.el) {
      vm.$mount(vm.$options.el);
    }
  };
}
複製程式碼
  • 根據_init方法所做的事情可大概梳理出以下要點:
    • ① 在非生產環境下開啟效能監控程式(監控 ②、③、④ 執行過程耗時)。
    • ② 對 Vue 提供的 props、data、methods 等選項進行合併處理。
    • ③ 設定渲染函式的作用域代理。
    • ④ 執行相關初始化程式及呼叫初期生命週期函式。
    • ⑤ 根據掛載點,呼叫掛載函式。

注:效能監控:利用 Web Performance API 允許網頁訪問某些函式來測量網頁和Web應用程式的效能; 這裡是Vue - mark、measure具體程式碼實現,就不過多贅述了; 接下來著重看被監控的幾個步驟主要做了什麼?

new Vue()

如果就單單看程式碼,可能就不太直觀且不易理解;不如直接用 Demo 代入斷點除錯看看每一步是如何做的,那將會使你對程式碼的執行有更直觀的理解與認識。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>vue.js DEMO</title>
    <script src="../../dist/vue.js"></script>
  </head>
  <body>
    <div id="app">
      <p>計算屬性:{{messageTo}}</p>
      <p>資料屬性:{{ message }}</p>
      <button @click="update">更新</button>
      <item v-for="item in list" :msg="item" :key="item" @rm="remove(item)" />
    </div>

    <script>
      new Vue({
        el: '#app',

        components: {
          item: {
            props: ['msg'],
            template: `<div style="margin-top: 20px;">{{ msg }} <button @click="$emit('rm')">x</button></div>`,
            created() {
              console.log('---componentA - 元件生命週期鉤子執行 created---');
            }
          }
        },

        mixins: [
          {
            created() {
              console.log('---created - mixins---');
            },
            methods: {
              remove(item) {
                console.log('響應移除:', item);
              }
            }
          }
        ],

        data: {
          message: 'hello vue.js',
          list: ['hello,', 'the updated', 'vue.js'],
          obj: {
            a: 1,
            b: {
              c: 2,
              d: 3
            }
          }
        },

        computed: {
          messageTo() {
            return `${this.message} !;`;
          }
        },

        watch: {
          message(val, oldVal) {
            console.log(val, oldVal, 'message - 改變了');
          }
        },

        methods: {
          update() {
            this.message = `${this.list.join(' ')} ---- ${Math.random()}`;
          }
        }
      });
    </script>
  </body>
</html>
複製程式碼

根據上述 demo 斷點進入 Vue 建構函式 options 引數如下斷點圖所:

「試著讀讀 Vue 原始碼」new Vue()發生了什麼 ❓

選項合併處理

  • 根據上述 Demo 我們著重分析執行程式碼即 mergeOptions函式,根據程式碼可知該函式是對我們傳入的options做了一層處理,然後賦值給例項屬性$options

  • resolveConstructorOptions, 該函式主要判斷建構函式是否存在父類,若存在父類需要對 vm.constructor.options 進行處理返回,若不存在直接返回vm.constructor.options; 根據上述Demo直接返回 vm.constructor.options

  • 注:在上文初始化過程對 vm.constructor.options 進行處理,其結果為:

    Vue.options = {
      components: {
        KeepAlive,
        Transition,
        TransitionGroup
      },
      directives: {
        model,
        show
      },
      filters: Object.create(null),
      _base: Vue
    };
    複製程式碼
// _isComponent 內部選項:在 Vue 建立元件的時候才會生成
if (options && options._isComponent) {
  initInternalComponent(vm, options); // 優化內部元件例項化,因為動態選項合併非常慢,而且沒有一個內部元件選項需要特殊處理。
} else {
  vm.$options = mergeOptions(
    resolveConstructorOptions(vm.constructor), // parentVal
    options || {}, // childVal
    vm
  );
}
複製程式碼

根據上述分析,程式進入 mergeOptions 函式內部,下面斷點圖展示了該函式的入參:

「試著讀讀 Vue 原始碼」new Vue()發生了什麼 ❓

mergeOptions

將兩個 option 物件合併到一個新的 options,用於例項化和繼承的核心實用程式中。

export function mergeOptions(
  parent: Object,
  child: Object,
  vm?: Component
): Object {
  // 校驗元件的名字是否符合要求:
  //                       限定元件的名字由普通的字元和中橫線(-)組成,且必須以字母開頭。
  //                       檢測是否是內建的標籤(如:slot) ||  檢測是否是保留標籤(html、svg等)。
  if (process.env.NODE_ENV !== 'production') {
    checkComponents(child);
  }

  // 如果 child 是一個函式的話,去其靜態屬性 options 重寫 child;
  if (typeof child === 'function') {
    child = child.options;
  }

  /************************  規範化處理  ************************/
  normalizeProps(child, vm);
  normalizeInject(child, vm);
  normalizeDirectives(child);

  /************************  extends/mixins 遞迴處理合並  ************************/
  const extendsFrom = child.extends;
  if (extendsFrom) {
    parent = mergeOptions(parent, extendsFrom, 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);
    }
  }
  function mergeField(key) {
    const strat = strats[key] || defaultStrat;
    options[key] = strat(parent[key], child[key], vm, key);
  }

  return options;
}
複製程式碼

規範化處理

normalizeProps(child, vm);
normalizeInject(child, vm);
normalizeDirectives(child);
複製程式碼

上述程式碼主要對 Vue 選項進行規範化處理,我們知道 Vue 的選項支援多種寫法,但最終都需要化為統一格式,進行處理。 下面所列出的是各種寫法與規範化之後的對比; 上述程式碼實現就不過多論述了,可直接根據上述導航到程式碼段去看即可。

  • Props:

    • 如下幾種寫法:
      • props: ['size', 'myMessage']
      • props: { height: Number }
      • props: { height: { type: Number, default: 0 } }
    • 統一格式處理之後為:
      • props: { size: { type: null }, myMessage: { type: null } }
      • props: { height: { type: Number } }
      • props: { height: { type: Number, default: 0 } }
  • Inject:

    • 如下幾種寫法:
      • inject: ['foo'],
      • inject: { bar: 'foo' }
    • 統一格式處理之後為:
      • inject: { foo: { from: 'foo' } }
      • inject: { bar: { from: 'foo' } }
  • Directives:

    • 如下幾種寫法:
      • directives: { foo: function() { console.log('自定義指令: v-foo') }
    • 統一格式處理之後為:
      • directives: { foo: { bind: function() { console.log('v-foo'), update: function() { console.log('v-foo') } } }

合併階段

程式碼到執行到這裡,將開始真正的合併了,最終返回合併之後的options

const options = {};
let key;
for (key in parent) {
  mergeField(key);
}
for (key in child) {
  if (!hasOwn(parent, key)) {
    mergeField(key);
  }
}
function mergeField(key) {
  const strat = strats[key] || defaultStrat;
  options[key] = strat(parent[key], child[key], vm, key);
}
return options;
複製程式碼

這裡特別說明一下,Vue 為每一個選項合併都提供了選項合併的策略函式,strats 變數存放著這些函式。這裡就不分別對每個策略函式進行展開論述了。

const defaultStrat = function(parentVal: any, childVal: any): any {
  return childVal === undefined ? parentVal : childVal;
};

export function mergeDataOrFn(
  parentVal: any,
  childVal: any,
  vm?: Component
): ?Function {
  // ...
}

// optionMergeStrategies: Object.create(null),
const strats = config.optionMergeStrategies;

// el / propsData 合併策略函式
if (process.env.NODE_ENV !== 'production') {
  strats.el = strats.propsData = function(parent, child, vm, key) {
    // ...
  };
}

// data 合併策略函式
strats.data = function(
  parentVal: any,
  childVal: any,
  vm?: Component
): ?Function {
  // ...
};

// watch 合併策略函式
strats.watch = function(
  parentVal: ?Object,
  childVal: ?Object,
  vm?: Component,
  key: string
): ?Object {
  // ...
};

// props、methods、inject、computed 合併策略函式
strats.props = strats.methods = strats.inject = strats.computed = function(
  parentVal: ?Object,
  childVal: ?Object,
  vm?: Component,
  key: string
): ?Object {
  // ...
};

// provide 合併策略函式
strats.provide = mergeDataOrFn;
複製程式碼

根據上述分析, mergeOptions 函式將返回規範化,且合併之後options,下面斷點圖展示了合併之後的options

「試著讀讀 Vue 原始碼」new Vue()發生了什麼 ❓

執行相關初始化程式及呼叫初期生命週期函式

initLifecycle(vm); // 初始化生命週期
initEvents(vm); // 初始化事件
initRender(vm); // 初始化渲染
callHook(vm, 'beforeCreate'); // 呼叫生命週期鉤子函式 -- beforeCreate
initInjections(vm); // resolve injections before data/props
initState(vm); // 初始化 initProps、initMethods、initData、initComputed、initWatch
initProvide(vm); // resolve provide after data/props
callHook(vm, 'created'); // 此時還沒有任何掛載的操作,所以在 created 中是不能訪問DOM的,即不能訪問 $el
複製程式碼

initLifecycle

  • 如下程式碼主要做了:
    • 找到第一個非抽象父級
    • 將當前例項新增到父例項的 $children 屬性裡
    • 並設定當前例項的 $parent 為父例項
    • 在當前例項上設定一些屬性
export function initLifecycle(vm: Component) {
  const options = vm.$options;
  /**
   * abstract - 是否是抽象元件
   * 抽象元件: 它自身不會渲染一個 DOM 元素,也不會出現在父元件鏈中。(如 keep-alive transition )
   */
  let parent = options.parent;
  if (parent && !options.abstract) {
    // 迴圈查詢第一個非抽象的父元件
    while (parent.$options.abstract && parent.$parent) {
      parent = parent.$parent;
    }
    parent.$children.push(vm);
  }
  vm.$parent = parent;
  vm.$root = parent ? parent.$root : vm;

  vm.$children = [];
  vm.$refs = {};
  vm._watcher = null;
  vm._inactive = null;
  vm._directInactive = false;
  vm._isMounted = false;
  vm._isDestroyed = false;
  vm._isBeingDestroyed = false;
}
複製程式碼

initEvents

export function initEvents(vm: Component) {
  // 在當前例項新增 `_events` `_hasHookEvent` 屬性
  vm._events = Object.create(null);
  vm._hasHookEvent = false; // 用於判斷是否存在生命週期鉤子的事件偵聽器
  const listeners = vm.$options._parentListeners; // 初始化父附加事件
  if (listeners) {
    updateComponentListeners(vm, listeners);
  }
}
複製程式碼

initRender

export function initRender(vm: Component) {
  vm._vnode = null; // the root of the child tree
  vm._staticTrees = null; // v-once cached trees

  /***************************  解析並處理 slot  **************************/
  const options = vm.$options;
  const parentVnode = (vm.$vnode = options._parentVnode); // the placeholder node in parent tree
  const renderContext = parentVnode && parentVnode.context;
  vm.$slots = resolveSlots(options._renderChildren, renderContext);
  vm.$scopedSlots = emptyObject;

  /***************************  包裝 createElement()   **************************/
  // render: (createElement: () => VNode) => VNode createElement
  // 將createElement fn繫結到這個例項,以便在其中獲得適當的呈現上下文。
  // args順序:標籤、資料、子元素、normalizationType、alwaysNormalize內部版本由模板編譯的呈現函式使用
  vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false);
  // 規範化總是應用於公共版本,用於使用者編寫的呈現函式。
  vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true);

  /***************************  在例項新增 $attrs/$listeners   **************************/
  // $attrs和$listeners 用於更容易的臨時建立。它們需要是反應性的,以便使用它們的 HOC 總是被更新
  const parentData = parentVnode && parentVnode.data;
  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
    );
  }
  /***************************  在例項新增 $attrs/$listeners   **************************/
}
複製程式碼

callHook

export function callHook(vm: Component, hook: string) {
  pushTarget(); // 為了避免在某些生命週期鉤子中使用 props 資料導致收集冗餘的依賴 #7573
  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`);
      }
    }
  }
  // 判斷是否存在生命週期鉤子的事件偵聽器,在 initEvents 中初始化,若存在觸發響應鉤子函式
  if (vm._hasHookEvent) {
    vm.$emit('hook:' + hook);
  }
  popTarget();
}
複製程式碼

這裡額外提一下: 可以使用 hook: 加 生命週期鉤子名稱 的方式來監聽元件相應的生命週期

<child
  @hook:beforeCreate="handleChildBeforeCreate"
  @hook:created="handleChildCreated"
  @hook:mounted="handleChildMounted"
  @hook:生命週期鉤子名稱
/>
複製程式碼

initInjections

export function initInjections(vm: Component) {
  const result = resolveInject(vm.$options.inject, vm); // 作用:尋找父代元件提供的資料
  if (result) {
    // provide 和 inject 繫結並不是可響應的。
    // 這是刻意為之的。然而,如果你傳入了一個可監聽的物件,那麼其物件的屬性還是可響應的。
    toggleObserving(false); // 關閉響應式檢測
    Object.keys(result).forEach(key => {
      // 對每個屬性定義響應式屬性,並在非生產環境下,提供警告程式。
      if (process.env.NODE_ENV !== 'production') {
        defineReactive(vm, key, result[key], () => {
          warn(
            `避免直接修改注入的值,因為當提供的元件重新呈現時,更改將被覆蓋。正在修改的注入:“${key}”`,
            vm
          );
        });
      } else {
        defineReactive(vm, key, result[key]);
      }
    });
    toggleObserving(true); // 開啟響應式檢測
  }
}
複製程式碼

initState

/**
 * 初始化 props/ methods/ data/ computed/ watch/ 等選項。
 */
export function initState(vm: Component) {
  vm._watchers = [];
  const opts = vm.$options;
  if (opts.props) initProps(vm, opts.props);
  if (opts.methods) initMethods(vm, opts.methods);
  if (opts.data) {
    initData(vm);
  } else {
    observe((vm._data = {}), true /* asRootData */);
  }
  if (opts.computed) initComputed(vm, opts.computed);
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch);
  }
}
複製程式碼

注: 這裡只是簡單展示了其初始化順序,其內部各個初始化方法將在構建響應式系統深挖。 這裡只需要明白一點,即初始化順序:props => methods => data => computed => watch (根據上述順序,自然也就知道,為什麼可以在data選項中使用props去初始化值)

initProvide

export function initProvide(vm: Component) {
  const provide = vm.$options.provide;
  if (provide) {
    vm._provided = typeof provide === 'function' ? provide.call(vm) : provide;
  }
}
複製程式碼

上述初始化部分的分析,只是簡單的梳理了其執行過程,如果想對其內部實現做更為細緻的認識,可以自行去看看程式碼實現或上述說明提到的原始碼解析的相關文章。

根據掛載點,呼叫掛載函式

若存在掛載點,則執行掛載函式,渲染元件。掛載函式如何執行,實現機制如何,將在後文慢慢梳理出來。

if (vm.$options.el) {
  vm.$mount(vm.$options.el);
}
複製程式碼

總結:全文梳理了執行 new Vue() 呼叫 _init() 方法,接著又跟著程式碼執行過程探討了內部實現。


承接上文 - 「試著讀讀 Vue 原始碼」初始化前後做了哪些事❓

承接下文 - 「試著讀讀Vue原始碼」響應式系統是如何構建的❓待續...

相關文章