Vue源码学习-结合EventLoop看nextTick

Posted by Yeoman on 2018-08-30

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中有两种任务类型,分别是taskmicrotask。对应的,这两种任务对应的队列叫task queuesmicrotask queues

2.1.1 task source

在规范里提到的task source主要有以下这些:

  1. The DOM manipulation task source

比如当一个节点被插入到文档中的时候这是一个非阻塞的事件

  1. The user interaction task source

比如用户点击事件

  1. The networking task source

一些网络请求相关的任务

  1. The history traversal task source

H5的history相关的API,类似history.back()

  1. timer task source

setInterval和setTimeout的callback

2.1.2 microtask source

规范中并没有明确指出哪些任务是属于microtask的,但是我们通常认为有:

  1. promise callbacks

  2. mutation observer callbacks

  1. 一个Event Loop可以有一个或者多个task queue,但是microtask queue只有一个。
  2. 来自相同的task source的任务必须放在一个task queue里面,来自不同task sourcetask可能在不同的task queue

2.2 Processing model

清楚了哪些属于taskmicrotask之后,事件循环的调用机制Processing model用这张图就已经描述得很清楚了,不再列举具体过程。

我们看一段简单的script:

1
2
3
4
5
6
7
8
9
10
11
12
13
console.log('script start');

setTimeout(function() {
console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
console.log('promise1');
}).then(function() {
console.log('promise2');
});

console.log('script end');

实际上’promise’输出会在’timeout’之前,而上图中不是分明说是task先执行么?

国内很多的博客把script的执行也认为是一种task,但是我觉得这样的描述并不是准确的,规范中并没有提及这种task source

然而,在WHATWG规范calling scripts章节中,我注意到这么一段话:

也就是说在每次calling scriptscleanup阶段,并且当前的JS调用栈是空的情况下microtask queue都会被执行。

所以我觉得更准确的说法应该是:

2.2.1 microtask被执行的时机

  1. calling scriptscleanup阶段,并且当前的JS调用栈是空的情况下。

关于JS调用栈必须为空这一点在tasks-microtasks-queues-and-schedules这篇文章中用鼠标点击触发click和调用click函数来演示了区别

  1. 每个task的结束完成之后。

2.3 小结

浏览器端的Event Loop相对好理解一些,只要清楚taskmicrotask的划分,以及理清图中的循环过程就好了,当然还要注意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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const fs = require('fs');

function someAsyncOperation(callback) {
// Assume this takes 95ms to complete
fs.readFile('/Users/yeoman/Desktop/task.key', callback);
}

const timeoutScheduled = Date.now();

setTimeout(() => {
const delay = Date.now() - timeoutScheduled;

console.log(`${delay}ms have passed since I was scheduled`);
}, 4);


// do someAsyncOperation which takes 95 ms to complete
someAsyncOperation(() => {
console.log('read finish')
});

输出的结果有时候是有时候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
2
3
4
5
6
7
setTimeout(() => {
console.log('timeout');
}, 0);

setImmediate(() => {
console.log('immediate');
});

这段代码的输出是不固定的,有时候是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的延迟的,那么有两种情况:

  1. 在调用uv__run_timers函数前,初始化loop的时间大于等于1ms,那么这个setTimeout的回调就会直接执行,然后才是setImmediate
  2. 在调用uv__run_timers函数前,初始化loop的时间小于1ms,那么uv__run_timers第一次执行的时候什么都不会发生,直接进入check阶段,也就是调用setImmediate,然后再循环回timer。
1
2
3
4
5
6
7
8
9
10
const fs = require('fs');

fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});

那么如果我们把代码换成这样呢,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 queuenextTick queue的调用顺序。

文档中提到,process.nextTick()setImmediate()的名字本应该换一下,因为process.nextTick()的作用看起来更像是立即调用,而setImmediate()的调用需要等待下一次循环,但是考虑到兼容,现在不太可能去变了。

Node.js官方建议开发者在任何情况下都使用setImmediate(),因为这样更容易理解和更好的平台兼容。

IE11居然是支持setImmediate()的,惊了个呆

那么process.nextTick这个函数对于Node.js来说到底有什么意义呢?

文档中举了这个一个例子:

1
2
3
const server = net.createServer(() => {}).listen(8080);

server.on('listening', () => {});

这段代码的潜在问题是,当我们调用listen(8080)的时候,这时候端口直接被绑定了,但是这时候'listening'的回调函数还没有绑上去呢,那么如何先完成回调函数的绑定,再触发'listening'呢?这个问题就是通过process.nextTick来实现的。

我们去源码里看看,是否真的如文档所说呢?

我们知道,实际上Node.js在创建Server这个API上暴露给开发者的是http模块,因此我们从这个文件找起,最终定位到net.js的_listen2函数。这个函数在完成端口监听之后,会进行这样的函数调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
defaultTriggerAsyncIdScope(this[async_id_symbol],
process.nextTick,
emitListeningNT,
this);

function emitListeningNT(self) {
// ensure handle hasn't closed
if (self._handle)
self.emit('listening');
}

function defaultTriggerAsyncIdScope(triggerAsyncId, block, ...args) {
if (triggerAsyncId === undefined)
return Reflect.apply(block, null, args);
// CHECK(Number.isSafeInteger(triggerAsyncId))
// CHECK(triggerAsyncId > 0)
const oldDefaultTriggerAsyncId = async_id_fields[kDefaultTriggerAsyncId];
async_id_fields[kDefaultTriggerAsyncId] = triggerAsyncId;

let ret;
try {
ret = Reflect.apply(block, null, args);
} finally {
async_id_fields[kDefaultTriggerAsyncId] = oldDefaultTriggerAsyncId;
}

return ret;
}

可以看出来,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
2
3
4
5
6
7
mounted () {
this.title = 'young';
this.title = 'simple';
this.$nextTick(() => {
this.title = 'naive';
})
}

当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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
export function nextTick (cb?: Function, ctx?: Object) {
let _resolve
// 把入参推到callbacks队列中
callbacks.push(() => {
if (cb) {
try {
cb.call(ctx)
} catch (e) {
handleError(e, ctx, 'nextTick')
}
} else if (_resolve) {
_resolve(ctx)
}
})
// 判断当前是不是处在等待状态
if (!pending) {
pending = true
// 某些特殊情况必须要用macrotask
if (useMacroTask) {
macroTimerFunc()
} else {
// 生成microtask
microTimerFunc()
}
// 这里不管是macroTimerFunc还是microTimerFunc,都会把整个callbacks队列放进microtask queue或者task queue
}
// 处理没有传callback的情况
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}

这个函数的作用就是和我们前面举的例子一样,用来把回调任务都放到microtask queue或者是task queue中执行。

然后我们看下macroTimerFuncmicroTimerFunc分别是怎么来的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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)
}
}

if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
microTimerFunc = () => {
p.then(flushCallbacks)
// 这里尤大注释说明了,iOS有一种特殊的现象就是microtask不会被调用刷新,因此要手动触发一个空的task来调用microtask quque
if (isIOS) setTimeout(noop)
}
} else {
// 降级成宏任务
microTimerFunc = macroTimerFunc
}

我们来总结一下:

  1. 对于macrotask的选择,最高优先级的是setImmediate,前面我们提过这个只在IE中实现了,然后则是MessageChannel,最后才是setTimeout,至于为什么setTimeout是最低优先级的,我觉得一来是因为这个接口在浏览器端有最低4ms的延迟,二来是需要监听timeout,在性能上相比前两种直接调用要差一些。
  2. microtask的选择上,最高优先级的是Promise,如果不支持Promise的话直接降级成宏任务。(以前版本还有MutationObserver的,我查了下PromiseMutationObserver的兼容性差不多,不知道尤大是不是出于这个原因废弃了)

3.3 $nextTick的原理

实际上,我们上面讲的nextTick相关的部分都是Vue内部的nextTick实现,好像还没有提到我们开发中使用$nextTick函数最重要的那个作用呢。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<template>
<p id="title">{{ title }}</p>
</template>

<script>

mounted () {
this.title = 'young';
this.title = 'simple';
this.$nextTick(() => {
console.log(document.getElementById('title').innerText);
// simple
this.title = 'naive';
})
}

</script>

我们再来看这段伪代码,我们在$nextTick是可以拿到最新的DOM的。(实际工作中我们经常会去拿$refs,也是同理的)

也就是说这个$nextTick回调可以保证我们能获取到前面的同步代码执行完之后的最新DOM。其实原理也已经比较清楚了,我们画一张图更清楚地描述这个过程。

  1. 在执行完this.title = 'young';this.title = 'simple';之后,会把flushSchedulerQueue(也就是Watcher的集合)推到nextTick的callback队列中
  2. 生成一个microtask,并且把nextTickcallbacks队列作为这个miscrotask的回调函数
  3. 执行this.$nextTick(() => {})之后,实际就是调用Vue内部的nextTick函数,同样把自己的回调推到nextTickcallbacks队列。但是因为这时候pending参数为true,所以不会生成一个新的microtask。
  4. 执行microtask,首先会执行flushSchedulerQueue里的RenderWatcher,内部会执行patch,更新DOM。
  5. 然后执行我们在$nextTick中传入的回调,注意,这个时候虽然浏览器还没有进行渲染,因为microtask还没有执行结束,但是我们DOM已经在第四步同步更新完了,所以这时候我们可以拿到最新的DOM。
  6. 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
2
3
4
5
6
7
8
9
10
11
12
/**
* 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
})
}

这段注释提到了几个issue,大意是说在连续点击或者事件冒泡的场景下,microtask由于执行时机太早会引出一些问题。因此暴露了一个withMacroTask函数用于在某些场景下强制使用macrotask,比如我们在用v-on去绑定UI事件的时候,Vue内部就会强制使用macrotask

5. 总结

最近在看Vue源码的过程中,越来越发觉结合实际场景去思考一些技术点是很有帮助的。希望上面的内容可以给你提供一些帮助,当然也有可能是我理解不对的(逃

Refrence

WHATWG规范

tasks-microtasks-queues-and-schedules

Node.js文档

深入理解 JavaScript Event Loop

这篇文章的评论