根據除錯工具看Vue原始碼之watch

tonychen發表於2019-03-24

官方定義

  • 型別{ [key: string]: string | Function | Object | Array }

  • 詳細

一個物件,鍵是需要觀察的表示式,值是對應回撥函式。值也可以是方法名,或者包含選項的物件。Vue 例項將會在例項化時呼叫 $watch(),遍歷 watch 物件的每一個屬性。

初次探索

我們的意圖是 —— 監測app這個變數,並在函式中打下一個斷點。
我們期待的是 —— 斷點停下後,呼叫棧中出現相關的函式,提供我們分析watch原理的依據。

抱著上面的意圖以及期待,我們新建一個Vue專案,同時寫入以下程式碼:

created () {
    this.app = 233
},
watch: {
    app (val) {
      debugger
      console.log('val:', val)
    }
}
複製程式碼

重新整理頁面後右邊的呼叫棧顯示如下?:

  • app
  • run
  • flushSchedulerQueue
  • anonymous
  • flushCallbacks
  • timeFunc
  • nextTick
  • queueWatcher
  • update
  • notify
  • reactiveSetter
  • proxySetter
  • created
  • ...

看到需要經過這麼多的呼叫過程,不禁心裡一慌... 然而,如果你理解了上一篇關於computed的文章,你很容易就能知道:

Vue通過對變數進行依賴收集,進而在變數的值變化時進行訊息提醒。最後,依賴該變數的computed最後決定需要重新計算還是使用快取

computedwatch還是有些相似的,所以在看到reactiveSetter的時候,我們心中大概想到,watch一定也利用了依賴收集

為什麼執行了queueWatcher

單看呼叫棧的話,這個watch過程中執行了queueWatcher,這個函式是放在update中的

update的實現?:

/**
 * Subscriber interface.
 * Will be called when a dependency changes.
 */
Watcher.prototype.update = function update () {
  /* istanbul ignore else */
  if (this.lazy) {
    this.dirty = true;
  } else if (this.sync) {
    this.run();
  } else {
    queueWatcher(this);
  }
};
複製程式碼

顯然,queueWatcher函式是否呼叫,取決於這兩個變數:

  • this.lazy
  • this.sync

這兩個變數實際上是在Watcher類裡初始化的,所以在這裡打下斷點,下面直接給出呼叫順序?:

  • initWatch
  • createWatcher
  • Vue.$watch
  • Watcher
initWatch?
function initWatch (vm, watch) {
  // 遍歷watch屬性
  for (var key in watch) {
    var handler = watch[key];
    // 如果是陣列,那麼再遍歷一次
    if (Array.isArray(handler)) {
      for (var i = 0; i < handler.length; i++) {
        // 呼叫createWatcher
        createWatcher(vm, key, handler[i]);
      }
    } else {
      // 同上
      createWatcher(vm, key, handler);
    }
  }
}
複製程式碼
createWatcher?
function createWatcher (
  vm,
  expOrFn,
  handler,
  options
) {
   // 傳值是物件時重新拿一次屬性
  if (isPlainObject(handler)) {
    options = handler;
    handler = handler.handler;
  }
  // 相容字元型別
  if (typeof handler === 'string') {
    handler = vm[handler];
  }
  return vm.$watch(expOrFn, handler, options)
}
複製程式碼
Vue.prototype.$watch?
Vue.prototype.$watch = function (
    expOrFn,
    cb,
    options
  ) {
	var vm = this;
	// 如果傳的cb是物件,那麼再呼叫一次createWatcher
    if (isPlainObject(cb)) {
      return createWatcher(vm, expOrFn, cb, options)
    }
    options = options || {};
	options.user = true;
	// 新建一個Watcher的例項
	var watcher = new Watcher(vm, expOrFn, cb, options);
	// 如果在watch的物件裡設定了immediate為true,那麼立即執行這個它
    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的初始化過程比較簡單,光看上面給的註釋也是足夠清晰的了。當然,前面提到的this.lazythis.sync變數,由於在初始化過程中沒有傳入true值,那麼在update觸發時直接走入了queueWatcher函式

深入研究

queueWatcher的實現

/**
 * Push a watcher into the watcher queue.
 * Jobs with duplicate IDs will be skipped unless it's
 * pushed when the queue is being flushed.
 */
function queueWatcher (watcher) {
  var id = watcher.id;
  // 判斷是否已經在佇列中,防止重複觸發
  if (has[id] == null) {
    has[id] = true;
	// 沒有重新整理佇列的話,直接將wacher塞入佇列中排隊
    if (!flushing) {
      queue.push(watcher);
    } else {
      // if already flushing, splice the watcher based on its id
      // if already past its id, it will be run next immediately.
      // 如果正在重新整理,那麼這個watcher會按照id的排序插入進去
      // 如果已經重新整理了這個watcher,那麼它將會在下次重新整理再次被執行
      var i = queue.length - 1;
      while (i > index && queue[i].id > watcher.id) {
        i--;
      }
      queue.splice(i + 1, 0, watcher);
    }
    // queue the flush
    // 排隊進行重新整理
    if (!waiting) {
      waiting = true;

      // 如果是開發環境,同時配置了async為false,那麼直接呼叫flushSchedulerQueue
      if (process.env.NODE_ENV !== 'production' && !config.async) {
        flushSchedulerQueue();
        return
      }
      // 否則在nextTick裡呼叫flushSchedulerQueue
      nextTick(flushSchedulerQueue);
    }
  }
}
複製程式碼

queueWatcher是一個很重要的函式,從上面的程式碼我們可以提煉出一些關鍵點?

  • watcher.id做去重處理,對於同時觸發queueWatcher的同一個watcher,只push一個進入佇列中
  • 一個非同步重新整理佇列(flashSchedulerQueue)在下一個tick中執行,同時使用waiting變數,避免重複呼叫
  • 如果在重新整理階段觸發了queueWatcher,那麼將它按id順序從小到大的方式插入到佇列中;如果它已經重新整理過了,那麼它將在佇列的下一次呼叫中立即執行
如何理解在重新整理階段觸發queueWatcher的操作?

其實理解這個並不難,我們將斷點打入flushSchedulerQueue中,這裡只列出簡化後的程式碼?

function flushSchedulerQueue () {
  currentFlushTimestamp = getNow();
  flushing = true;
  var watcher, id;

  ...

  for (index = 0; index < queue.length; index++) {
    watcher = queue[index];
    if (watcher.before) {
      watcher.before();
    }
    id = watcher.id;
    has[id] = null;
    watcher.run();
    
    ...
  }

  ...
}
複製程式碼

其中兩個關鍵的變數:

  • fluashing
  • has[id]

都是在watcher.run()之前變化的。這意味著,在對應的watch函式執行前/執行時(此時處於重新整理佇列階段),其他變數都能在這個重新整理階段重新加入到這個重新整理佇列中

最後放上完整的程式碼:

/**
 * Flush both queues and run the watchers.
 */
function flushSchedulerQueue () {
  currentFlushTimestamp = getNow();
  flushing = true;
  var watcher, id;

  // 重新整理之前對佇列做一次排序
  // 這個操作可以保證:
  // 1. 元件都是從父元件更新到子元件(因為父元件總是在子元件之前建立)
  // 2. 一個元件自定義的watchers都是在它的渲染watcher之前執行(因為自定義watchers都是在渲染watchers之前執行(render watcher))
  // 3. 如果一個元件在父元件的watcher執行期間剛好被銷燬,那麼這些watchers都將會被跳過
  queue.sort(function (a, b) { return a.id - b.id; });

  // 不對佇列的長度做快取,因為在重新整理階段還可能會有新的watcher加入到佇列中來
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index];
    if (watcher.before) {
      watcher.before();
    }
    id = watcher.id;
    has[id] = null;
    // 執行watch裡面定義的方法
    watcher.run();
    // 在測試環境下,對可能出現的死迴圈做特殊處理並給出提示
    if (process.env.NODE_ENV !== 'production' && has[id] != null) {
      circular[id] = (circular[id] || 0) + 1;
      if (circular[id] > MAX_UPDATE_COUNT) {
        warn(
          'You may have an infinite update loop ' + (
            watcher.user
              ? ("in watcher with expression \"" + (watcher.expression) + "\"")
              : "in a component render function."
          ),
          watcher.vm
        );
        break
      }
    }
  }

  // 重置狀態前對activatedChildren、queue做一次淺拷貝(備份)
  var activatedQueue = activatedChildren.slice();
  var updatedQueue = queue.slice();

  // 重置定時器的狀態,也就是這個非同步重新整理中的has、waiting、flushing三個變數的狀態
  resetSchedulerState();

  // 呼叫元件的 updated 和 activated 鉤子
  callActivatedHooks(activatedQueue);
  callUpdatedHooks(updatedQueue);

  // deltools 的鉤子
  if (devtools && config.devtools) {
    devtools.emit('flush');
  }
}
複製程式碼

nextTick

非同步重新整理佇列(flushSchedulerQueue)其實是在nextTick中執行的,這裡我們簡單分析下nextTick的實現,具體程式碼如下?

// 兩個引數,一個cb(回撥),一個ctx(上下文物件)
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) { // 如果cb不存在,那麼呼叫_resolve
      _resolve(ctx);
    }
  });
  if (!pending) {
    pending = true;
    timerFunc();
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(function (resolve) {
      _resolve = resolve;
    })
  }
}
複製程式碼

我們看到這裡其實還呼叫了一個timeFunc函式(偷個懶,這段程式碼的註釋就不翻譯了?)?

var timerFunc;

// The nextTick behavior leverages the microtask queue, which can be accessed
// via either native Promise.then or MutationObserver.
// MutationObserver has wider support, however it is seriously bugged in
// UIWebView in iOS >= 9.3.3 when triggered in touch event handlers. It
// completely stops working after triggering a few times... so, if native
// Promise is available, we will use it:
/* istanbul ignore next, $flow-disable-line */
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  var p = Promise.resolve();
  timerFunc = function () {
    p.then(flushCallbacks);
    // In problematic UIWebViews, Promise.then doesn't completely break, but
    // it can get stuck in a weird state where callbacks are pushed into the
    // microtask queue but the queue isn't being flushed, until the browser
    // needs to do some other work, e.g. handle a timer. Therefore we can
    // "force" the microtask queue to be flushed by adding an empty timer.
    if (isIOS) { setTimeout(noop); }
  };
  isUsingMicroTask = true;
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
  isNative(MutationObserver) ||
  // PhantomJS and iOS 7.x
  MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  // Use MutationObserver where native Promise is not available,
  // e.g. PhantomJS, iOS7, Android 4.4
  // (#6466 MutationObserver is unreliable in IE11)
  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;
} 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);
  };
} else {
  // Fallback to setTimeout.
  timerFunc = function () {
    setTimeout(flushCallbacks, 0);
  };
}
複製程式碼

timerFunc的程式碼其實很簡單,無非是做了這些事情:

  • 檢查瀏覽器對於PromiseMutationObserversetImmediate的相容性,並按優先順序從大到小的順序分別選擇
    1. Promise
    2. MutationObserver
    3. setImmediate
    4. setTimeout
  • 在支援Promise / MutationObserver的情況下便可以觸發微任務(microTask),在相容性較差的時候只能使用setImmediate / setTimeout觸發巨集任務(macroTask)

當然,關於巨集任務(macroTask)和微任務(microTask)的概念這裡就不詳細闡述了,我們只要知道,在非同步任務執行過程中,在同一起跑線下,微任務(microTask)的優先順序永遠高於巨集任務(macroTask)。

tips
  1. 全域性檢索其實可以發現nextTick這個方法被繫結在了Vue的原型上?
Vue.prototype.$nextTick = function (fn) {
  return nextTick(fn, this)
};
複製程式碼
  1. nextTick並不能被隨意調起?
if (!pending) {
  pending = true;
  timerFunc();
}
複製程式碼

總結

  • watchcomputed一樣,依託於Vue的響應式系統
  • 對於一個非同步重新整理佇列(flushSchedulerQueue),重新整理前 / 重新整理後都可以有新的watcher進入佇列,當然前提是nextTick執行之前
  • computed不同的是,watch並不是立即執行的,而是在下一個tick裡執行,也就是微任務(microTask) / 巨集任務(macroTask)

相關文章