Vue2源码解读(六)-nextTick

7,925 阅读6分钟

前篇

上面文章讲到了Watcher && Scheduler,在queueWatcher函数里面会通过下面的方式来调用:

nextTick(flushSchedulerQueue)

nextTick是Vue里面一个比较核心的概念;不过在讲nextTick之前就必须要讲到JavaScript的运行机制和任务队列。

JavaScript的运行机制

众所周知,浏览器的脚本语言是JavaScript,这个语言最大的特点就是单线程,也就是在同一时间只能干一件事情。

为什么是单线程的呢?假定有两个线程,一个操作dom,一个删除dom,岂不就乱套了~

当然为了充分利用CPU,Html5提出了web worker,允许开发人员创建多个线程,但是子线程完全受主线程控制,但是不得操作DOM,这也是遵循了单线程的标准。

单线程呢,也就意味着所有的任务,都需要排队运行,一个任务运行结束后,才会去执行下一个任务;

熟悉JavaScript的开发人员都明白,有异步回调这个概念,也就是说会挂起等待中的任务,去执行下一个任务,等回调回来再去执行被挂起的任务。

综上所述,任务分为两种,一个是同步任务(synchronous,简称sync),一个是异步任务(asynchronous,简称async)。

  • 同步任务指的是,在主线程上面排队执行的任务,一个任务的结束,才能执行下一个任务;
  • 异步任务指的是,不在主线程上面的任务,而是在任务队列中,主线程执行完成后询问任务队列,从任务队列中取的一个任务,放到主线程中执行。 所以简要图示一下,就是这样的:

主进程会不断重复获取步骤,执行完一个qtask,则继续询问qtask任务队列,获取qtask,放到主线程来执行。

只要主线程空了,就会去读取"任务队列",这就是JavaScript的运行机制

任务队列

任务队列,也就是异步任务的队列。分为两种类型的任务:微任务(microtask)和宏任务(macrotask)

宏任务(macrotask):

  • 包括:setTimeout、setInterval、setImmediate、I/O、UI renderingmacrotask事件;
  • 可以理解为浏览器执行完当前宏任务后,在下一个宏任务执行之前,浏览器就会开始进行渲染;
  • 宏任务一般是当前事件循环的最后一个任务,浏览器的ui绘制会插在每个宏任务之间,阻塞宏任务会导致浏览器ui不渲染;
  • 其实也可以把主线程的任务当作第一个宏任务来看待。

微任务(microtask):

  • 包括:Promises(浏览器实现的原生Promise)、MutationObserver、process.nextTick;
  • 浏览器进行ui渲染之前执行的任务,也就是ui渲染是在微任务执行完成后才开始的;
  • 值得注意的是,过多的微任务会阻塞浏览器的渲染;给microtask队列添加过多回调阻塞macrotask队列的任务;
  • 鉴于上面问题,浏览器考虑性能的问题,也会对微任务的数量进行限制;
  • 事件的冒泡行为,也是在微任务后执行,微任务的优先级是最高的; 举个例子:
console.log('main start');

setTimeout(() => {
  console.log('macrotask');
  Promise.resolve().then(() => {
    console.log('microtask 1');
  })
}, 0);

Promise.resolve().then(() => {
  console.log('microtask 2');
  Promise.resolve().then(() => {
    console.log('microtask 3');
  })
})

console.log('main end');

上面模仿了一下微任务(Promise)和宏任务(setTimeout);微任务里面套了个微任务;宏任务里面套了个微任务; 输出如下:

main start
main end
microtask 2
microtask 3
macrotask
microtask 1

可以分析下上面代码的执行顺序:

  • 第一步:先执行的是主线程的代码main start和main end;
  • 第二步:开始执行微任务microtask2,执行microtask2过程中,又添加了一个microtask3的微任务,
  • 第三步:执行完microtask2后,继续从microtask队列中取微任务,发现有刚在执行microtask2过程中放进去的3,取出microtask3来执行microtask3;
  • 第四步:执行完microtask3后,继续从microtask队列中去取微任务,此时微任务队列为空,则去宏任务队列中取任务,取到macrotask;
  • 第五步:执行macrotask,执行的过程中,又往微任务队列存了个microtask1;
  • 第六步:执行完macrotask后,此时一个宏任务执行完成,开始下一轮重复,也就回到了上面的步骤2,微任务队列获取微任务,发现了microtask1;
  • 第七步:执行microtask1,执行完成,程序运行完成。

综上分析任务队列完成

nextTick

经过上面的过程,相信大家都对浏览器的运行机制和任务队列有了足够的了解,也明白了任务队列中任务的执行顺序,接下来咱们看下nextTick的实现。文件位于/src/core/util/next-tick.js,先看下Vue里面任务的代码:

let timerFunc
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) ||
  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
    console.log('counter', counter)
    textNode.data = String(counter)
  }
  isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

上面代码是Vue对timerFun的定义,Vue倾向于微任务,毕竟微任务优先级是最高的,咱们来看下实现:

  • 最优先采用的是Promise,直接使用的也是咱们上面的例子:Promise.resolve().then(flushCallbacks);
  • 如果浏览器不支持原生的Promise,退而求其次,使用浏览器自带的MutationObserver;MutationObserver,它会在指定的DOM发生变化时被调用;Vue的实现方式是创建一个dom节点,通过改变节点的内容,来触发MutationObserver的回调:new MutationObserver(flushCallbacks);
  • 如果浏览器也不支持MutationObserver,那没办法了,只能使用宏任务了setImmediate和setTimeout,这两个在Vue里面使用方式是一样的,两者的执行顺序在无I/O的时候说不准,不过在有I/O的时候setImmediate是会先被执行的,这可能也是Vue先考虑使用setImmediate的原因吧。

来看下nextTick的代码吧:

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) {
      console.log('_resolve')
      _resolve(ctx)
    }
  })
  if (!pending) {
    pending = true
    timerFunc()
  }
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

上面部分代码,会对传进来的cb进行存储,放到全局变量callbacks里面,然后判断当前执行的状态,是否属于pending(类似Promise的pending状态)状态,可以理解为忙着呢,如果不忙,就让它忙起来,执行上面部分讲到的timerFun;这部分也就是咱们经常用到的

$nextTick(function() {
	dosomething....
})

下面咱们来看下调用timerFun后,执行的flushCallbacks;

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

执行开始,置为不忙状态,因为浏览器是单线程的,执行这段代码的时候,就不会执行别的代码,不用担心这时候会有别的事情影响此处代码的执行,也不用担心此时会有nextTick的调用,也就不用担心pending状态的此处改变会不会影响nextTick部分的逻辑;

此处代码很简单,获取回调的拷贝,然后把回调栈清空;依次执行回调。

结语

nextTick本章讲完,篇幅较少,不过之所以把nextTick拿出来单独讲,主要是因为涉及到宏任务和微任务,对此处有疑惑的读者可以评论或私信。