一个Vue.nextTick DEMO 引发的学(血)案

2,385 阅读2分钟

一个Vue.nextTick DEMO 引发的学(血)案

上Demo代码

<div id="example">
    <div ref="test">{{test}}</div>
    <button @click="handleClick">click</button>
</div>
var vm = new Vue({
    el: '#example',
    data: {
        test: 'begin',
    },
    methods: {
        handleClick() {
            this.test = 'end';
            console.log('1')
            setTimeout(() => { // macroTask
                console.log('3')
            }, 0);
            Promise.resolve().then(function() { //microTask
                console.log('promise!')
            })
            this.$nextTick(function () {
                console.log('2')
            })
        }
    }
})

点击按钮后控制台打出什么?

这段代码执行的顺序有人说是1、2、promise、3。也有人说是1、promise、2、3。

那到底是什么呢???

以上的demo涉及两块知识1.js事件循环机制 2.vue中nextTick的实现机制 js事件循环机制是我们今天讨论的这篇文章的基础,但不是重点,市面上介绍event loop的文章很多所以提供以下几篇文章大家自行了解下:

developer.mozilla.org/zh-CN/docs/…

www.ruanyifeng.com/blog/2014/1…

zhuanlan.zhihu.com/p/33058983

什么时候需要用到Vue.nextTick?

移步官方文档api

Vue.nextTick( [callback, context] )
参数:

{Function} [callback]
{Object} [context]

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

// 修改数据
vm.msg = 'Hello'
// DOM 还没有更新
Vue.nextTick(function () {
  // DOM 更新了
})

// 作为一个 Promise 使用 (2.1.0 起新增,详见接下来的提示)
Vue.nextTick()
  .then(function () {
    // DOM 更新了
  })

oh my god! 疑问又来了 用法中写到的“下次DOM更新循环结束”是什么时候?

江湖传说vue官方文档有一个神奇的特点,当你解决一个问题后,反过去去原文档里找,总能找的着。但是假如你没找着解决办法的时候,你也别指望在文档里找到办法。这就是把“什么都有,什么都找不到。”发挥到了一定境界。

言归正传: 请看异步更新队列

ok原来如此! Vue 实现响应式并不是数据发生变化之后 DOM 立即变化,而是按一定的策略进行DOM的更新(参看深入响应式原理)。那么为了在数据变化之后等待 Vue 完成更新 DOM后 搞些与DOM有关的事情,就可以用Vue.nextTick(callback) 。这样回调函数将在 DOM 更新完成后被调用。换句话说:在数据变化后要执行的某个操作,而这个操作需要使用随数据改变而改变的DOM结构的时候要用到Vue.nextTick()

Vue.nextTick到底做了啥

我们来看下vue2.4.4中nextTick的实现(关键点源码中写了注释)

/**
 * Defer a task to execute it asynchronously.
 */
export const nextTick = (function () {
/*首先声明3个变量,callbacks用来存储所有需要执行的回调函数,
pending用来标志是否正在执行回调函数,
timerFunc用来触发执行回调函数。*/
  const callbacks = []
  let pending = false
  let timerFunc

//声明nextTickHandler函数,这个函数用来执行callbacks里存储的所有回调函数。
  function nextTickHandler () {
    pending = false
    const copies = callbacks.slice(0)
    callbacks.length = 0
    for (let i = 0; i < copies.length; i++) {
      copies[i]()
    }
  }

  // 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 if */
  
  //断是否原生支持promise,如果支持,则利用promise来触发执行回调函数;
  if (typeof Promise !== 'undefined' && isNative(Promise)) {
  //立即resolve的 Promise 对象,是在本轮“事件循环”(event loop)的结束时,而不是在下一轮“事件循环”的开始时。
    var p = Promise.resolve()
    var logError = err => { console.error(err) }
    timerFunc = () => {
      p.then(nextTickHandler).catch(logError)
      // 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)
    }
    //如果支持MutationObserver,则实例化一个观察者对象,
    //观察文本节点发生变化时,触发执行所有回调函数。
    //每次调用timerFunc时,会对文本节点进行重新赋值
  } 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
    var counter = 1
    var observer = new MutationObserver(nextTickHandler)
    var textNode = document.createTextNode(String(counter))
    observer.observe(textNode, {
      characterData: true
    })
    timerFunc = () => {
      counter = (counter + 1) % 2
      //用MutationObserver绑定该DOM并指定回调函数,
      //在DOM变化的时候则会触发回调,该回调会进入主线程(比任务队列优先执行)
      textNode.data = String(counter)
    }
  } else {
  //如果都不支持,则利用setTimeout设置延时为0
    // fallback to setTimeout
    /* istanbul ignore next */
    timerFunc = () => {
      setTimeout(nextTickHandler, 0)
    }
  }
//返回一个queueNextTick函数,用来往callbacks里存入回调函数,这里可以支持回调函数和promise两种形式。
  return function queueNextTick (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)
      }
    })
    //判断如果没有在执行回调函数,则调用timerFunc来触发执行回调函数,从而执行用户传入的代码。
    if (!pending) {
      pending = true
      timerFunc()
    }
    if (!cb && typeof Promise !== 'undefined') {
      return new Promise((resolve, reject) => {
        _resolve = resolve
      })
    }
  }
})()

由此vue2.4.4版本执行后就是 1、2、promise、3 为什么 2 在 promise前面 和事件循环有关了看这段代码

 if (typeof Promise !== 'undefined' && isNative(Promise)) {
  //立即resolve的 Promise 对象,是在本轮“事件循环”(event loop)的结束时,而不是在下一轮“事件循环”的开始时。
    var p = Promise.resolve()
    var logError = err => { console.error(err) }
    timerFunc = () => {
      p.then(nextTickHandler).catch(logError)
         if (isIOS) setTimeout(noop)
    }

早已在初始化的时候有个resolve 状态的 promise对象了 在执行nextTick当前相当于在本轮事件循环中直接执行了回调函数

大家已经发现我在上文强调了vue的版本号,那么和版本有什么关系呢?这也是为何会有两种执行顺序的原因。

vue2.5+ 中的nextTick (本文以2.5.21为例)

和之前版本有哪些改动?

1.从Vue 2.5+开始,抽出来了一个单独的文件next-tick.js来执行它。

2.microTask与macroTask

源码中有这么一段注释,以及这几个变量

  // Here we have async deferring wrappers using both microtasks and (macro) tasks.
  // In < 2.4 we used microtasks everywhere, but there are some scenarios where
  // microtasks have too high a priority and fire in between supposedly
  // sequential events (e.g. #4521, #6690) or even between bubbling of the same
  // event (#6566). However, using (macro) tasks everywhere also has subtle problems
  // when state is changed right before repaint (e.g. #6813, out-in transitions).
  // Here we use microtask by default, but expose a way to force (macro) task when
  // needed (e.g. in event handlers attached by v-on).
  var microTimerFunc;
  var macroTimerFunc;
  var useMacroTask = false;

在Vue 2.4之前的版本中,nextTick几乎都是基于microTask实现的,但是由于microTask的执行优先级非常高,在某些场景之下它甚至要比事件冒泡还要快,就会导致一些诡异的问题;但是如果全部都改成macroTask,对一些有重绘和动画的场景也会有性能的影响。所以最终nextTick采取的策略是默认走microTask,对于一些DOM的交互事件,如v-on绑定的事件回调处理函数的处理,会强制走macroTask。

具体做法就是,在Vue执行绑定的DOM事件时,默认会给回调的handler函数调用withMacroTask方法做一层包装,它保证整个回调函数的执行过程中,遇到数据状态的改变,这些改变而导致的视图更新(DOM更新)的任务都会被推到macroTask。 看两个函数

  function add$1 (
    event,
    handler,
    capture,
    passive
  ) {
    handler = withMacroTask(handler);
    target$1.addEventListener(
      event,
      handler,
      supportsPassive
        ? { capture: capture, passive: passive }
        : capture
    );
  }
  
/**
   * Wrap a function so that if any code inside triggers state change,
   * the changes are queued using a (macro) task instead of a microtask.
   * 包装参数fn,让其使用marcotask
   * 这里的fn为我们在事件上绑定的回调函数
 */
  function withMacroTask (fn) {
    return fn._withTask || (fn._withTask = function () {
      useMacroTask = true;
      try {
        return fn.apply(null, arguments)
      } finally {
        useMacroTask = false;    
      }
    })
  }

其实绑定在onclick上的回调函数是在这个函数内以apply的形式触发的,useMacroTask = true ,这才是很关键的东西 什么时候用到了呢,看源码

  function nextTick (cb, ctx) {
    var _resolve;
    callbacks.push(function () {
      if (cb) {
        try {
          cb.call(ctx);
        } catch (e) {
          handleError(e, ctx, 'nextTick');
        }
      } else if (_resolve) {
        _resolve(ctx);
      }
    });
    // 标记位,保证之后如果有this.$nextTick之类的操作不会再次执行以下代码
    if (!pending) {
      pending = true;
      //用微任务还是用宏任务,此例中运行到现在为止Vue的选择是用宏任务
      // 其实我们可以理解成所有用v-on绑定事件所直接产生的数据变化都是采用宏任务的方式
      // 因为我们绑定的回调都经过了withMacroTask的包装,withMacroTask中会使useMacroTask为true
      if (useMacroTask) {
        macroTimerFunc();
      } else {
        microTimerFunc();
      }
    }
    // $flow-disable-line
    if (!cb && typeof Promise !== 'undefined') {
      return new Promise(function (resolve) {
        _resolve = resolve;
      })
    }
  }

ok,如果 useMacroTask == true 会直接执行macroTimerFunc() 强制走(macro)task

那没强制的时候2.5+版本是怎么做的呢?

  if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
    macroTimerFunc = function () {
      setImmediate(flushCallbacks);
    };
  } else if (typeof MessageChannel !== 'undefined' && (
      isNative(MessageChannel) ||
      // PhantomJS
      MessageChannel.toString() === '[object MessageChannelConstructor]'
    )) {
    var channel = new MessageChannel();
    var port = channel.port2;
    channel.port1.onmessage = flushCallbacks;
    macroTimerFunc = function () {
      port.postMessage(1);
    };
  } else {
    /* istanbul ignore next */
    macroTimerFunc = function () {
      setTimeout(flushCallbacks, 0);
    };
  }

  // Determine microtask defer implementation.
  /* istanbul ignore next, $flow-disable-line */
  if (typeof Promise !== 'undefined' && isNative(Promise)) {
    var p = Promise.resolve();
    microTimerFunc = 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);
      }
    };
  } else {
    // fallback to macro
    microTimerFunc = macroTimerFunc;
  }

对于macroTask的执行,Vue优先检测是否支持原生setImmediate(高版本IE和Edge支持),不支持的话再去检测是否支持原生MessageChannel,如果还不支持的话为setTimeout(fn, 0)。对于microTask的执行 用的还是Promise。

废弃MutationObserver使用MessageChannel也是2.5以后带来的变化之一

const channel = new MessageChannel()
const port = channel.port2
channel.port1.onmessage = flushCallbacks
macroTimerFunc = () => {
port.postMessage(1)
}

新建一个MessageChannel对象,该对象通过port1来检测信息,port2发送信息。通过port2的主动postMessage来触发port1的onmessage事件,进而把回调函数flushCallbacks作为macroTask参与事件循环。 可以看到源码中优先使用了MessageChannel,而不是setTimeout。什么要优先MessageChannel创建macroTask而不是setTimeout? HTML5中规定setTimeout的最小时间延迟是4ms,也就是说理想环境下异步回调最快也是4ms才能触发。

Vue使用这么多函数来模拟异步任务,其目的只有一个,就是让回调异步且尽早调用。而MessageChannel的延迟是小于setTimeout的。

总结

所以回到最初的问题vue2.5+版本执行后就是 1、promise、2、3 ; 而vue2.4.4版本执行后就是 1、2、promise、3

到这里这篇文章也就结束了

所以碰到类似这种问题的时候,最好的办法是查看官方文档,并查阅源码,不能死记概念和顺序,因为标准也是会变的。

欢迎大家指正不足之处。