11

官方定义

  • 类型{ [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)

扫描下方的二维码或搜索「tony老师的前端补习班」关注我的微信公众号,那么就可以第一时间收到我的最新文章。


tonychen
1.2k 声望272 粉丝