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

不做祖國的韭菜發表於2019-07-11

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

7.12 陣列檢測

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

7.12.1 陣列方法的重寫

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

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函式,這個函式的具體實現,我們放到陣列的派發更新中介紹。

僅僅建立一個新的陣列方法合集是不夠的,我們在訪問陣列時,如何不呼叫原生的陣列方法,而是將過程指向這個新的類,這是下一步的重點。

回到資料初始化過程,也就是執行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過程,

// __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類的方法。這也是陣列進行依賴收集和派發更新的前提。

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);
      }
    }
  }

複製程式碼

我們可以通過截圖看最終依賴收集的結果。

收集前

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

收集後

深入剖析Vue原始碼 - 響應式系統構建(下)
/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會建立一個依賴,這個依賴在資料改變時會執行回撥。


相關文章