你真的了解Javascript执行顺序么?

前言

大家都知道,Javascript是单线程、顺序执行的,通过事件循环来处理异步。而且稍有开发经验的同学也知道,利用setTimeoutsetInterval以及Promise可以延时代码的执行。如果在Node.js中,大家会用process.nextTick来让代码在下一个周期执行;或者在Vue中,会利用Vue.nextTick保证DOM全部更新完毕后再执行回调函数。但是,如果他们都放在一起呢?执行顺序又会是怎么样的?

来个样例

聪明的你知道下面代码的执行顺序么?

1
2
3
4
5
6
7
console.log('script start')

setTimeout(() => {
console.log('setTimeout')
}, 0)

console.log('script end')

看到这个问题,有经验的同学会脱口而出:太简单了,会输出如下内容:

1
2
3
script start
script end
setTimeout

因为setTimeout会加入到队列,延时执行。的确没错。那我们再看看下面的例子呢?

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

setTimeout(() => {
console.log('setTimeout')
}, 0)

Promise.resolve().then(() => {
console.log('promise1')
}).then(() => {
console.log('promise2')
})

console.log('script end')

呃,这个问题好像难住了一部分同学,因为他们会有这样的想法:

setTimeoutPromise到底谁先执行呢?听说Promise是异步的,但是setTimeout也是异步的,而且延时为0, 这可怎么好?

想不明白?没关系,先执行下看看结果:

1
2
3
4
5
6

script start
script end
promise1
promise2
setTimeout

结果是不是蛮有意思的?为啥Promise会先执行呢?我尝试着解释下,如果解释的不对,希望各位大牛多多指导。

大家知道,Javascript是基于事件循环(event loop)来处理事件的,用户的一些操作会放到事件队列里面,Javascript引擎会在合适的时候执行队列里面的操作。注意我们这里用到了“合适的时候“这个限定词,是因为Javascript单线程的,如果某段Javascript执行时间过长,那么它会阻塞主线程的执行。所以setTimeout也并不说是一定会精确的执行。

在Javascript引擎里面,队列还分为Task队列(也有人叫做MacroTask)和MicroTask队列,MicroTask会优先于Task执行。比如常见的点击事件、setImmediatesetTimeoutMessageChannel等会放入Task队列,但是Promise以及MutationObserver会放到Microtask队列。同时,Javascript引擎在执行Microtask队列的时候,如果期间又加入了新的Microtask,则该Microtask会加入到之前的Microtask队列的尾部,保证Microtask先于Task队列执行。

这样,大家就清楚了为啥Promise先执行吧,因为它是一个Microtask呀!优先级高,真是没办法 :-)。大家也许会问,优先级高,会高到什么程度呢?我们可以简单量度下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const checkDuration = () => {
const start = Date.now()
let setTimeoutDuration = 0
let promiseDuration = 0

setTimeout(() => {
setTimeoutDuration = Date.now() - start
}, 0)

Promise.resolve().then(() => {
promiseDuration = Date.now() - start
})

setTimeout(() => {
console.log(`setTimeout耗时: ${setTimeoutDuration}`)
console.log(`Promise耗时: ${promiseDuration}`)
}, 100)
}

checkDuration()

我在Chrome的console里面执行多次,会输出:

1
2
setTimeout耗时: 1
Promise耗时: 0
1
2
setTimeout耗时: 4
Promise耗时: 1

当然,如果这个结果不是固定的,测试多次, setTimeout执行大慨在4ms左右,Promise大慨在1ms左右。哈哈,其实就快了3ms,前端同学为了争取这3ms真是不懈努力而且煞费苦心呀,不过真的为他们爱专研的态度点赞!!!

值得说明的是, Vue中Vue.nextTick也利用了该原理来保证在下次DOM更新循环结束之后执行延迟回调。如Vue 2.5.2里面就有这样的代码逻辑:

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
34
35
36
37
38
39
40
41
42
43
44

var microTimerFunc;
var macroTimerFunc;
var useMacroTask = false;

if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
macroTimerFunc = function () {
setImmediate(flushCallbacks);
};
} else if (typeof MessageChannel !== 'undefined' && (
isNative(MessageChannel) ||
// PhantomJS
MessageChannel.toString() === '[object MessageChannelConstructor]'
)) {
var channel = new MessageChannel();
var port = channel.port2;
channel.port1.onmessage = flushCallbacks;
macroTimerFunc = function () {
port.postMessage(1);
};
} else {
/* istanbul ignore next */
macroTimerFunc = function () {
setTimeout(flushCallbacks, 0);
};
}

// Determine MicroTask defer implementation.
/* istanbul ignore next, $flow-disable-line */
if (typeof Promise !== 'undefined' && isNative(Promise)) {
var p = Promise.resolve();
microTimerFunc = 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); }
};
} else {
// fallback to macro
microTimerFunc = macroTimerFunc;
}

在Vue,用MacroTask就是我们上文说的Task。可见执行的时机是:

Task(MacroTask)队列中: setImmediate > MessageChannel > setTimeout
MicroTask队列中: 直接用了Promise,新版本中弃用了MutationObserver,因为其兼容性不好

扯了这么多,大家应该知道原因了吧?

再来个样例

为了巩固大家对MicroTask的列举,我们再看一个例子

1
2
3
<div class="outer">
<div class="inner"></div>
</div>
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
// 获取DOM
const outer = document.querySelector('.outer')
const inner = document.querySelector('.inner')

// 利用MuationObserver监听DOM的变化
new MutationObserver( () => {
console.log('mutate')
}).observe(outer, {
attributes: true
});

// 事件处理
const onClick = () => {
console.log('click')

setTimeout(() => {
console.log('timeout')
}, 0)

Promise.resolve().then(() => {
console.log('promise')
})

outer.setAttribute('data-random', Math.random())
}

// 事件绑定
inner.addEventListener('click', onClick)
outer.addEventListener('click', onClick)

如果我们点击 inneer区域, 输出内容为什么呢? 如果你理解了上文的内容,就会知道输出结果为:

1
2
3
4
5
6
7
8
click
promise
mutate
click
promise
mutate
timeout
timeout

好,今天就分享在这里。下篇文章,我们聊聊Node.js里面的事件。比如上文我们还没提到setImmediate呢?这个东西只在IE里面支持,但是在Node.js里面是支持的,而且Node.js里面还有一个Process.nextTick。下次我们再聊聊。

参考资料

  1. 什么是微任务与宏任务
  2. Vue.nextTick源码阅读
  3. Vue 中如何使用 MutationObserver 做批量处理?
  4. Node.js Event Loop 的理解 Timers,process.nextTick()
  5. what-is-the-event-loop
  6. Process.nextTick 和 setImmediate 的区别?