# $nextTick 的浅析

# nextTick

在下次 DOM 更新循环结束之后执行延迟回调。在修改数据之后立即使用这个方法,获取更新后的 DOM。

由于 Vue 中,修改数据后对应的 DOM 不会立即更新,而是会被推入到一个任务队列中异步执行(这个异步实现就是nextTick),所以当我们想要获取更新后的 DOM 的话一般使用$nextTick来异步获取。对应的代码在src/core/util/next-tick.js里,我们来看下这段代码

export let isUsingMicroTask = false; //标记当前是否是使用 microTask 来实现

const callbacks = [];
let pending = false;

function flushCallbacks() {
  pending = false;
  const copies = callbacks.slice(0);
  callbacks.length = 0;
  for (let i = 0; i < copies.length; i++) {
    copies[i]();
  }
}

let timerFunc;

if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve();
  timerFunc = () => {
    p.then(flushCallbacks);
  };
  isUsingMicroTask = true;
} else if (
  !isIE &&
  typeof MutationObserver !== 'undefined' &&
  (isNative(MutationObserver) ||
    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;
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  timerFunc = () => {
    setImmediate(flushCallbacks);
  };
} else {
  timerFunc = () => {
    setTimeout(flushCallbacks, 0);
  };
}

export function nextTick(cb?: Function, ctx?: Object) {
  let _resolve;
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx);
      } catch (e) {
        handleError(e, ctx, 'nextTick');
      }
    } else if (_resolve) {
      _resolve(ctx);
    }
  });
  if (!pending) {
    pending = true;
    timerFunc();
  }
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise((resolve) => {
      _resolve = resolve;
    });
  }
}

首先定义了callbacks变量来维护待执行的函数,以及一个flushCallbacks方法来循环执行callbacks里面的方法,这个方法会拷贝callbacks变量,防止callbacks在执行过程中的再次被添加入回调函数。

接着定义了timerFunc这是异步方法的具体实现。

  • 如果当前环境支持Promise的话,则将异步执行函数flushCallbacks放入微任务队列中用,Promise.resolve().then()来实现。并将isUsingMicroTask设置为true
  • 接着判断当前浏览器是否支持MutationObserver,如果支持则将异步执行函数作为MutationObserver回调,并创建一个textNode,通过修改textNode.data来实现异步函数,同时将isUsingMicroTask设置为true
  • 接着判断当前环境是否支持setImmediate,支持则使用setImmediate来实现一个宏任务队列
  • 最后使用setTimeout来作为一个兜底的异步实现 这样就完成了对一步函数timerFunc初始化

接着我们看具体的nextTick的实现。首先定义了一个_resolve方法,接着向callbackspush 了一个方法,方法很简单,如果有回调函数则执行回调函数,没有回调函数则执行_resolve方法。接着判断当前异步方法是否在执行中,如果没有执行则执行异步方法timerFunc,因为我们callbacks方法的执行是在timerFunc异步方法的回调里,所以当一轮同步任务执行完时,callbacks里面已经收集了这轮同步任务产生的所有通过nextTick添加进来的异步任务,包括 DOM 更新的Watchr函数。在同步任务结束后,在依次执行callbacks里面的回调函数。因为是依次执行的,如果我们在修改数据前就去获取 DOM 元素是获取不到的,比如:

this.$nextTick(() => {
  //获取 a 属性对应的DOM是获取不到的
});
this.a = 'b';

最后,判断当前环境是否支持Promise并且cb参数为假值,是的话则返回一个Promise实例,并将内部的resolve函数赋值给_resolve变量。这样当我们调用$nextTick并且没有传递回调函数是就可以使用Promise的语法了

# 微任务与事件冒泡

上面的代码有一个全局变量isUsingMicroTask标记了当前nextTick的实现方式是一个微任务还是宏任务,这个变量的作用是什么呢?我们先来看下这样一个例子

new Vue({
    template:`<div @click="flag?onDivClick:()=>{}">
        <button @click="flag = true">Button</button>
    </div>`
    data:{
        flag:false
    },
    methods:{
        onDivClick(){
            console.error(666)
        }
    }
})

当我们点击 button 的时候,onDivClick方法会执行吗?按我们的预期应该是不会执行的,因为 DOM 的更新在微任务队列里,是异步的。然而事实却是onDivClick会执行。因为微任务的执行太快了,比事件的冒泡还要快。所以当事件传递到外层 div 的时候,外层 div 的事件绑定已经更新过了。有什么办法能够规避这个问题呢,其实很简单,只需要比较事件执行的时间与事件挂载的事件,如果发生时间比挂载时间还要早,则不执行这个方法。

事件的挂载时间的定义,在Watcher调度器的src/core/observer/scheduler.js

执行事件与挂载时间的对比相关代码,在 Web 平台的事件绑定相关的代码中src/platforms/web/runtime/modules/events.js

function add(name, handler) {
  if (useMicrotaskFix) { //是否是微任务环境下
    const attachedTimestamp = currentFlushTimestamp;
    const original = handler;
    handler = original._wrapper = function (e) {
      if (e.timeStamp >= attachedTimestamp) { // 比较2者时间大小
        return original.apply(this, arguments);
      }
    };
  }

  target.addEventListener(
    name,
    handler
  );
}

以上是核心的判断代码,在微任务环境下去比较时间的发生时间与挂载时间。而宏任务的优先级比较微任务的低,所以不会出现这样的情况

# 总结

  • nextTick 内部根据执行环境先后尝试使用Promise MutationObserver setImmediatesetTimeout 来实现一个异步调度函数
  • 将一次同步任务产生的所有通过nextTick函数添加的回调放在一个队列里,在异步回调中一次性依次执行完毕
  • nextTick执行环境是微任务时,防止因微任务优先级大于时间冒泡,导致的事件回调提前执行的问题。需要判断DOM回调执行时比较下事件的发生时间与挂载时间