主线程的执行过程就是一个 tick,而全部的异步结果都是经过 “任务队列” 来调度被调度。 消息队列中存放的是一个个的任务(task)。 规范中规定 task 分为两大类,分别是 macro task 和 micro task,而且每一个 macro task 结束后,都要清空全部的 micro task。node
关于 macro task 和 micro task 的概念,这里不会细讲,简单经过一段代码演示他们的执行顺序:ios
for (macroTask of macroTaskQueue) { // 1. Handle current MACRO-TASK handleMacroTask(); // 2. Handle all MICRO-TASK for (microTask of microTaskQueue) { handleMicroTask(microTask); } }
在浏览器环境中,常见的 macro task 有 setTimeout、MessageChannel、postMessage、setImmediate;常见的 micro task 有 MutationObsever 和 Promise.then。数组
nextTick 的实现单独有一个 JS 文件来维护它,它的源码并很少,总共也就 100 多行。接下来咱们来看一下它的实现,在 src/core/util/next-tick.js 中promise
import { noop } from 'shared/util' import { handleError } from './error' import { isIOS, isNative } from './env' 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]() } } // 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). let microTimerFunc let macroTimerFunc let useMacroTask = false // Determine (macro) task defer implementation. // Technically setImmediate should be the ideal choice, but it's only available // in IE. The only polyfill that consistently queues the callback after all DOM // events triggered in the same loop is by using MessageChannel. /* istanbul ignore if */ if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) { macroTimerFunc = () => { setImmediate(flushCallbacks) } } else if (typeof MessageChannel !== 'undefined' && ( isNative(MessageChannel) || // PhantomJS MessageChannel.toString() === '[object MessageChannelConstructor]' )) { const channel = new MessageChannel() const port = channel.port2 channel.port1.onmessage = flushCallbacks macroTimerFunc = () => { port.postMessage(1) } } else { /* istanbul ignore next */ macroTimerFunc = () => { setTimeout(flushCallbacks, 0) } } // Determine microtask defer implementation. /* istanbul ignore next, $flow-disable-line */ if (typeof Promise !== 'undefined' && isNative(Promise)) { const p = Promise.resolve() microTimerFunc = () => { 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 } /** * Wrap a function so that if any code inside triggers state change, * the changes are queued using a (macro) task instead of a microtask. */ export function withMacroTask (fn: Function): Function { return fn._withTask || (fn._withTask = function () { useMacroTask = true const res = fn.apply(null, arguments) useMacroTask = false return res }) } 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 if (useMacroTask) { macroTimerFunc() } else { microTimerFunc() } } // $flow-disable-line if (!cb && typeof Promise !== 'undefined') { return new Promise(resolve => { _resolve = resolve }) } }
咱们知道任务队列并不是只有一个队列,在 node 中更为复杂,但总的来讲咱们能够将其分为 microtask 和 (macro)task,而且这两个队列的行为还要依据不一样浏览器的具体实现去讨论,这里咱们只讨论被普遍认同和接受的队列执行行为。当调用栈空闲后每次事件循环只会从 (macro)task 中读取一个任务并执行,而在同一次事件循环内会将 microtask 队列中全部的任务所有执行完毕,且要先于 (macro)task。另外 (macro)task 中两个不一样的任务之间可能穿插着UI的重渲染,那么咱们只须要在 microtask 中把全部在UI重渲染以前须要更新的数据所有更新,这样只须要一次重渲染就能获得最新的DOM了。刚好 Vue 是一个数据驱动的框架,若是能在UI重渲染以前更新全部数据状态,这对性能的提高是一个很大的帮助,全部要优先选用 microtask 去更新数据状态而不是 (macro)task,这就是为何不使用 setTimeout 的缘由,由于 setTimeout 会将回调放到 (macro)task 队列中而不是 microtask 队列,因此理论上最优的选择是使用 Promise,当浏览器不支持 Promise 时再降级为 setTimeout。以下是 next-tick.js 文件中的一段代码:浏览器
if (typeof Promise !== 'undefined' && isNative(Promise)) { const p = Promise.resolve() microTimerFunc = () => { 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 }
其中变量 microTimerFunc 定义在文件头部,它的初始值是 undefined,上面的代码中首先检测当前宿主环境是否支持原生的 Promise,若是支持则优先使用 Promise 注册 microtask,作法很简单,首先定义常量 p 它的值是一个当即 resolve 的 Promise 实例对象,接着将变量 microTimerFunc 定义为一个函数,这个函数的执行将会把 flushCallbacks 函数注册为 microtask。另外你们注意这句代码:app
if (isIOS) setTimeout(noop)
注释已经写得很清楚了,这是一个解决怪异问题的变通方法,在一些 UIWebViews 中存在很奇怪的问题,即 microtask 没有被刷新,对于这个问题的解决方案就是让浏览作一些其余的事情好比注册一个 (macro)task 即便这个 (macro)task 什么都不作,这样就可以间接触发 microtask 的刷新。框架
使用 Promise 是最理想的方案,可是若是宿主环境不支持 Promise,咱们就须要降级处理,即注册 (macro)task,这就是 else 语句块内代码所作的事情异步
if (typeof Promise !== 'undefined' && isNative(Promise)) { // 省略... } else { // fallback to macro microTimerFunc = macroTimerFunc }
将 macroTimerFunc 的值赋值给 microTimerFunc。咱们知道 microTimerFunc 用来将 flushCallbacks 函数注册为 microtask,而 macroTimerFunc 则是用来将 flushCallbacks 函数注册为 (macro)task 的,来看下面这段代码:async
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) { macroTimerFunc = () => { setImmediate(flushCallbacks) } } else if (typeof MessageChannel !== 'undefined' && ( isNative(MessageChannel) || // PhantomJS MessageChannel.toString() === '[object MessageChannelConstructor]' )) { const channel = new MessageChannel() const port = channel.port2 channel.port1.onmessage = flushCallbacks macroTimerFunc = () => { port.postMessage(1) } } else { /* istanbul ignore next */ macroTimerFunc = () => { setTimeout(flushCallbacks, 0) } }
若是宿主环境支持原生 setImmediate 函数,则使用 setImmediate 注册 (macro)task,为何首选 setImmediate 呢?这是有缘由的,由于 setImmediate 拥有比 setTimeout 更好的性能,这个问题很好理解,setTimeout 在将回调注册为 (macro)task 以前要不停的作超时检测,而 setImmediate 则不须要,这就是优先选用 setImmediate 的缘由。可是 setImmediate 的缺陷也很明显,就是它的兼容性问题,到目前为止只有IE浏览器实现了它,因此为了兼容非IE浏览器咱们还须要作兼容处理,只不过此时还轮不到 setTimeout 上场,而是使用 MessageChannel:ide
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) { // 省略... } else if (typeof MessageChannel !== 'undefined' && ( isNative(MessageChannel) || // PhantomJS MessageChannel.toString() === '[object MessageChannelConstructor]' )) { > const channel = new MessageChannel() > const port = channel.port2 > channel.port1.onmessage = flushCallbacks > macroTimerFunc = () => { > port.postMessage(1) > } } else { // 省略... }
相信你们应该了解过 Web Workers,实际上 Web Workers 的内部实现就是用到了 MessageChannel,一个 MessageChannel 实例对象拥有两个属性 port1 和 port2,咱们只须要让其中一个 port 监听 onmessage 事件,而后使用另一个 port 的 postMessage 向前一个 port 发送消息便可,这样前一个 port 的 onmessage 回调就会被注册为 (macro)task,因为它也不须要作任何检测工做,因此性能也要优于 setTimeout。总之 macroTimerFunc 函数的做用就是将 flushCallbacks 注册为 (macro)task。
如今是时候仔细看一下 nextTick 函数都作了什么事情了,不过为了更融入理解 nextTick 函数的代码,咱们须要从 $nextTick 方法入手,以下:
export function renderMixin (Vue: Class<Component>) { // 省略... > Vue.prototype.$nextTick = function (fn: Function) { > return nextTick(fn, this) > } // 省略... }
$nextTick 方法只接收一个回调函数做为参数,但在内部调用 nextTick 函数时,除了把回调函数 fn 透传以外,第二个参数是硬编码为当前组件实例对象 this。咱们知道在使用 $nextTick 方法时是能够省略回调函数这个参数的,这时 $nextTick 方法会返回一个 promise 实例对象。这些功能实际上都是由 nextTick 函数提供的,以下是 nextTick 函数的签名:
export function nextTick (cb?: Function, ctx?: Object) { // 省略... }
nextTick 函数接收两个参数,第一个参数是一个回调函数,第二个参数指定一个做用域。下面咱们逐个分析传递回调函数与不传递回调函数这两种使用场景功能的实现,首先咱们来看传递回调函数的状况,那么此时参数 cb 就是回调函数,来看以下代码:
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) } }) // 省略 }
nextTick 函数会在 callbacks 数组中添加一个新的函数,callbacks 数组定义在文件头部:const callbacks = []。注意并非将 cb 回调函数直接添加到 callbacks 数组中,但这个被添加到 callbacks 数组中的函数的执行会间接调用 cb 回调函数,而且能够看到在调用 cb 函数时使用 .call 方法将函数 cb 的做用域设置为 ctx,也就是 nextTick 函数的第二个参数。因此对于 $nextTick 方法来说,传递给 $nextTick 方法的回调函数的做用域就是当前组件实例对象,固然了前提是回调函数不能是箭头函数,其实在平时的使用中,回调函数使用箭头函数也不要紧,只要你可以达到你的目的便可。另外咱们再次强调一遍,此时回调函数并无被执行,当你调用 $nextTick 方法并传递回调函数时,会使用一个新的函数包裹回调函数并将新函数添加到 callbacks 数组中。
咱们继续看 nextTick 函数的代码,以下:
export function nextTick (cb?: Function, ctx?: Object) { // 省略... if (!pending) { pending = true if (useMacroTask) { macroTimerFunc() } else { microTimerFunc() } } // 省略... }
在将回调函数添加到 callbacks 数组以后,会进行一个 if 条件判断,判断变量 pending 的真假,pending 变量也定义在文件头部:let pending = false,它是一个标识,它的真假表明回调队列是否处于等待刷新的状态,初始值是 false 表明回调队列为空不须要等待刷新。假如此时在某个地方调用了 $nextTick 方法,那么 if 语句块内的代码将会被执行,在 if 语句块内优先将变量 pending 的值设置为 true,表明着此时回调队列不为空,正在等待刷新。既然等待刷新,那么固然要刷新回调队列啊,怎么刷新呢?这时就用到了咱们前面讲过的 microTimerFunc 或者 macroTimerFunc 函数,咱们知道这两个函数的做用是将 flushCallbacks 函数分别注册为 microtask 和 (macro)task。可是不管哪一种任务类型,它们都将会等待调用栈清空以后才执行。以下
created () { this.$nextTick(() => { console.log(1) }) this.$nextTick(() => { console.log(2) }) this.$nextTick(() => { console.log(3) }) }
上面的代码中咱们在 created 钩子中连续调用三次 $nextTick 方法,但只有第一次调用 $nextTick 方法时才会执行 microTimerFunc 函数将 flushCallbacks 注册为 microtask,但此时 flushCallbacks 函数并不会执行,由于它要等待接下来的两次 $nextTick 方法的调用语句执行完后才会执行,或者准确的说等待调用栈被清空以后才会执行。也就是说当 flushCallbacks 函数执行的时候,callbacks 回调队列中将包含本次事件循环所收集的全部经过 $nextTick 方法注册的回调,而接下来的任务就是在 flushCallbacks 函数内将这些回调所有执行并清空。以下是 flushCallbacks 函数的源码
function flushCallbacks () { pending = false const copies = callbacks.slice(0) callbacks.length = 0 for (let i = 0; i < copies.length; i++) { copies[i]() } }
很好理解,首先将变量 pending 重置为 false,接着开始执行回调,但须要注意的是在执行 callbacks 队列中的回调函数时并无直接遍历 callbacks 数组,而是使用 copies 常量保存一份 callbacks 的复制,而后遍历 copies 数组,而且在遍历 copies 数组以前将 callbacks 数组清空:callbacks.length = 0。为何要这么作呢?这么作确定是有缘由的,咱们模拟一下整个异步更新的流程就明白了,以下代码:
created () { this.name = 'HcySunYang' this.$nextTick(() => { this.name = 'hcy' this.$nextTick(() => { console.log('第二个 $nextTick') }) }) }
上面代码中咱们在外层 $nextTick 方法的回调函数中再次调用了 $nextTick 方法,理论上外层 $nextTick 方法的回调函数不该该与内层 $nextTick 方法的回调函数在同一个 microtask 任务中被执行,而是两个不一样的 microtask 任务,虽然在结果上看或许没什么差异,但从设计角度就应该这么作。
咱们注意上面代码中咱们修改了两次 name 属性的值(假设它是响应式数据),首先咱们将 name 属性的值修改成字符串 HcySunYang,咱们前面讲过这会致使依赖于 name 属性的渲染函数观察者被添加到 queue 队列中,这个过程是经过调用 src/core/observer/scheduler.js 文件中的 queueWatcher 函数完成的。同时在 queueWatcher 函数内会使用 nextTick 将 flushSchedulerQueue 添加到 callbacks 数组中,因此此时 callbacks 数组以下:
callbacks = [
flushSchedulerQueue // queue = [renderWatcher]]
同时会将 flushCallbacks 函数注册为 microtask,因此此时 microtask 队列以下:
// microtask 队列
[
flushCallbacks]
接着调用了第一个 $nextTick 方法,$nextTick 方法会将其回调函数添加到 callbacks 数组中,那么此时的 callbacks 数组以下:
callbacks = [
flushSchedulerQueue, // queue = [renderWatcher]
() => {
this.name = 'hcy'
this.$nextTick(() => { console.log('第二个 $nextTick') })
}]
接下来主线程处于空闲状态(调用栈清空),开始执行 microtask 队列中的任务,即执行 flushCallbacks 函数,flushCallbacks 函数会按照顺序执行 callbacks 数组中的函数,首先会执行 flushSchedulerQueue 函数,这个函数会遍历 queue 中的全部观察者并从新求值,完成从新渲染(re-render),在完成渲染以后,本次更新队列已经清空,queue 会被重置为空数组,一切状态还原。接着会执行以下函数:
() => {
this.name = 'hcy'
this.$nextTick(() => { console.log('第二个 $nextTick') })
}
这个函数是第一个 $nextTick 方法的回调函数,因为在执行该回调函数以前已经完成了从新渲染,因此该回调函数内的代码是可以访问更新后的DOM的,到目前为止一切都很正常,咱们继续往下看,在该回调函数内再次修改了 name 属性的值为字符串 hcy,这会再次触发响应,一样的会调用 nextTick 函数将 flushSchedulerQueue 添加到 callbacks 数组中,可是因为在执行 flushCallbacks 函数时优先将 pending 的重置为 false,因此 nextTick 函数会将 flushCallbacks 函数注册为一个新的 microtask,此时 microtask 队列将包含两个 flushCallbacks 函数:
// microtask 队列
[
flushCallbacks, // 第一个 flushCallbacks
flushCallbacks // 第二个 flushCallbacks]
怎么样?咱们的目的达到了,如今有两个 microtask 任务。
而另外除了将变量 pending 的值重置为 false 以外,咱们要知道第一个 flushCallbacks 函数遍历的并非 callbacks 自己,而是它的复制品 copies 数组,而且在第一个 flushCallbacks 函数的一开头就清空了 callbacks 数组自己。因此第二个 flushCallbacks 函数的一切流程与第一个 flushCallbacks 是彻底相同。
最后咱们再来说一下,当调用 $nextTick 方法时不传递回调函数时,是如何实现返回 Promise 实例对象的,实现很简单咱们来看一下 nextTick 函数的代码,以下:
export function nextTick (cb?: Function, ctx?: Object) {
let _resolve
// 省略...
// $flow-disable-line
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
如上高亮代码所示,当 nextTick 函数没有接收到 cb 参数时,会检测当前宿主环境是否支持 Promise,若是支持则直接返回一个 Promise 实例对象,而且将 resolve 函数赋值给 _resolve 变量,_resolve 变量声明在 nextTick 函数的顶部。同时再来看以下代码:
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)
}
})
// 省略...
// $flow-disable-line
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
当 flushCallbacks 函数开始执行 callbacks 数组中的函数时,若是没有传递 cb 参数,则直接调用 _resolve 函数,咱们知道这个函数就是返回的 Promise 实例对象的 resolve 函数。这样就实现了 Promise 方式的 $nextTick 方法。
补充知识点,在以前的版本微任务有MutationObserver的实现
MutationObserver是HTML5新增的属性,用于监听DOM修改事件,可以监听到节点的属性、文本内容、子节点等的改动,是一个功能强大的利器,基本用法以下:
//MO基本用法 var observer = new MutationObserver(function(){ //这里是回调函数 console.log('DOM被修改了!'); }); var article = document.querySelector('article'); observer.observer(article);
if (typeof MutationObserver !== 'undefined' && (isNative(MutationObserver) || 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) } }