Vue2源码学习笔记之观察者的执行

先看下同步执行的代码。 如果设置了观察者的 sync 属性为 true 的话,会执行了观察者的 run 方法,执行同步更新。

目录
  1. 同步执行
    1. 单个观察者同步执行
    2. 全局观察者同步执行
  2. 异步队列更新
  3. nextTick
  4. 小结
  5. 参考资料

同步执行#

单个观察者同步执行#

先看下同步执行的代码。

// ! 同步执行,不需要调用 nextTick 执行观察者 -> 用于 watch 自定义的观察者
else if (this.sync) {
  this.run()
}

如果设置了观察者的sync属性为true的话,会执行了观察者的run方法,执行同步更新。

怎么设置sync属性呢?看下面的示例代码。

new Vue({
  watch: {
    someWatch: {
      handler() {
        /* ... */
      },
      sync: true,
    },
  },
});

在我们设置watch选项时,设置一个对象而不是函数,然后设置sync的值。

看下run方法的代码。

run() {
  if (this.active) {
    const value = this.get() // ! 获取值 -> 执行 getter,如果执行的是 updateComponent,返回 undefined
    if (
      value !== this.value ||
      // Deep watchers and watchers on Object/Arrays should fire even
      // when the value is the same, because the value may
      // have mutated.
      isObject(value) ||
      this.deep
    ) {
      // set new value
      const oldValue = this.value // ! 缓存旧值
      this.value = value // ! 旧值更新为新值
      // ! 处理用户定义的观察者,比如 watch $watch
      if (this.user) {
        try {
          this.cb.call(this.vm, value, oldValue) // ! 传入新旧值,执行回调函数更新视图
        } catch (e) {
          handleError(e, this.vm, `callback for watcher "${this.expression}"`)
        }
      } else {
        this.cb.call(this.vm, value, oldValue)
      }
    }
  }
}

判断this.active是否为true,如果为true,说明当前观察者是激活状态,可以执行更新。在我们删除观察者的时候,这个值会为false,表示观察者是非激活状态,不会执行更新。

首先,声明变量value存储调用this.get方法获取到新值,再判断新值和旧值是否不相同,新值是不是一个非null的对象,以及this.deep的值是否为true。如果满足其中的一个条件就会进行下面的逻辑。

声明常量oldValue存储旧值,然后把this.value更新为新值value。然后判断this.user的值是否为true,如果为true说明这个观察者是用户定义的。再调用this.cb函数去更新视图,调用时使用call方法,函数指向观察者所属的实例对象,并传入新值和旧值。

因为由用户定义的观察者,可能会出错,所以这里使用try...catch语句来执行回调函数cb,这样可以更好捕获可能出现的错误。在catch代码块中使用了this.expression正是前面定义的expOrFn函数的字符串转换值,为了更好的显示错误信息。

如果this.userfalse,说明是这个观察者是 Vue 内部定义的,不过还是使用和用户定义的观察一样的方法调用它,只是少了捕获错误的代码。

注意,这里说的观察者执行,指的是观察者的回调函数的调用。观察者是一个实例对象,它是不能执行调用的。

全局观察者同步执行#

上面学习了单个观察者(的回调函数)同步执行过程,其实 Vue 还提供了一个全局配置属性Vue.config.async,默认是true,即默认 Vue 的所有观察者的回调函数都是异步执行的。

设置这个属性的代码在core/config.js文件中。

export default {
  // ...
  /**
   * Perform updates asynchronously. Intended to be used by Vue Test Utils
   * This will significantly reduce performance if set to false.
   */
  async: true,
};

如果在项目的入口文件把Vue.config.async的值设置为false,那么 Vue 的所有的观察者都是同步执行。

Vue 的观察者默认是异步执行的,异步执行的代码在core/observer/scheduler.js文件中。

export function queueWatcher(watcher: Watcher) {
  // ...
  if (!waiting) {
    // ...
    // ! 同步执行,主要用于非生产环境中测试代码
    if (process.env.NODE_ENV !== "production" && !config.async) {
      flushSchedulerQueue(); // ! 直接执行
      return;
    }
    nextTick(flushSchedulerQueue); // ! 使用 nextTick 执行队列的回调函数
  }
}

把观察者放入的一个队列中,然后异步执行队列。如果设置了所有观察者都是同步执行的,那么它是怎么同步执行的呢?答案是按照id的顺序执行的。

看下队列中观察者是怎么执行的,查看flushSchedulerQueue函数的代码。

function flushSchedulerQueue() {
  // ...
  queue.sort((a, b) => a.id - b.id); // 队列中元素升序排序
}

然后再看下订阅器dep是怎么触发依赖,即怎么通知观察者的。

查看notify方法的代码,在core/observer/dep.js文件中。

notify() {
    // stabilize the subscriber list first
    const subs = this.subs.slice()
 
    // ! 同步执行观察者,不是异步队列全部入队后一起执行
    // ! 需要按照顺序来执行,主要用于非生产环境中测试代码
    if (process.env.NODE_ENV !== 'production' && !config.async) {
      // subs aren't sorted in scheduler if not running async
      // we need to sort them now to make sure they fire in correct
      // order
      subs.sort((a, b) => a.id - b.id) // ! id 升序排序
    }
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }

发现这里也是按照id的排序来通知观察者的。排序主要是为了保证观察者按照它创建时的顺序执行,避免错误。

异步队列更新#

如果没有设置观察者的执行模式,默认是异步执行的。

下面看下异步执行的代码,

// ! 异步执行,需要调用 nextTick 执行观察者 => 用于执行自动收集的依赖
else {
  queueWatcher(this) // ! 使用异步队列更新
}

调用queueWatcher函数,并把当前的观察者作为参数传入。

下面看下queueWatcher函数的代码,在core/observer/scheduler.js文件中。

let has: { [key: number]: ?true } = {};
let waiting = false; // ! 等待标识
let flushing = false; // ! 队列是否正在执行更新
const queue: Array<Watcher> = [];
export function queueWatcher(watcher: Watcher) {
  const id = watcher.id;
  if (has[id] == null) {
    has[id] = true;
    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.
      let i = queue.length - 1;
      while (i > index && queue[i].id > watcher.id) {
        i--;
      }
      queue.splice(i + 1, 0, watcher); // ! 从后面开始查找,插入到第一个比它 id 小的元素后面
    }
 
    // ...
  }
}

定义常量id存储观察者的id,然后判断has对象中是否存在key值和这个id相同,has是一个普通的对象。如果不存在,把id作为key存入到has对象中,id对应的值是true

再判断flushing的值是否为false,如果为false,说明队列中的观察者没有在执行,这时候把观察者放入队列尾部。如果flushing的值为true,说明队列中有观察者正在执行,这时候就不能放在队列尾部,而是从队列的后面开始查找观察者,然后把当前观察者插入到第一个比它 id 小的元素后面,也就是按照id的顺序把当前观察者插入到队列的合适的位置中。

什么时候观察者在队列执行时还要入队呢?答案是计算属性观察者,因为计算属性更新值的时机和普通的响应性数据是不一样的。

继续看下面的代码。

// queue the flush
// ! waiting 标识,保证只执行一次
if (!waiting) {
  waiting = true;
 
  // ! 同步执行,主要用于非生产环境中测试代码
  if (process.env.NODE_ENV !== "production" && !config.async) {
    flushSchedulerQueue(); // ! 直接执行
    return;
  }
  nextTick(flushSchedulerQueue); // ! 使用 nextTick 执行队列的回调函数
}

代码中的waiting是一个开关,表示是否需要等待,它的默认值false,表示不需要等待。

waitingfalse时,先设置它为true,这样在执行if代码的过程中,如果函数里面再去调用queueWatcher函数就不会再去执行这个函数的if代码。因为无论递归执行多少次queueWatcher函数,函数里面的if代码只会执行一次。

在非生产环境中,如果设置了全局同步执行,直接调用flushSchedulerQueue函数同步执行队列的观察者。

注意,到这里还是同步执行。先不管flushSchedulerQueue是如何执行队列观察者的。否则使用nextTick函数异步执行flushSchedulerQueue函数,只有到了这里才是真正的异步执行。

nextTick#

在 Vue 的官方文档中,提供Vue.nextTick或者vm.$nextTick这两个 API 供我们异步执行函数。

它们都来自同一个nextTick函数,作用是:在下次 DOM 更新循环结束之后执行延迟回调。在修改数据之后立即使用这个方法,获取更新后的 DOM。

nextTick函数把同步执行的函数变成异步执行。

nextTick(fn);

它的原理和定时器setTimeout是一样的。它在里面也封装了setTimeout函数。

在 Vue 2.6 以前的版本中,nextTick还会明显的区分宏任务和微任务中是怎么处理异步。但是从 2.6 版本开始就只是根据异步接口执行的优先级,按照顺序来尝试执行异步函数。

查看下面的代码,在core/util/next-tick.js文件中。

// ! 回调函数,明面上不分 microtasks 和 (macro)tasks 了,按照下面的优先级来实现
// ! 优先级(性能高低) Promise -> MutationObserver -> setImmediate -> setTimeout
let timerFunc;
 
// ! Promise
if (typeof Promise !== "undefined" && isNative(Promise)) {
  const p = Promise.resolve();
  timerFunc = () => {
    p.then(flushCallbacks);
    if (isIOS) setTimeout(noop);
  };
  isUsingMicroTask = true;
} else if (
  !isIE &&
  typeof MutationObserver !== "undefined" &&
  (isNative(MutationObserver) ||
    // PhantomJS and iOS 7.x
    MutationObserver.toString() === "[object MutationObserverConstructor]")
) {
  let counter = 1;
  const observer = new MutationObserver(flushCallbacks);
  const textNode = document.createTextNode(String(counter));
  observer.observe(textNode, {
    characterData: true,
  });
  timerFunc = () => {
    counter = (counter + 1) % 2;
    textNode.data = String(counter);
  };
  isUsingMicroTask = true;
  // ! setImmediate
} else if (typeof setImmediate !== "undefined" && isNative(setImmediate)) {
  timerFunc = () => {
    setImmediate(flushCallbacks);
  };
} else {
  // Fallback to setTimeout.
  timerFunc = () => {
    setTimeout(flushCallbacks, 0);
  };
}

首先声明变量timerFunc,用来存储异步执行函数的包装函数,包装异步执行的flushCallbacks函数。

按照异步函数的执行的优先级,先尝试使用Promise.resolve。如果不支持,再尝试使用MutationObserver。如果还是不支持,继续尝试使用setImmediate。最后使用setTimeout,这个异步接口基本所有的浏览器都会支持。通过这些异步接口,使flushCallbacks函数异步执行。

其中Promise.resolve接口的性能是最好的,使用它包装的异步函数在执行时会放在浏览器的微任务列表中,这个列表中的异步函数等待时间最短,执行最快。而setTimeout接口的性能是最差的。

看下flushCallbacks函数的代码。

const callbacks = []; // ! 回调函数组成的数组
let pending = false; // ! 队列是否等待执行,false 表示队列为空,不用等待
 
// ! 执行 callbacks 的所有的回调函数
function flushCallbacks() {
  pending = false; // ! 重置 pending
  const copies = callbacks.slice(0); // ! 拷贝副本
  callbacks.length = 0; // ! 清空 callbacks
  // ! 执行拷贝副本的所有回调函数
  for (let i = 0; i < copies.length; i++) {
    copies[i]();
  }
}

flushCallbacks的作用就是按照顺序执行callbacks数组中的所有的回调函数(观察者)。

下面看下导出的nextTick函数的代码。

export function nextTick(cb?: Function, ctx?: Object) {
  let _resolve;
 
  // ! 把 cb 包装成一个函数,并放入 callbacks 数组中
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx);
      } catch (e) {
        handleError(e, ctx, "nextTick");
      }
    } else if (_resolve) {
      _resolve(ctx);
    }
  });
 
  // ! 判断数组是否等待执行
  if (!pending) {
    pending = true;
    timerFunc(); // ! 执行异步函数
  }
 
  // $flow-disable-line
  // ! nextTick 没有传入回调函数时,提供一个 Promise 化的调用
  if (!cb && typeof Promise !== "undefined") {
    return new Promise((resolve) => {
      _resolve = resolve;
    });
  }
}

这个函数接受两个参数,第一个参数是回调函数cb,第二个参数是函数调用时的上下文环境。

声明变量_resolve,在用户使用nextTick时如果没有传入回调函数cb,在可以使用Promise时的环境时,会把变量_resolve赋值为Promiseresolve函数。

如果有回调函数cb,使用try...catch语句包装并执行这个函数,这样不但很好捕获错误,而且在cb没有值时还调用_resolve函数。另外,把这个包装的函数放入到数组callbacks中。

判断队列callbacks是否等待执行,如果pendingfalse时,说明数组中没有回调函数在等待执行,先设置pending为``true,表示数组中已经有回调函数,不用等待了,可以执行,然后调用timerFunc函数,也就是异步执行flushCallbacks`中的回调函数。

然后我们会发现在执行flushCallbacks函数时,一开始是把pending重置为false,因为函数接下来就会执行完所有的回调函数。

function flushCallbacks() {
  pending = false; // ! 重置 pending
  // ...
}

小结#

在一般情况下, Vue 的所有观察者都是异步执行的,只有在测试环境或者其他的特殊场景才让观察者同步执行。另外同步执行时,在队列中都会根据观察者的id进行排序,然后按照顺序执行观察者的回调函数。

参考资料#