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

上一節,咱們深刻分析了以data,computed爲數據建立響應式系統的過程,並對其中依賴收集和派發更新的過程進行了詳細的分析。然而在使用和分析過程當中依然存在或多或少的問題,這一節咱們將針對這些問題展開分析,最後咱們也會分析一下watch的響應式過程。這篇文章將做爲響應式系統分析的完結篇。react

7.12 數組檢測

在以前介紹數據代理章節,咱們已經詳細介紹過Vue數據代理的技術是利用了Object.defineProperty,Object.defineProperty讓咱們能夠方便的利用存取描述符中的getter/setter來進行數據的監聽,在get,set鉤子中分別作不一樣的操做,達到數據攔截的目的。然而Object.definePropertyget,set方法只能檢測到對象屬性的變化,對於數組的變化(例如插入刪除數組元素等操做),Object.defineProperty卻沒法達到目的,這也是利用Object.defineProperty進行數據監控的缺陷,雖然es6中的proxy能夠完美解決這一問題,但畢竟有兼容性問題,因此咱們還須要研究VueObject.defineProperty的基礎上如何對數組進行監聽檢測。es6

7.12.1 數組方法的重寫

既然數組已經不能再經過數據的getter,setter方法去監聽變化了,Vue的作法是對數組方法進行重寫,在保留原數組功能的前提下,對數組進行額外的操做處理。也就是從新定義了數組方法。web

var arrayProto = Array.prototype;
// 新建一個繼承於Array的對象
var arrayMethods = Object.create(arrayProto);

// 數組擁有的方法
var methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
];
複製代碼

arrayMethods是基於原始Array類爲原型繼承的一個對象類,因爲原型鏈的繼承,arrayMethod擁有數組的全部方法,接下來對這個新的數組類的方法進行改寫。算法

methodsToPatch.forEach(function (method) {
  // 緩衝原始數組的方法
  var original = arrayProto[method];
  // 利用Object.defineProperty對方法的執行進行改寫
  def(arrayMethods, method, function mutator () {});
});

function def (obj, key, val, enumerable) {
    Object.defineProperty(obj, key, {
      value: val,
      enumerable: !!enumerable,
      writable: true,
      configurable: true
    });
  }

複製代碼

這裏對數組方法設置了代理,當執行arrayMethods的數組方法時,會代理執行mutator函數,這個函數的具體實現,咱們放到數組的派發更新中介紹。express

僅僅建立一個新的數組方法合集是不夠的,咱們在訪問數組時,如何不調用原生的數組方法,而是將過程指向這個新的類,這是下一步的重點。api

回到數據初始化過程,也就是執行initData階段,上一篇內容花了大篇幅介紹過數據初始化會爲data數據建立一個Observer類,當時咱們只講述了Observer類會爲每一個非數組的屬性進行數據攔截,從新定義getter,setter方法,除此以外對於數組類型的數據,咱們有意跳過度析了。這裏,咱們重點看看對於數組攔截的處理。數組

var Observer = function Observer (value) {
  this.value = value;
  this.dep = new Dep();
  this.vmCount = 0;
  // 將__ob__屬性設置成不可枚舉屬性。外部沒法經過遍歷獲取。
  def(value, '__ob__', this);
  // 數組處理
  if (Array.isArray(value)) {
    if (hasProto) {
      protoAugment(value, arrayMethods);
    } else {
      copyAugment(value, arrayMethods, arrayKeys);
    }
    this.observeArray(value);
  } else {
  // 對象處理
    this.walk(value);
  }
}
複製代碼

數組處理的分支分爲兩個,hasProto的判斷條件,hasProto用來判斷當前環境下是否支持__proto__屬性。而數組的處理會根據是否支持這一屬性來決定執行protoAugment, copyAugment過程,promise

// __proto__屬性的判斷
var hasProto = '__proto__' in {};
複製代碼

當支持__proto__時,執行protoAugment會將當前數組的原型指向新的數組類arrayMethods,若是不支持__proto__,則經過代理設置,在訪問數組方法時代理訪問新數組類中的數組方法。瀏覽器

//直接經過原型指向的方式

function protoAugment (target, src) {
  target.__proto__ = src;
}

// 經過數據代理的方式
function copyAugment (target, src, keys) {
  for (var i = 0, l = keys.length; i < l; i++) {
    var key = keys[i];
    def(target, key, src[key]);
  }
}
複製代碼

有了這兩步的處理,接下來咱們在實例內部調用push, unshift等數組的方法時,會執行arrayMethods類的方法。這也是數組進行依賴收集和派發更新的前提。markdown

7.12.2 依賴收集

因爲數據初始化階段會利用Object.definePrototype進行數據訪問的改寫,數組的訪問一樣會被getter所攔截。因爲是數組,攔截過程會作特殊處理,後面咱們再看看dependArray的原理。

function defineReactive###1() {
  ···
  var childOb = !shallow && observe(val);

  Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get: function reactiveGetter () {
          var value = getter ? getter.call(obj) : val;
          if (Dep.target) {
            dep.depend();
            if (childOb) {
              childOb.dep.depend();
              if (Array.isArray(value)) {
                dependArray(value);
              }
            }
          }
          return value
        },
        set() {}
}
 
複製代碼

childOb是標誌屬性值是否爲基礎類型的標誌,observe若是遇到基本類型數據,則直接返回,不作任何處理,若是遇到對象或者數組則會遞歸實例化Observer,會爲每一個子屬性設置響應式數據,最終返回Observer實例。而實例化Observer又回到以前的老流程: 添加__ob__屬性,若是遇到數組則進行原型重指向,遇到對象則定義getter,setter,這一過程前面分析過,就再也不闡述。

在訪問到數組時,因爲childOb的存在,會執行childOb.dep.depend();進行依賴收集,該Observer實例的dep屬性會收集當前的watcher做爲依賴保存,dependArray保證了若是數組元素是數組或者對象,須要遞歸去爲內部的元素收集相關的依賴。

function dependArray (value) {
    for (var e = (void 0), i = 0, l = value.length; i < l; i++) {
      e = value[i];
      e && e.__ob__ && e.__ob__.dep.depend();
      if (Array.isArray(e)) {
        dependArray(e);
      }
    }
  }

複製代碼

咱們能夠經過截圖看最終依賴收集的結果。

收集前

收集後

/img/7.2.png)

7.12.3 派發更新

當調用數組的方法去添加或者刪除數據時,數據的setter方法是沒法攔截的,因此咱們惟一能夠攔截的過程就是調用數組方法的時候,前面介紹過,數組方法的調用會代理到新類arrayMethods的方法中,而arrayMethods的數組方法是進行重寫過的。具體咱們看他的定義。

methodsToPatch.forEach(function (method) {
    var original = arrayProto[method];
    def(arrayMethods, method, function mutator () {
      var args = [], len = arguments.length;
      while ( len-- ) args[ len ] = arguments[ len ];
      // 執行原數組方法
      var result = original.apply(this, args);
      var ob = this.__ob__;
      var inserted;
      switch (method) {
        case 'push':
        case 'unshift':
          inserted = args;
          break
        case 'splice':
          inserted = args.slice(2);
          break
      }
      if (inserted) { ob.observeArray(inserted); }
      // notify change
      ob.dep.notify();
      return result
    });
  });

複製代碼

mutator是重寫的數組方法,首先會調用原始的數組方法進行運算,這保證了與原始數組類型的方法一致性,args保存了數組方法調用傳遞的參數。以後取出數組的__ob__也就是以前保存的Observer實例,調用ob.dep.notify();進行依賴的派發更新,前面知道了。Observer實例的depDep的實例,他收集了須要監聽的watcher依賴,而notify會對依賴進行從新計算並更新。具體看Dep.prototype.notify = function notify () {}函數的分析,這裏也不重複贅述。

回到代碼中,inserted變量用來標誌數組是不是增長了元素,若是增長的元素不是原始類型,而是數組對象類型,則須要觸發observeArray方法,對每一個元素進行依賴收集。

Observer.prototype.observeArray = function observeArray (items) {
  for (var i = 0, l = items.length; i < l; i++) {
    observe(items[i]);
  }
};
複製代碼

總的來講。數組的改變不會觸發setter進行依賴更新,因此Vue建立了一個新的數組類,重寫了數組的方法,將數組方法指向了新的數組類。同時在訪問到數組時依舊觸發getter進行依賴收集,在更改數組時,觸發數組新方法運算,並進行依賴的派發。

如今咱們回過頭看看Vue的官方文檔對於數組檢測時的注意事項:

Vue 不能檢測如下數組的變更:

  • 當你利用索引直接設置一個數組項時,例如:vm.items[indexOfItem] = newValue
  • 當你修改數組的長度時,例如:vm.items.length = newLength

顯然有了上述的分析咱們很容易理解數組檢測帶來的弊端,即便Vue重寫了數組的方法,以便在設置數組時進行攔截處理,可是不論是經過索引仍是直接修改長度,都是沒法觸發依賴更新的。

7.13 對象檢測異常

咱們在實際開發中常常遇到一種場景,對象test: { a: 1 }要添加一個屬性b,這時若是咱們使用test.b = 2的方式去添加,這個過程Vue是沒法檢測到的,理由也很簡單。咱們在對對象進行依賴收集的時候,會爲對象的每一個屬性都進行收集依賴,而直接經過test.b添加的新屬性並無依賴收集的過程,所以當以後數據b發生改變時也不會進行依賴的更新。

瞭解決這一問題,Vue提供了Vue.set(object, propertyName, value)的靜態方法和vm.$set(object, propertyName, value)的實例方法,咱們看具體怎麼完成新屬性的依賴收集過程。

Vue.set = set
function set (target, key, val) {
    //target必須爲非空對象
    if (isUndef(target) || isPrimitive(target)
    ) {
      warn(("Cannot set reactive property on undefined, null, or primitive value: " + ((target))));
    }
    // 數組場景,調用重寫的splice方法,對新添加屬性收集依賴。
    if (Array.isArray(target) && isValidArrayIndex(key)) {
      target.length = Math.max(target.length, key);
      target.splice(key, 1, val);
      return val
    }
    // 新增對象的屬性存在時,直接返回新屬性,觸發依賴收集
    if (key in target && !(key in Object.prototype)) {
      target[key] = val;
      return val
    }
    // 拿到目標源的Observer 實例
    var ob = (target).__ob__;
    if (target._isVue || (ob && ob.vmCount)) {
      warn(
        'Avoid adding reactive properties to a Vue instance or its root $data ' +
        'at runtime - declare it upfront in the data option.'
      );
      return val
    }
    // 目標源對象自己不是一個響應式對象,則不須要處理
    if (!ob) {
      target[key] = val;
      return val
    }
    // 手動調用defineReactive,爲新屬性設置getter,setter
    defineReactive###1(ob.value, key, val);
    ob.dep.notify();
    return val
  }
複製代碼

按照分支分爲不一樣的四個處理邏輯:

  1. 目標對象必須爲非空的對象,能夠是數組,不然拋出異常。
  2. 若是目標對象是數組時,調用數組的splice方法,而前面分析數組檢測時,遇到數組新增元素的場景,會調用ob.observeArray(inserted)對數組新增的元素收集依賴。
  3. 新增的屬性值在原對象中已經存在,則手動訪問新的屬性值,這一過程會觸發依賴收集。
  4. 手動定義新屬性的getter,setter方法,並經過notify觸發依賴更新。

7.14 nextTick

在上一節的內容中,咱們說到數據修改時會觸發setter方法進行依賴的派發更新,而更新時會將每一個watcher推到隊列中,等待下一個tick到來時再執行DOM的渲染更新操做。這個就是異步更新的過程。爲了說明異步更新的概念,須要牽扯到瀏覽器的事件循環機制和最優的渲染時機問題。因爲這不是文章的主線,我只用簡單的語言概述。

7.14.1 事件循環機制

  1. 完整的事件循環機制須要瞭解兩種異步隊列:macro-taskmicro-task
  2. macro-task常見的有 setTimeout, setInterval, setImmediate, script腳本, I/O操做,UI渲染
  3. micro-task常見的有 promise, process.nextTick, MutationObserver
  4. 完整事件循環流程爲: 4.1 micro-task空,macro-task隊列只有script腳本,推出macro-taskscript任務執行,腳本執行期間產生的macro-task,micro-task推到對應的隊列中 4.2 執行所有micro-task裏的微任務事件 4.3 執行DOM操做,渲染更新頁面 4.4 執行web worker等相關任務 4.5 循環,取出macro-task中一個宏任務事件執行,重複4的操做。

從上面的流程中咱們能夠發現,最好的渲染過程發生在微任務隊列的執行過程當中,此時他離頁面渲染過程最近,所以咱們能夠藉助微任務隊列來實現異步更新,它可讓複雜批量的運算操做運行在JS層面,而視圖的渲染只關心最終的結果,這大大下降了性能的損耗。

舉一個這一作法好處的例子: 因爲Vue是數據驅動視圖更新渲染,若是咱們在一個操做中重複對一個響應式數據進行計算,例如 在一個循環中執行this.num ++一千次,因爲響應式系統的存在,數據變化觸發settersetter觸發依賴派發更新,更新調用run進行視圖的從新渲染。這一次循環,視圖渲染要執行一千次,很明顯這是很浪費性能的,咱們只須要關注最後第一千次在界面上更新的結果而已。因此利用異步更新顯得格外重要。

7.14.2 基本實現

Vue用一個queue收集依賴的執行,在下次微任務執行的時候統一執行queueWatcherrun操做,與此同時,相同idwatcher不會重複添加到queue中,所以也不會重複執行屢次的視圖渲染。咱們看nextTick的實現。

// 原型上定義的方法
Vue.prototype.$nextTick = function (fn) {
  return nextTick(fn, this)
};
// 構造函數上定義的方法
Vue.nextTick = nextTick;

// 實際的定義
var callbacks = [];
function nextTick (cb, ctx) {
    var _resolve;
    // callbacks是維護微任務的數組。
    callbacks.push(function () {
      if (cb) {
        try {
          cb.call(ctx);
        } catch (e) {
          handleError(e, ctx, 'nextTick');
        }
      } else if (_resolve) {
        _resolve(ctx);
      }
    });
    if (!pending) {
      pending = true;
      // 將維護的隊列推到微任務隊列中維護
      timerFunc();
    }
    // nextTick沒有傳遞參數,且瀏覽器支持Promise,則返回一個promise對象
    if (!cb && typeof Promise !== 'undefined') {
      return new Promise(function (resolve) {
        _resolve = resolve;
      })
    }
  }
複製代碼

nextTick定義爲一個函數,使用方式爲Vue.nextTick( [callback, context] ),當callback通過nextTick封裝後,callback會在下一個tick中執行調用。從實現上,callbacks是一個維護了須要在下一個tick中執行的任務的隊列,它的每一個元素都是須要執行的函數。pending是判斷是否在等待執行微任務隊列的標誌。而timerFunc是真正將任務隊列推到微任務隊列中的函數。咱們看timerFunc的實現。

1.若是瀏覽器執行Promise,那麼默認以Promsie將執行過程推到微任務隊列中。

var timerFunc;

if (typeof Promise !== 'undefined' && isNative(Promise)) {
  var p = Promise.resolve();
  timerFunc = function () {
    p.then(flushCallbacks);
    // 手機端的兼容代碼
    if (isIOS) { setTimeout(noop); }
  };
  // 使用微任務隊列的標誌
  isUsingMicroTask = true;
}
複製代碼

flushCallbacks是異步更新的函數,他會取出callbacks數組的每個任務,執行任務,具體定義以下:

function flushCallbacks () {
  pending = false;
  var copies = callbacks.slice(0);
  // 取出callbacks數組的每個任務,執行任務
  callbacks.length = 0;
  for (var i = 0; i < copies.length; i++) {
    copies[i]();
  }
}
複製代碼

2.不支持promise,支持MutataionObserver

else if (!isIE && typeof MutationObserver !== 'undefined' && (
    isNative(MutationObserver) ||
    // PhantomJS and iOS 7.x
    MutationObserver.toString() === '[object MutationObserverConstructor]'
  )) {
    var counter = 1;
    var observer = new MutationObserver(flushCallbacks);
    var textNode = document.createTextNode(String(counter));
    observer.observe(textNode, {
      characterData: true
    });
    timerFunc = function () {
      counter = (counter + 1) % 2;
      textNode.data = String(counter);
    };
    isUsingMicroTask = true;
  }
複製代碼

3.若是不支持微任務方法,則會使用宏任務方法,setImmediate會先被使用

else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
    // Fallback to setImmediate.
    // Techinically it leverages the (macro) task queue,
    // but it is still a better choice than setTimeout.
    timerFunc = function () {
      setImmediate(flushCallbacks);
    };
  }
複製代碼

4.全部方法都不適合,會使用宏任務方法中的setTimeout

else {
  timerFunc = function () {
    setTimeout(flushCallbacks, 0);
  };
}
複製代碼

nextTick不傳遞任何參數時,能夠做爲一個promise,例如:

nextTick().then(() => {})
複製代碼

7.14.3 使用場景

說了這麼多原理性的東西,回過頭來看看nextTick的使用場景,因爲異步更新的原理,咱們在某一時間改變的數據並不會觸發視圖的更新,而是須要等下一個tick到來時纔會更新視圖,下面是一個典型場景:

<input v-if="show" type="text" ref="myInput">

// js
data() {
  show: false
},
mounted() {
  this.show = true;
  this.$refs.myInput.focus();// 報錯
}
複製代碼

數據改變時,視圖並不會同時改變,所以須要使用nextTick

mounted() {
  this.show = true;
  this.$nextTick(function() {
    this.$refs.myInput.focus();// 正常
  })
}
複製代碼

7.15 watch

到這裏,關於響應式系統的分析大部份內容已經分析完畢,咱們上一節還遺留着一個問題,Vue對用戶手動添加的watch如何進行數據攔截。咱們先看看兩種基本的使用形式。

// watch選項
var vm = new Vue({
  el: '#app',
  data() {
    return {
      num: 12
    }
  },
  watch: {
    num() {}
  }
})
vm.num = 111

// $watch api方式
vm.$watch('num', function() {}, {
  deep: ,
  immediate: ,
})
複製代碼

7.15.1 依賴收集

咱們以watch選項的方式來分析watch的細節,一樣從初始化提及,初始化數據會執行initWatch,initWatch的核心是createWatcher

function initWatch (vm, watch) {
    for (var key in watch) {
      var handler = watch[key];
      // handler能夠是數組的形式,執行多個回調
      if (Array.isArray(handler)) {
        for (var i = 0; i < handler.length; i++) {
          createWatcher(vm, key, handler[i]);
        }
      } else {
        createWatcher(vm, key, handler);
      }
    }
  }

  function createWatcher (vm,expOrFn,handler,options) {
    // 針對watch是對象的形式,此時回調回選項中的handler
    if (isPlainObject(handler)) {
      options = handler;
      handler = handler.handler;
    }
    if (typeof handler === 'string') {
      handler = vm[handler];
    }
    return vm.$watch(expOrFn, handler, options)
  }
複製代碼

不管是選項的形式,仍是api的形式,最終都會調用實例的$watch方法,其中expOrFn是監聽的字符串,handler是監聽的回調函數,options是相關配置。咱們重點看看$watch的實現。

Vue.prototype.$watch = function (expOrFn,cb,options) {
    var vm = this;
    if (isPlainObject(cb)) {
      return createWatcher(vm, expOrFn, cb, options)
    }
    options = options || {};
    options.user = true;
    var watcher = new Watcher(vm, expOrFn, cb, options);
    // 當watch有immediate選項時,當即執行cb方法,即不須要等待屬性變化,馬上執行回調。
    if (options.immediate) {
      try {
        cb.call(vm, watcher.value);
      } catch (error) {
        handleError(error, vm, ("callback for immediate watcher \"" + (watcher.expression) + "\""));
      }
    }
    return function unwatchFn () {
      watcher.teardown();
    }
  };
}
複製代碼

$watch的核心是建立一個user watcher,options.user是當前用戶定義watcher的標誌。若是有immediate屬性,則當即執行回調函數。 而實例化watcher時會執行一次getter求值,這時,user watcher會做爲依賴被數據所收集。這個過程能夠參考data的分析。

var Watcher = function Watcher() {
  ···
  this.value = this.lazy
      ? undefined
      : this.get();
}

Watcher.prototype.get = function get() {
  ···
  try {
    // getter回調函數,觸發依賴收集
    value = this.getter.call(vm, vm);
  } 
}
複製代碼

7.15.2 派發更新

watch派發更新的過程很好理解,數據發生改變時,setter攔截對依賴進行更新,而此前user watcher已經被當成依賴收集了。這個時候依賴的更新就是回調函數的執行。

7.16 小結

這一節是響應式系統構建的完結篇,data,computed如何進行響應式系統設計,這在上一節內容已經詳細分析,這一節針對一些特殊場景作了分析。例如因爲Object.defineProperty自身的缺陷,沒法對數組的新增刪除進行攔截檢測,所以Vue對數組進行了特殊處理,重寫了數組的方法,並在方法中對數據進行攔截。咱們也重點介紹了nextTick的原理,利用瀏覽器的事件循環機制來達到最優的渲染時機。文章的最後補充了watch在響應式設計的原理,用戶自定義的watch會建立一個依賴,這個依賴在數據改變時會執行回調。


相關文章
相關標籤/搜索