1. 背景
Vue中的$nextTick
函数算是一个我们开发中出场率比较高的API了,作用是在这个回调中可以保证UI是更新完成之后的内容。
而Event Loop的知识用在工程中的实践并不多见,在$nextTick
中这算是一个典型的例子,因此我觉得结合事件循环来看这块儿的源码是非常合适的。
另一方面,这个函数和Node.js中的process.nextTick
是同名的。我也一直没去梳理过Node.js中的Event Loop,趁着这个机会深入理解下Node.js中的事件循环。
虽然说浏览器(Chrome为例)中和Node.js中都是v8作为JS引擎,但是Event Loop并不包含其中,而是在各自的runtime中实现的,也就是说浏览器端是各大浏览器各自实现,而Node.js的这部分实现在libuv中。
网上很多文章说浏览器端和Node.js中的事件循环类似我是非常不认同的- -,我觉得两者相差挺大的,毕竟是两套实现。
2. Event Loop in browsing contexts
前面说过Event Loop是在各自的runtime中实现的,因为浏览器端的Event Loop规范是定义在HTML规范中的。强烈建议有时间的同学直接多看几遍WHATWG规范以及Jake Archibald的tasks-microtasks-queues-and-schedules。
我会挑一些我认为最重要的点来记录。
2.1 task & microtask
Event Loop中有两种任务类型,分别是task和microtask。对应的,这两种任务对应的队列叫task queues和microtask queues。
2.1.1 task source
在规范里提到的task source主要有以下这些:
- The DOM manipulation task source
比如当一个节点被插入到文档中的时候这是一个非阻塞的事件
- The user interaction task source
比如用户点击事件
- The networking task source
一些网络请求相关的任务
- The history traversal task source
H5的history相关的API,类似history.back()
- timer task source
setInterval和setTimeout的callback
2.1.2 microtask source
规范中并没有明确指出哪些任务是属于microtask的,但是我们通常认为有:
promise callbacks
mutation observer callbacks
- 一个Event Loop可以有一个或者多个task queue,但是microtask queue只有一个。
- 来自相同的task source的任务必须放在一个task queue里面,来自不同task source的task可能在不同的task queue。
2.2 Processing model
清楚了哪些属于task和microtask之后,事件循环的调用机制Processing model用这张图就已经描述得很清楚了,不再列举具体过程。
我们看一段简单的script:
1 | console.log('script start'); |
实际上’promise’输出会在’timeout’之前,而上图中不是分明说是task先执行么?
国内很多的博客把script的执行也认为是一种task,但是我觉得这样的描述并不是准确的,规范中并没有提及这种task source。
然而,在WHATWG规范的calling scripts章节中,我注意到这么一段话:
也就是说在每次calling scripts的cleanup阶段,并且当前的JS调用栈是空的情况下,microtask queue都会被执行。
所以我觉得更准确的说法应该是:
2.2.1 microtask被执行的时机
- 在calling scripts的cleanup阶段,并且当前的JS调用栈是空的情况下。
关于JS调用栈必须为空这一点在tasks-microtasks-queues-and-schedules这篇文章中用鼠标点击触发click和调用click函数来演示了区别
- 每个task的结束完成之后。
2.3 小结
浏览器端的Event Loop相对好理解一些,只要清楚task和microtask的划分,以及理清图中的循环过程就好了,当然还要注意microtask的调用时机。
3. Event Loop in Node.js
如果只是要研究Vue中的nextTick
机制的话,了解上面的浏览器中的Event Loop就够了,可以直接跳过这个章节,看第四章节。
同样的,研究Node.js中的Event Loop机制,我们尽量也要看第一手资料。在Node.js的官方文档中比较详细地描述了Event Loop。
3.1 phase in Event Loop
光是看这张图就觉得和浏览器中的Event Loop不太一样有木有- -我们来看看每个阶段需要做的事情。
3.1.1 timers
这个阶段是用来执行setTimeout()
和setInterval()
这两个timer的callback的阶段。
但是要注意的是,真正控制timer被调用的阶段是在 poll phase,我们往下看就知道了。
3.1.2 pending callbacks
这个阶段会执行某些操作系统的回调,比较TCP链接收到ECONNREFUSED
回调。
3.1.3 idle, prepare
只在系统内部调用,我们并不关心。
3.1.4 poll
轮询阶段可以说是最重要的一个阶段了,这个阶段主要的作用是计算I/O事件需要阻塞的时间,执行I/O事件。
我画了一张图来描述轮询阶段做的事情:
前面在timer phase那里提到了,实际上控制timers阶段执行的是在poll阶段。为什么这么说呢?
我们设想一个场景,我们开启了一个100ms的timer,同时读取了一个文件,文件读取需要200ms。这个时候在poll阶段是会等文件读取完,然后让循环走到timer阶段,还是在timer到达后立即进入check阶段呢?
我写了个demo进行测试:
1 | const fs = require('fs'); |
输出的结果有时候是有时候read finish先输出,有时候是read finish后输出。
也就是说在poll阶段,会一直检查是否有timer到达阈值了,同时也会检查I/O的回调是否加到poll queue中了,如果timer先到达了,则会直接进入timer阶段。如果是I/O操作先完成了,则会先执行I/O的回调再进入timer阶段。
3.1.5 check
这个阶段主要是执行setImmediate
这个函数的回调。
3.1.6 close callbacks
这个阶段会处理一些close回调,比如socket.destroy()
这个函数的回调会在这个阶段进行。
3.2 setImmediate() vs setTimeout()
这两个函数的调用时机在上面已经讲的非常清楚了,但是文档中提到了一个有趣的现象。
1 | setTimeout(() => { |
这段代码的输出是不固定的,有时候是timeout在先,有时候是immediate在先。但是根据我们之前的理解,setTimeout在timer阶段就会执行了,而setImmediate是在check阶段。
这到底是为什么呢?文档中的解释是这样的:
For example, if we run the following script which is not within an I/O cycle (i.e. the main module), the order in which the two timers are executed is non-deterministic, as it is bound by the performance of the process
和性能有关是闹哪样啊喂!这显然不是我们想要的答案。
百思不得其解之后,我感觉应该只能从看源码来理解这个现象了,但是我们知道这块儿代码是属于libuv模块的,用C++写的,翻了翻源码之后差点放弃治疗了- -
直到我看到了cnode上的这篇文章的评论,@hyj1991 非常棒的给出了这段核心代码的注释以及解释这个现象的原因。
首先,在Node.js中,我们给setTimeout
设置0的延迟和1的延迟是一样的,嗯?阮老师明明告诉我们setTimeout
的最小时间是4毫秒呀。
找到Timeout的代码一看,确实在实现上就是最小是1毫秒,具体原因没有去深究。
那么原因很清晰了,实际上我们的setTimeout
是有1ms的延迟的,那么有两种情况:
- 在调用
uv__run_timers
函数前,初始化loop的时间大于等于1ms,那么这个setTimeout的回调就会直接执行,然后才是setImmediate
。 - 在调用
uv__run_timers
函数前,初始化loop的时间小于1ms,那么uv__run_timers
第一次执行的时候什么都不会发生,直接进入check阶段,也就是调用setImmediate,然后再循环回timer。
1 | const fs = require('fs'); |
那么如果我们把代码换成这样呢,setImmediate
的执行顺序还会是变动的么?相信聪明的你心里已经有答案了。
我们知道,readFile
的回调是在poll阶段执行的,那么肯定会先执行check阶段的setImmediate
,然后才是timer阶段的setTimeout
。
写到这里有个感受,如果想要深入Node.js的话,还是得具备一些C++代码的阅读能力的,任重而道远- -。
3.3 process.nextTick
终于写到这个函数了,我们发现,process.nextTick
并不是属于Event Loop的一部分。但是在每一个phase结束之后都会对nextTick queue
进行依次调用。
咦,那我们的microtask呢,怎么到现在还没有提到???
实际上,microtask queue
会跟在nextTick queue
之后进行调用。本来想去啃一下这一块儿的源码的,奈何现阶段能力和精力都有限,留到以后再读。但是找到一个知乎问题,死月大佬的回答从源码角度解释了microtask queue
和nextTick queue
的调用顺序。
文档中提到,process.nextTick()
和setImmediate()
的名字本应该换一下,因为process.nextTick()
的作用看起来更像是立即调用,而setImmediate()
的调用需要等待下一次循环,但是考虑到兼容,现在不太可能去变了。
Node.js官方建议开发者在任何情况下都使用setImmediate()
,因为这样更容易理解和更好的平台兼容。
IE11居然是支持
setImmediate()
的,惊了个呆
那么process.nextTick
这个函数对于Node.js来说到底有什么意义呢?
文档中举了这个一个例子:
1 | const server = net.createServer(() => {}).listen(8080); |
这段代码的潜在问题是,当我们调用listen(8080)
的时候,这时候端口直接被绑定了,但是这时候'listening'
的回调函数还没有绑上去呢,那么如何先完成回调函数的绑定,再触发'listening'
呢?这个问题就是通过process.nextTick
来实现的。
我们去源码里看看,是否真的如文档所说呢?
我们知道,实际上Node.js在创建Server这个API上暴露给开发者的是http
模块,因此我们从这个文件找起,最终定位到net.js的_listen2函数。这个函数在完成端口监听之后,会进行这样的函数调用:
1 | defaultTriggerAsyncIdScope(this[async_id_symbol], |
可以看出来,self.emit('listening');
函数是在 process.nextTick
中进行调用的。而等到这个执行的时候,'listening'
的回调已经绑定完成了。
官方文档果然比大部分文章靠谱多了,诚不欺我(逃
3.4 小结
关于Node.js的事件循环实际上花了挺长的时间去找资料和理解,但是总体来讲还是觉得很值。
相信看完了2,3两节内容并充分消化的同学,再去网上找那些异步函数各种嵌套调用顺序的题目,肯定都能迎刃而解了~
4. Vue的nextTick
前面花了太多的时间来讲Event Loop,终于要回到我们的主题Vue了。
4.1 nextTick的作用
因为这篇文章的重点是结合Event Loop来看Vue的nextTick
。因此我假设读者已经了解Vue的响应式原理。也就是说默认读者是知道下面这段代码实际上发生了什么的(这里的title是响应式的):
1 | mounted () { |
当title的setter被触发之后,对应的RenderWatcher
会调用update
方法,然后调用scheduler.js中的queueWatcher方法把自身加到调度器的队列中,然后执行nextTick(flushSchedulerQueue)
。这里我们先暂且认为nextTick
就是一个异步API,类似于process.nextTick
。那把这个更新操作做成异步的好处是什么呢?
$nextTick就是调用了这个内部的nextTick函数
代码中我们看到,我们先对title进行了两次赋值,假设这时候我们直接同步更新,页面会进行一次渲染。而第二次title再次变化,又会进行一次渲染。显然,第一次的渲染是没有意义的,这对性能来说是极大的浪费。
这个时候我们就想到说,如果我们把更新操作放在setTimeout
中,那这样就会等到同步代码全部调用结束之后再执行更新操作了。(也就是说title的setter会被触发两次,而实际上每一个Watcher都有id,因此第二次在触发的时候因为这个id的RenderWatcher
已经存在了,所以不会被push到queue中)。这样,虽然我们对title进行了两次赋值,但是只会批量执行一次更新,这就是Vue内部nextTick函数的意义。
那么这样是否就完美了呢?
我们知道setTimeout
是会生成一个task,而这个task会在当前的task结束之后在下一次循环中调用。那么就意味着,当前的task执行完成之后,就需要Render UI。然后执行setTimeout
又会再一次Render UI,显然这不是最优的方案。
显然如果用microtask
,比如Promise
来取代setTimeout
是更合适的办法,第二节内容已经讲过,动态添加进来的microtask
会在这次循环中直接执行完毕。这样只会执行一次Render UI。
3.2 nextTick的实现
实际上,Vue内部要比我们考虑得全面,因为他要考虑更多的兼容性,比如Promise
在IE11里就是不兼容的。
我们来看下内部源码的实现:
我们先看入口函数:
1 | export function nextTick (cb?: Function, ctx?: Object) { |
这个函数的作用就是和我们前面举的例子一样,用来把回调任务都放到microtask queue或者是task queue中执行。
然后我们看下macroTimerFunc
和microTimerFunc
分别是怎么来的:
1 | if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) { |
我们来总结一下:
- 对于macrotask的选择,最高优先级的是
setImmediate
,前面我们提过这个只在IE中实现了,然后则是MessageChannel,最后才是setTimeout
,至于为什么setTimeout
是最低优先级的,我觉得一来是因为这个接口在浏览器端有最低4ms的延迟,二来是需要监听timeout,在性能上相比前两种直接调用要差一些。 - 在microtask的选择上,最高优先级的是
Promise
,如果不支持Promise
的话直接降级成宏任务。(以前版本还有MutationObserver
的,我查了下Promise
和MutationObserver
的兼容性差不多,不知道尤大是不是出于这个原因废弃了)
3.3 $nextTick的原理
实际上,我们上面讲的nextTick
相关的部分都是Vue内部的nextTick
实现,好像还没有提到我们开发中使用$nextTick
函数最重要的那个作用呢。
1 | <template> |
我们再来看这段伪代码,我们在$nextTick
是可以拿到最新的DOM的。(实际工作中我们经常会去拿$refs,也是同理的)
也就是说这个$nextTick
回调可以保证我们能获取到前面的同步代码执行完之后的最新DOM。其实原理也已经比较清楚了,我们画一张图更清楚地描述这个过程。
- 在执行完
this.title = 'young';this.title = 'simple';
之后,会把flushSchedulerQueue
(也就是Watcher的集合)推到nextTick的callback队列中 - 生成一个microtask,并且把
nextTick
的callbacks
队列作为这个miscrotask的回调函数 - 执行
this.$nextTick(() => {})
之后,实际就是调用Vue内部的nextTick函数,同样把自己的回调推到nextTick
的callbacks
队列。但是因为这时候pending
参数为true,所以不会生成一个新的microtask。 - 执行microtask,首先会执行
flushSchedulerQueue
里的RenderWatcher
,内部会执行patch,更新DOM。 - 然后执行我们在
$nextTick
中传入的回调,注意,这个时候虽然浏览器还没有进行渲染,因为microtask还没有执行结束,但是我们DOM已经在第四步同步更新完了,所以这时候我们可以拿到最新的DOM。 - microtask执行结束,浏览器渲染。
至此$nextTick
的原理已经完全解释清楚了~
3.4 one more thing
那么是不是microtask就一定优于macrotask呢?在next-tick.js源码中,有这么一段注释,以及这么一段好像没有被用到的代码。
// 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).
1 | /** |
这段注释提到了几个issue,大意是说在连续点击或者事件冒泡的场景下,microtask
由于执行时机太早会引出一些问题。因此暴露了一个withMacroTask
函数用于在某些场景下强制使用macrotask
,比如我们在用v-on
去绑定UI事件的时候,Vue内部就会强制使用macrotask
。
5. 总结
最近在看Vue源码的过程中,越来越发觉结合实际场景去思考一些技术点是很有帮助的。希望上面的内容可以给你提供一些帮助,当然也有可能是我理解不对的(逃
Refrence
tasks-microtasks-queues-and-schedules