解析Vue.nextTick

不知啥时候起,对nextTick的理解出现了偏差,感觉变成了setTimeout一样的延时操作了,好像就一定会在代码执行完成之后执行。有多少人和我一样,当需要等到dom渲染完成之后进行js操作时,就想着使用nextTick。殊不知,离真相越来越远,现在,让我们来梳理一下它的真正用法。

image.png

先来看下官网的介绍:在下次 DOM更新循环结束之后执行延迟回调。在修改数据之后立即使用这个方法,获取更新后的DOM。敲黑板划重点,DOM更新之后执行延迟回调,它确实是延迟回调,但是是涉及到DOM更新之后才会执行的。
我们先来欣赏一下nextTick的源码。

/**
 * Defer a task to execute it asynchronously.
 * 异步更新队列
 */
var nextTick = (function() {
    var callbacks = [];
    var pending = false; // 标记位,是否在执行回调函数
    var timerFunc; // 延时操作

    function nextTickHandler() {
        pending = false;  
        var copies = callbacks.slice(0);
        callbacks.length = 0;
        for (var i = 0; i < copies.length; i++) {
            copies[i]();
        }
    }
    // 只要观察到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据改变。
    // 如果同一个 watcher 被多次触发,只会被推入到队列中一次。
    // 这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作上非常重要。
    // 然后,在下一个的事件循环“tick”中,Vue 刷新队列并执行实际 (已去重的) 工作。
    // Vue 在内部尝试对异步队列使用原生的 Promise.then 和 MessageChannel,如果执行环境不支持,会采用 setTimeout(fn, 0) 代替。
     if (typeof Promise !== 'undefined' && isNative(Promise)) {
      var p = Promise.resolve()
      var logError = err => { console.error(err) }
      timerFunc = () => {
      p.then(nextTickHandler).catch(logError)
      if (isIOS) setTimeout(noop)
    }
  } else if (!isIE && typeof MutationObserver !== 'undefined' && (
    isNative(MutationObserver) ||
    // PhantomJS and iOS 7.x
    MutationObserver.toString() === '[object MutationObserverConstructor]'
  )) {
    var counter = 1
    var observer = new MutationObserver(nextTickHandler)
    var textNode = document.createTextNode(String(counter))
    observer.observe(textNode, {
      characterData: true
    })
    timerFunc = () => {
      counter = (counter + 1) % 2
      textNode.data = String(counter)
    }
  } else {
    // fallback to setTimeout
    /* istanbul ignore next */
    timerFunc = () => {
      setTimeout(nextTickHandler, 0)
    }
  }

    return function queueNextTick(cb, ctx) {
        var _resolve;
        callbacks.push(function() {
            if (cb) {
                try {
                    cb.call(ctx);
                } catch (e) {
                    handleError(e, ctx, 'nextTick');
                }
            } else if (_resolve) {
                _resolve(ctx);
            }
        });
        if (!pending) {
            pending = true;
            timerFunc();
        }
    }
})();

Vue.prototype.$nextTick = function(fn) {
    return nextTick(fn, this)
};

从源码我们能得到下面这张图,主要的关键就在于timeFunc方法中。

image.png

timeFunc()一共有三种实现方式:

  • promise
  • MutationObserver
  • setTimeout

promisesetTimeout很好理解,都是异步任务,在同步任务执行完以及更新DOM的异步任务之后再执行。MutationObserver则是h5的新标准,用来监视DOM变动的接口,它能监听一个DOM对象上发生的子节点删除、属性修改、文本内容修改等等。

理解了源码,也就理解了官网的描述,我们来分析一下nextTick的使用场景。

场景一:
生命周期created里要进行dom操作时需使用nextTick。原因是createddom还未渲染

场景二:
js更改了视图后,想基于新的视图进行操作时,需要将操作代码放在nextTick

场景三:
使用其他插件时,希望在dom动态改变后重新应用该插件,也需要在nextTick中重新应用该插件。如使用better-scroll组件,组件内部数据刷新了,高度变化了,这时我们需要在nextTick中主动调用它的refresh方法以重新计算高度并流畅滚动。

。。。

那么是不是nextTick就是万能的呢?它一定会在dom执行完成之后执行?也不一定。

比如下面这个场景:

当存在折叠面板时,我们经常会使用watch去监听绑定的值,然后希望绑定的值改变时,动态去改变scroll的高度,这时候我们想用nextTick去实现在dom更新完成之后触发refresh,你会发现,不行。。。why ??? 黑人问号脸。。。

watch:{
    activeName(newVal, oldVal){
      console.log(document.getElementById('scroll').scrollHeight, '---')
      this.$nextTick(() => {
        console.log(document.getElementById('scroll').scrollHeight, '---')
        console.log('nextTick', new Date().getTime())
        this.$refs.scroll.refresh();
        console.log(document.getElementById('scroll').scrollHeight, '===')
        console.log('nextTick之后', new Date().getTime())
    })
    }
  },

我们从打印的结果会发现,nextTick里的scrollHeight高度并不为真实高度。那么这是为什么呢?是nextTick的问题,还是watch的问题?我比较了一下,既不是watch的问题,也不是nextTick的问题,而是我们watch的对象并不是真实dom所关联的数据,它改变了,还要驱动着其他真正控制着dom改变的数据,这时候,nextTick就失效了。如果我们监听的是真正控制dom的对象,我们会发现我们在nextTick中得到的是准确的dom渲染完成后的。那这时,要解决这种监听并不直接操作domdata时,我们只能通过 setTimeout来实现dom渲染完成后的操作了,并且dom渲染也需要一定的时间。

还有些关于nextTick在父子组件间的更新机制,推荐看另外一篇文章,写的挺好。https://www.jianshu.com/p/cd299e4d0221