深入浅出理解vm.$nextTick

8,782 阅读5分钟

使用场景:

在我们开发项目的时候,总会碰到一些场景:当我们使用vue操作更新dom后,需要对新的dom做一些操作时,但是这个时候,我们往往会获取不到跟新后的DOM.因为这个时候,dom还没有重新渲染,所以我们就要使用vm.$nextTick方法。

用法:

nextTick接受一个回调函数作为参数,它的作用将回调延迟到下次DOM跟新周期之后执行。

methods:{
example:function(){
 //修改数据
 this.message='changed'
//此时dom还没有跟新,不能获取新的数据
 this.$nextTick(function(){
   //dom现在跟新了
   //可以获取新的dom数据,执行操作
   this.doSomeThing()
  })
 }
}

小思考:

在用法中,我们发现,什么是下次DOM更新周期之后执行,具体是什么时候,所以,我们要明白什么是DOM更新周期。 在Vue当中,当视图状态发生变化时,watcher会得到通知,然后触发虚拟DOM的渲染流程,渲染这个操作不是同步的,是异步。Vue中有一个队列,每当渲染时,会将watcher推送这个队列,在下一次事件循环中,让watcher触发渲染流程。

为什么Vue使用异步更新队列?

简单来说,就是提升性能,提升效率。 我们知道Vue2.0使用虚拟dom来进行渲染,变化侦测的通知只发送到组件上,组件上的任意一个变化都会通知到一个watcher上,然后虚拟DOM会对整个组件进行比对(diff算法,以后有时间我会详细研究一下),然后更新DOM.如果在同一轮事件循环中有两个数据发生变化了,那么组件的watcher会收到两次通知,从而进行两次渲染(同步跟新也是两次渲染),事实上我们并不需要渲染这么多次,只需要等所有状态都修改完毕后,一次性将整个组件的DOM渲染到最新即可。

如何解决一次事件循环组件多次状态改变只需要一次渲染更新?

其实很简单,就是将收到的watcher实例加入队列里缓存起来,并且再添加队列之前检查这个队列是否已存在相同watcher。不存在时,才将watcher实例添加到队列中。然后再下一次事件循环中,Vue会让这个队列中的watcher触发渲染并清空队列。这样就保证一次事件循环组件多次状态改变只需要一次渲染更新。

什么是事件循环?

我们知道js是一门单线程非阻塞的脚本语言,意思是执行js代码时,只有一个主线程来处理所有任务。非阻塞是指当代码需要处理异步任务时,主线程会挂起(pending),当异步任务处理完毕,主线程根据一定的规则去执行回调。事实上,当任务执行完毕,js会将这个事件加入一个队列(事件队列)。被放入队列中的事件不会立刻执行其回调,而是当前执行栈中所有任务执行完毕后,主线程会去查找事件队列中是否有任务。
异步任务有两种类型,微任务和宏任务。不同类型的任务会被分配到不同的任务队列中。
执行栈中所有任务执行完毕后,主线程会去查找事件队列中是否有任务,如果存在,依次执行所有队列中的回调,只到为空。然后再去宏任务队列中取出一个事件,把对应的回调加入当前执行栈,当前执行栈中所有任务都执行完毕,检查微任务队列是否有事件。无线循环此过程,叫做事件循环。

常见的微任务

  • Promise.then
  • Object.observe
  • MutationObserver

常见的宏任务

  • setTimeout
  • setInterval
  • setImmediate
  • UI交互事件

在我们使用vm.$nextTick中获取跟新后DOM时,一定要在更改数据的后面使用nextTick注册回调。

methods:{
example:function(){
 //修改数据
 this.message='changed'
//此时dom还没有跟新,不能获取新的数据
 this.$nextTick(function(){
   //dom现在跟新了
   //可以获取新的dom数据,执行操作
   this.doSomeThing()
  })
 }
}

如果是先使用nextTick注册回调,然后修改数据,在微任务队列中先执行使用nextTick注册的回调,然后才执行跟新DOM的回调,所以回调中得不到新的DOM,因为还没有更新。

methods:{
example:function(){
//此时dom还没有跟新,不能获取新的数据
 this.$nextTick(function(){
 //dom没有跟新,不能获取新的dom
   this.doSomeThing()
  })
   //修改数据
 this.message='changed'
 }
}

我们知道,添加微任务队列中的任务执行机制要高于宏任务的执行机制(下面代码必须理解)

methods:{
example:function(){
//先试用setTimeout向宏任务中注册回调
setTimeout(()=>{
//现在DOM已经跟新了,可以获取最新DOM
})
   //然后修改数据
 this.message='changed'
 }
}

setTimeout属于宏任务,使用它注册回调会加入宏任务中,宏任务执行要比微任务晚,所以即便是先注册,也是先跟新DOM后执行setTineout中设置回调。

理解nextTick的作用后,我们以下来介绍实现原理

实现原理剖析:

由于nextTick会将回调添加到任务队列中延迟执行,所以在回调执行之前,如果反复使用nextTick,Vue并不会将回调添加到任务队列中,只会添加一个任务。Vue内部有一个列表来存储nextTick参数中提供的回调,当任务触发时,以此执行列表里的所有回调并清空列表,其代码如下(简易版):

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 microTimeFun
const p=Promise.resolve()
microTimeFun=()=>{
  p.then(flushCallBacks)
}

export function nextTick(cb,ctx){
  callbacks.push(()=>{
    if(cb){
      cb.call(ctx)
    }
  })
  if(!pending){
    pending=true
    microTimeFun()
  }
}

理解相关变量:

  • callbacks:用来存储用户注册的回调函数(获得了更新后DOM所进行的操作)
  • pending:用来标记是否向任务队列添加任务,pending为false,表示任务队列没有nextTIck任务,需要添加nextTick任务,当添加一个nextTick任务时,pending为ture,在回调执行之前还有nextTick时,并不会重复添加任务到任务队列,当回调函数开始执行时,pending为flase,进行新的一轮事件循环。
  • flushCallbacks:就是我们所说的被注册在任务队列中的任务,当这个函数执行,callbacks中所有函数依次执行,然后清空callbacks,并重置pending为false,所以说,一轮事件循环中,flushCallbacks只会执行一次。
  • microTimerFunc:它的作用就是使用Promise.then将flushCallbacks添加到微任务队列中。

下图给出nextTick内部注册流程和执行流程。

官方文档里面还有这么一句话,如果没有提供回调且支持Promise的环境下,则返回一个Promise。也就是说。可以这样使用nextTick

this.$nextTick().then(function(){
    //dom跟新了
})

要实现这个功能,只需要在nextTIck中判断,如果没有提供回调且当前支持Promise,那么返回Promise,并且在callbacks中添加一个函数,当这个函数执行时,执行Promise的resolve,即可,代码如下

  function nextTick (cb, ctx) {
    var _resolve;
    callbacks.push(function () {
     if (cb) {
        cb.call(ctx);
      } else if (_resolve) {
        _resolve(ctx);
      }
    });
    if (!pending) {
      pending = true;
      timerFunc();
    }
    if (!cb && typeof Promise !== 'undefined') {
      return new Promise(function (resolve) {
        _resolve = resolve;
      })
    }
  }

nextTick源码查看

到此,nextTick原理基本上已经讲完了。那我们现在可以看看真正vue中关于nextTick中的源码,大概我们都能理解的过来了,源码如下。

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.
    // Technically 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);
    };
  }

  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);
      }
    });
    if (!pending) {
      pending = true;
      timerFunc();
    }
    // $flow-disable-line
    if (!cb && typeof Promise !== 'undefined') {
      return new Promise(function (resolve) {
        _resolve = resolve;
      })
    }
  }

总结

这篇文章大概花了两天时间才写出来的,充分的参考了<深入浅出vue.js>这本书,充分了理解书上关于vm.$nextTick中的每一句话,同时也对js中的事件循环有了进一步认识,对js运行机制也进一步加深。作为前端小白,不想只局限于调用各种API,更要知道其原理,每天进步一小步。希望大家能多多与我讨论交流。