深入剖析Vue原始碼 - 響應式系統構建(上)

不做祖國的韭菜發表於2019-06-17

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

7.1 資料初始化

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

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函式的屬性值。**因此需要同時對這兩種方式進行校驗。

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>

7.2.2 響應式資料props

剛才說到分析props需要兩個過程,前面已經針對父元件對props的處理做了描述,而對於子元件而言,我們是通過props選項去接收父元件傳遞的值。我們再看看子元件對props的處理:

子元件處理props的過程,是發生在父元件_update階段,這個階段是Vnode生成真實節點的過程,期間會遇到子Vnode,這時會呼叫createComponent去例項化子元件。而例項化子元件的過程又回到了_init初始化,此時又會經歷選項的合併,針對props選項,最終會統一成{props: { test: { type: null }}}的寫法。接著會呼叫initProps, initProps做的事情,簡單概括一句話就是,將元件的props資料設定為響應式資料。

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進行資料攔截。而文章的最後,我們在保留原始碼設計思想和邏輯的前提下,構建出了一個簡化版的響應式系統。完整的功能有助於我們下一節對原始碼具體實現細節的分析和思考。


相關文章