深刻剖析Vue源碼 - 響應式系統構建(上)

從這一小節開始,正式進入Vue源碼的核心,也是難點之一,響應式系統的構建。這一節將做爲分析響應式構建過程源碼的入門,主要分爲兩大塊,第一塊是針對響應式數據props,methods,data,computed,wather初始化過程的分析,另外一塊則是在保留源碼設計理念的前提下,嘗試手動構建一個基礎的響應式系統。有了這兩個基礎內容的鋪墊,下一篇進行源碼具體細節的分析會更加駕輕就熟。vue

7.1 數據初始化

回顧一下以前的內容,咱們對Vue源碼的分析是從初始化開始,初始化_init會執行一系列的過程,這個過程包括了配置選項的合併,數據的監測代理,最後纔是實例的掛載。而在實例掛載前還有意忽略了一個重要的過程,數據的初始化(即initState(vm))。initState的過程,是對數據進行響應式設計的過程,過程會針對props,methods,data,computedwatch作數據的初始化處理,並將他們轉換爲響應式對象,接下來咱們會逐步分析每個過程。node

function initState (vm) {
  vm._watchers = [];
  var opts = vm.$options;
  // 初始化props
  if (opts.props) { initProps(vm, opts.props); }
  // 初始化methods
  if (opts.methods) { initMethods(vm, opts.methods); }
  // 初始化data
  if (opts.data) {
    initData(vm);
  } else {
    // 若是沒有定義data,則建立一個空對象,並設置爲響應式
    observe(vm._data = {}, true /* asRootData */);
  }
  // 初始化computed
  if (opts.computed) { initComputed(vm, opts.computed); }
  // 初始化watch
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch);
  }
}
複製代碼

7.2 initProps

簡單回顧一下props的用法,父組件經過屬性的形式將數據傳遞給子組件,子組件經過props屬性接收父組件傳遞的值。算法

// 父組件
<child :test="test"></child>
var vm = new Vue({
  el: '#app',
  data() {
    return {
      test: 'child'
    }
  }
})
// 子組件
Vue.component('child', {
  template: '<div>{{test}}</div>',
  props: ['test']
})
複製代碼

所以分析props須要分析父組件和子組件的兩個過程,咱們先看父組件對傳遞值的處理。按照以往文章介紹的那樣,父組件優先進行模板編譯獲得一個render函數,在解析過程當中遇到子組件的屬性,:test=test會被解析成{ attrs: {test: test}}並做爲子組件的render函數存在,以下所示:數組

with(){..._c('child',{attrs:{"test":test}})}
複製代碼

render解析Vnode的過程遇到child這個子佔位符節點,所以會進入建立子組件Vnode的過程,建立子Vnode過程是調用createComponent,這個階段咱們在組件章節有分析過,在組件的高級用法也有分析過,最終會調用new Vnode去建立子Vnode。而對於props的處理,extractPropsFromVNodeData會對attrs屬性進行規範校驗後,最後會把校驗後的結果以propsData屬性的形式傳入Vnode構造器中。總結來講,props傳遞給佔位符組件的寫法,會以propsData的形式做爲子組件Vnode的屬性存在。下面會分析具體的細節。瀏覽器

// 建立子組件過程
function createComponent() {
  // props校驗
  var propsData = extractPropsFromVNodeData(data, Ctor, tag);
  ···
  // 建立子組件vnode
  var vnode = new VNode(
    ("vue-component-" + (Ctor.cid) + (name ? ("-" + name) : '')),
    data, undefined, undefined, undefined, context,
    { Ctor: Ctor, propsData: propsData, listeners: listeners, tag: tag, children: children },
    asyncFactory
  );
}
複製代碼

7.2.1 props的命名規範

先看檢測props規範性的過程。**props編譯後的結果有兩種,其中attrs前面分析過,是編譯生成render函數針對屬性的處理,而props是針對用戶自寫render函數的屬性值。**所以須要同時對這兩種方式進行校驗。markdown

function extractPropsFromVNodeData (data,Ctor,tag) {
  // Ctor爲子類構造器
  ···
  var res = {};
  // 子組件props選項
  var propOptions = Ctor.options.props;
  // data.attrs針對編譯生成的render函數,data.props針對用戶自定義的render函數
  var attrs = data.attrs;
  var props = data.props;
  if (isDef(attrs) || isDef(props)) {
    for (var key in propOptions) {
      // aB 形式轉成 a-b
      var altKey = hyphenate(key);
      {
          var keyInLowerCase = key.toLowerCase();
          if (
            key !== keyInLowerCase &&
            attrs && hasOwn(attrs, keyInLowerCase)
          ) {
            // 警告
          }
        }
    }
  }
}
複製代碼

重點說一下源碼在這一部分的處理,HTML對大小寫是不敏感的,全部的瀏覽器會把大寫字符解釋爲小寫字符,所以咱們在使用DOM中的模板時,cameCase(駝峯命名法)的props名須要使用其等價的 kebab-case (短橫線分隔命名) 命代替即: <child :aB="test"></child>須要寫成<child :a-b="test"></child>app

7.2.2 響應式數據props

剛纔說到分析props須要兩個過程,前面已經針對父組件對props的處理作了描述,而對於子組件而言,咱們是經過props選項去接收父組件傳遞的值。咱們再看看子組件對props的處理:框架

子組件處理props的過程,是發生在父組件_update階段,這個階段是Vnode生成真實節點的過程,期間會遇到子Vnode,這時會調用createComponent去實例化子組件。而實例化子組件的過程又回到了_init初始化,此時又會經歷選項的合併,針對props選項,最終會統一成{props: { test: { type: null }}}的寫法。接着會調用initProps, initProps作的事情,簡單歸納一句話就是,將組件的props數據設置爲響應式數據。async

function initProps (vm, propsOptions) {
  var propsData = vm.$options.propsData || {};
  var loop = function(key) {
    ···
    defineReactive(props,key,value,cb);
    if (!(key in vm)) {
      proxy(vm, "_props", key);
    }
  }
  // 遍歷props,執行loop設置爲響應式數據。
  for (var key in propsOptions) loop( key );
}
複製代碼

其中proxy(vm, "_props", key);props作了一層代理,用戶經過vm.XXX能夠代理訪問到vm._props上的值。針對defineReactive,本質上是利用Object.defineProperty對數據的getter,setter方法進行重寫,具體的原理能夠參考數據代理章節的內容,在這小節後半段也會有一個基本的實現。函數

7.3 initMethods

initMethod方法和這一節介紹的響應式沒有任何的關係,他的實現也相對簡單,主要是保證methods方法定義必須是函數,且命名不能和props重複,最終會將定義的方法都掛載到根實例上。

function initMethods (vm, methods) {
    var props = vm.$options.props;
    for (var key in methods) {
      {
        // method必須爲函數形式
        if (typeof methods[key] !== 'function') {
          warn(
            "Method \"" + key + "\" has type \"" + (typeof methods[key]) + "\" in the component definition. " +
            "Did you reference the function correctly?",
            vm
          );
        }
        // methods方法名不能和props重複
        if (props && hasOwn(props, key)) {
          warn(
            ("Method \"" + key + "\" has already been defined as a prop."),
            vm
          );
        }
        // 不能以_ or $.這些Vue保留標誌開頭
        if ((key in vm) && isReserved(key)) {
          warn(
            "Method \"" + key + "\" conflicts with an existing Vue instance method. " +
            "Avoid defining component methods that start with _ or $."
          );
        }
      }
      // 直接掛載到實例的屬性上,能夠經過vm[method]訪問。
      vm[key] = typeof methods[key] !== 'function' ? noop : bind(methods[key], vm);
    }
  }
複製代碼

7.4 initData

data在初始化選項合併時會生成一個函數,只有在執行函數時纔會返回真正的數據,因此initData方法會先執行拿到組件的data數據,而且會對對象每一個屬性的命名進行校驗,保證不能和props,methods重複。最後的核心方法是observe,observe方法是將數據對象標記爲響應式對象,並對對象的每一個屬性進行響應式處理。與此同時,和props的代理處理方式同樣,proxy會對data作一層代理,直接經過vm.XXX能夠代理訪問到vm._data上掛載的對象屬性。

function initData(vm) {
  var data = vm.$options.data;
  // 根實例時,data是一個對象,子組件的data是一個函數,其中getData會調用函數返回data對象
  data = vm._data = typeof data === 'function'? getData(data, vm): data || {};
  var keys = Object.keys(data);
  var props = vm.$options.props;
  var methods = vm.$options.methods;
  var i = keys.length;
  while (i--) {
    var key = keys[i];
    {
      // 命名不能和方法重複
      if (methods && hasOwn(methods, key)) {
        warn(("Method \"" + key + "\" has already been defined as a data property."),vm);
      }
    }
    // 命名不能和props重複
    if (props && hasOwn(props, key)) {
      warn("The data property \"" + key + "\" is already declared as a prop. " + "Use prop default value instead.",vm);
    } else if (!isReserved(key)) {
      // 數據代理,用戶可直接經過vm實例返回data數據
      proxy(vm, "_data", key);
    }
  }
  // observe data
  observe(data, true /* asRootData */);
}
複製代碼

最後講講observe,observe具體的行爲是將數據對象添加一個不可枚舉的屬性__ob__,標誌對象是一個響應式對象,而且拿到每一個對象的屬性值,重寫getter,setter方法,使得每一個屬性值都是響應式數據。詳細的代碼咱們後面分析。

7.5 initComputed

和上面的分析方法同樣,initComputedcomputed數據的初始化,不一樣之處在於如下幾點:

  1. computed能夠是對象,也能夠是函數,可是對象必須有getter方法,所以若是computed中的屬性值是對象時須要進行驗證。
  2. 針對computed的每一個屬性,要建立一個監聽的依賴,也就是實例化一個watcher,watcher的定義,能夠暫時理解爲數據使用的依賴自己,一個watcher實例表明多了一個須要被監聽的數據依賴。

除了不一樣點,initComputed也會將每一個屬性設置成響應式的數據,一樣的,也會對computed的命名作檢測,防止與props,data衝突。

function initComputed (vm, computed) {
  ···
  for (var key in computed) {
      var userDef = computed[key];
      var getter = typeof userDef === 'function' ? userDef : userDef.get;
      // computed屬性爲對象時,要保證有getter方法
      if (getter == null) {
        warn(("Getter is missing for computed property \"" + key + "\"."),vm);
      }
      if (!isSSR) {
        // 建立computed watcher
        watchers[key] = new Watcher(vm,getter || noop,noop,computedWatcherOptions);
      }
      if (!(key in vm)) {
        // 設置爲響應式數據
        defineComputed(vm, key, userDef);
      } else {
        // 不能和props,data命名衝突
        if (key in vm.$data) {
          warn(("The computed property \"" + key + "\" is already defined in data."), vm);
        } else if (vm.$options.props && key in vm.$options.props) {
          warn(("The computed property \"" + key + "\" is already defined as a prop."), vm);
        }
      }
    }
}
複製代碼

顯然Vue提供了不少種數據供開發者使用,可是分析完後發現每一個處理的核心都是將數據轉化成響應式數據,有了響應式數據,如何構建一個響應式系統呢?前面提到的watcher又是什麼東西?構建響應式系統還須要其餘的東西嗎?接下來咱們嘗試着去實現一個極簡風的響應式系統。

7.6 極簡風的響應式系統

Vue的響應式系統構建是比較複雜的,直接進入源碼分析構建的每個流程會讓理解變得困難,所以我以爲在儘量保留源碼的設計邏輯下,用最小的代碼構建一個最基礎的響應式系統是有必要的。對Dep,Watcher,Observer概念的初步認識,也有助於下一篇對響應式系統設計細節的分析。

7.6.1 框架搭建

咱們以MyVue做爲類響應式框架,框架的搭建不作贅述。咱們模擬Vue源碼的實現思路,實例化MyVue時會傳遞一個選項配置,精簡的代碼只有一個id掛載元素和一個數據對象data。模擬源碼的思路,咱們在實例化時會先進行數據的初始化,這一步就是響應式的構建,咱們稍後分析。數據初始化後開始進行真實DOM的掛載。

var vm = new MyVue({
  id: '#app',
  data: {
    test: 12
  }
})
// myVue.js
(function(global) {
  class MyVue {
      constructor(options) {
        this.options = options;
        // 數據的初始化
        this.initData(options);
        let el = this.options.id;
        // 實例的掛載
        this.$mount(el);
      }
      initData(options) {
      }
      $mount(el) {
      }
    }
}(window))
複製代碼

7.6.2 設置響應式對象 - Observer

首先引入一個類Observer,這個類的目的是將數據變成響應式對象,利用Object.defineProperty對數據的getter,setter方法進行改寫。在數據讀取getter階段咱們會進行依賴的收集,在數據的修改setter階段,咱們會進行依賴的更新(這兩個概念的介紹放在後面)。所以在數據初始化階段,咱們會利用Observer這個類將數據對象修改成相應式對象,而這是全部流程的基礎。

class MyVue {
  initData(options) {
    if(!options.data) return;
    this.data = options.data;
    // 將數據重置getter,setter方法
    new Observer(options.data);
  }
}
// Observer類的定義
class Observer {
  constructor(data) {
    // 實例化時執行walk方法對每一個數據屬性重寫getter,setter方法
    this.walk(data)
  }

  walk(obj) {
    const keys = Object.keys(obj);
    for(let i = 0;i< keys.length; i++) {
      // Object.defineProperty的處理邏輯
      defineReactive(obj, keys[i])
    }
  }
}
複製代碼

7.6.3 依賴自己 - Watcher

咱們能夠這樣理解,一個Watcher實例就是一個依賴,數據不論是在渲染模板時使用仍是在用戶計算時使用,均可以算作一個須要監聽的依賴,watcher中記錄着這個依賴監聽的狀態,以及如何更新操做的方法。

// 監聽的依賴
class Watcher {
  constructor(expOrFn, isRenderWatcher) {
    this.getter = expOrFn;
    // Watcher.prototype.get的調用會進行狀態的更新。
    this.get();
  }

  get() {}
}
複製代碼

那麼哪一個時間點會實例化watcher並更新數據狀態呢?顯然在渲染數據到真實DOM時能夠建立watcher$mount流程前面章節介紹過,會經歷模板生成render函數和render函數渲染真實DOM的過程。咱們對代碼作了精簡,updateView濃縮了這一過程。

class MyVue {
  $mount(el) {
    // 直接改寫innerHTML
    const updateView = _ => {
      let innerHtml = document.querySelector(el).innerHTML;
      let key = innerHtml.match(/{(\w+)}/)[1];
      document.querySelector(el).innerHTML = this.options.data[key]
    }
    // 建立一個渲染的依賴。
    new Watcher(updateView, true)
  }
}
複製代碼

7.6.4 依賴管理 - Dep

watcher若是理解爲每一個數據須要監聽的依賴,那麼Dep 能夠理解爲對依賴的一種管理。數據能夠在渲染中使用,也能夠在計算屬性中使用。相應的每一個數據對應的watcher也有不少。而咱們在更新數據時,如何通知到數據相關的每個依賴,這就須要Dep進行通知管理了。而且瀏覽器同一時間只能更新一個watcher,因此也須要一個屬性去記錄當前更新的watcher。而Dep這個類只須要作兩件事情,將依賴進行收集,派發依賴進行更新。

let uid = 0;
class Dep {
  constructor() {
    this.id = uid++;
    this.subs = []
  }
  // 依賴收集
  depend() {
    if(Dep.target) {
      // Dep.target是當前的watcher,將當前的依賴推到subs中
      this.subs.push(Dep.target)
    }
  }
  // 派發更新
  notify() {
    const subs = this.subs.slice();
    for (var i = 0, l = subs.length; i < l; i++) { 
      // 遍歷dep中的依賴,對每一個依賴執行更新操做
      subs[i].update();
    }
  }
}

Dep.target = null;
複製代碼

7.6.5 依賴管理過程 - defineReactive

咱們看看數據攔截的過程。前面的Observer實例化最終會調用defineReactive重寫getter,setter方法。這個方法開始會實例化一個Dep,也就是建立一個數據的依賴管理。在重寫的getter方法中會進行依賴的收集,也就是調用dep.depend的方法。在setter階段,比較兩個數不一樣後,會調用依賴的派發更新。即dep.notify

const defineReactive = (obj, key) => {
  const dep = new Dep();
  const property = Object.getOwnPropertyDescriptor(obj);
  let val = obj[key]
  if(property && property.configurable === false) return;
  Object.defineProperty(obj, key, {
    configurable: true,
    enumerable: true,
    get() {
      // 作依賴的收集
      if(Dep.target) {
        dep.depend()
      }
      return val
    },
    set(nval) {
      if(nval === val) return
      // 派發更新
      val = nval
      dep.notify();
    }
  })
}
複製代碼

回過頭來看watcher,實例化watcher時會將Dep.target設置爲當前的watcher,執行完狀態更新函數以後,再將Dep.target置空。這樣在收集依賴時只要將Dep.target當前的watcher pushDepsubs數組便可。而在派發更新階段也只須要從新更新狀態便可。

class Watcher {
  constructor(expOrFn, isRenderWatcher) {
    this.getter = expOrFn;
    // Watcher.prototype.get的調用會進行狀態的更新。
    this.get();
  }

  get() {
    // 當前執行的watcher
    Dep.target = this
    this.getter()
    Dep.target = null;
  }
  update() {
    this.get()
  }
}
複製代碼

7.6.6 結果

一個極簡的響應式系統搭建完成。在精簡代碼的同時,保持了源碼設計的思想和邏輯。有了這一步的基礎,接下來深刻分析源碼中每一個環節的實現細節會更加簡單。

7.7 小結

這一節內容,咱們正式進入響應式系統的介紹,前面在數據代理章節,咱們學過Object.defineProperty,這是一個用來進行數據攔截的方法,而響應式系統構建的基礎就是數據的攔截。咱們先介紹了Vue內部在初始化數據的過程,最終得出的結論是,不論是data,computed,仍是其餘的用戶定義數據,最終都是調用Object.defineProperty進行數據攔截。而文章的最後,咱們在保留源碼設計思想和邏輯的前提下,構建出了一個簡化版的響應式系統。完整的功能有助於咱們下一節對源碼具體實現細節的分析和思考。


相關文章
相關標籤/搜索