最近看了死月的 趣学 Node.js 小册,关于宏任务、微任务部分突然意识到所谓的执行顺序其实就是底层 C++
写的各种代码的结果,当了解了 Node.js
代码或者 V8
代码再看这些问题真的就是降维打击(当然我只是有了这个感觉,还没细看过[旺柴])。
但如果平常用不到,我们也没必要真的去看底层的代码,即使不了解底层代码,我们也可以根据具体的表现来自己定一些规则进行理解,只要根据这个规则来判断执行顺序是正确的,能指导平常开发也就足够了。
这篇文章只讲基本的概念,不进行深入,能够判断
setTimemout
、Promise
的执行顺序即可。
众所周知,JavaScript
单线程执行的,所以对于一些耗时的任务,我们可以将其丢入任务队列当中,这样一来,也就不会阻碍其他同步代码的执行。等到异步任务完成之后,再去进行相关逻辑的操作。
js
在主线程中执行的顺序:宏任务 -> 宏任务 -> 宏任务 …
在每一个宏任务中又可以产生微任务,当微任务全部执行结束后执行下一个宏任务。
【宏任务 [微任务]】 -> 【宏任务 [微任务]】-> 【宏任务 [微任务]】…
宏任务
生成方法:
- 用户交互:用户在页面上进行交互操作(例如点击、滚动、输入等),会触发浏览器产生宏任务来响应用户操作。
- 网络请求:当浏览器发起网络请求(例如通过
Ajax
、Fetch
、WebSocket
等方式)时,会产生宏任务来处理请求和响应。 - 定时器:通过
JavaScript
宿主环境提供的定时器函数(例如setTimeout
、setInterval
)可以设置一定的时间后产生宏任务执行对应的回调函数。 DOM
变化:当DOM
元素发生变化时(例如节点的添加、删除、属性的修改等),会产生宏任务来更新页面。- 跨窗口通信:在浏览器中,跨窗口通信(例如通过
postMessage
实现)会产生宏任务来处理通信消息。 JavaScript
脚本执行事件;比如页面引入的script
就是一个宏任务。
重点来看下 setTimeout
。
1 | setTimeout(() => { |
以上代码会输出什么?
什么都不会输出
上边代码相当于两个宏任务:
第一个宏任务就是上边的整个脚本
第二个宏任务是 setTimeout 传入的这个函数
1 | () => { |
第一个宏任务执行到 while true
的时候死循环了,所以自己的 console.log('end here')
不会执行。
第二个宏任务也没有机会执行到。
因此什么都不会输出。
再来看一个:
1 | const t1 = new Date() |
t1
记录开始的时间,设置一个 100
毫秒执行的定时器,定时器中输出执行当前任务的时间。
那么 console.log('t3 - t1 =', t3 - t1)
输出的是多少呢?
输出答案是 200
。
同样的,上边是两个宏任务。
整个脚本是第一个宏任务。
计时器生成了第二个宏任务。
只有第一个宏任务执行结束后才会执行第二个宏任务。
所以即使定时器时间到了也不会立刻执行,只有当第一个宏任务执行结束后才会去执行定时器的任务,此时已经过去了 200
毫秒。
微任务
生成方法:
Promise
:Promise
是一种异步编程的解决方案,它可以将异步操作封装成一个Promise
对象,通过then
方法注册回调函数,当promise
变为resolve
或者reject
会将回调函数加入微任务队列中。MutationObserver
:MutationObserver
是一种可以观察DOM
变化的API
,通过监听DOM
变化事件并注册回调函数,将回调函数加入微任务队列中。process.nextTick
:process.nextTick
是Node.js
中的一个API
,它可以将一个回调函数加入微任务队列中。
重点看 Promise
的使用,关于 Promise
怎么用这里不细说了,重点放到输出顺序上。
1 | const r = new Promise(function(resolve, reject){ |
上边的输出什么:
比较基础的使用。输出 1 3 2
。
new Promise
接受一个函数,返回一个 Promise
对象。值得注意的一点是传给 Promise
的那个函数会直接执行。所以会先输出 1
。
Promise
对象拥有一个 then
方法来注册回调函数,当 promise reslove 或者 reject
后会将注册函数加到微任务队列。
上边的代码因为是直接 resolve
了,所以会将 () => console.log("2")
注册到微任务队列中。
宏任务执行完毕后开始执行微任务,所以最后输出 2
。
再看下 async
和 await
:
1 | async function method() { |
上边的会输出什么呢?
先输出 2
,再输出 1
。
这里需要明确一点,async
修饰的函数,相当于给当前函数包了一层 Promise。
所以
1 | function main() { |
相当于
1 | function main() { |
结合前边说的传给 Promise 的那个函数会直接执行。
所以先执行 resolve(method())
,进入method
内部:
接下来是 await
的作用:遇到 await
会先执行 await
右边的逻辑,执行完之后会暂停到这里。跳出当前函数去执行之前的代码。
所以 method()
方法中,
1 | async function method() { |
先执行了 method2
,当 method2
返回了 Promise
后就会暂定执行,跳回 main
函数。
1 | function main() { |
main
函数执行完毕后才会再回到 method
方法中。
所以先输出 2
,后输出 1
。
如果想要先输出 1 再输出 2 需要怎么改呢?
1 | async function method() { |
再看一个:
1 | async function method() { |
上边的会输出什么呢?
当 main
函数执行结束后,按照之前说的应该是回到 await
那里,所以应该输出3 2 1
吗?
其实是不对的,await
还有一个特性,它会把后边执行的代码整个注册为回调函数,相当于放到了 .then 里边,如果 Promise
直接 resolve
,相当于将后边的代码放到了微任务队列中。
所以
1 | async function method() { |
等价于:
1 | async function method() { |
在 await
之前已经有一个 Promise
把任务加到了微任务队列中。所以正确的输出顺序是 3 1 2
。
所以回到 await 继续执行其实是表象,本质上是从微任务队列中把之前要执行的代码取了出来继续执行。
如果想输出 3 2 1
,该怎么改代码呢?
可以将 new Promise((resolve) => resolve()).then(() => console.log(1));
这句中的 reslove()
函数延迟调用,通过 setTimeout
放到下一个宏任务中执行。
1 | async function method() { |
综合
如果理解了上边的,下边的内容就简单了,首先明确几个点:
- 【宏任务 [微任务]】 -> 【宏任务 [微任务]】-> 【宏任务 [微任务]】…
当宏任务和当前宏任务产生的微任务全部执行完毕后,才会执行下一个宏任务。每遇到生成的微任务就放到微任务队列中,当前宏任 务代码全部执行后开始执行微任务队列中的任务
- 传给
new Promise
的函数会直接执行 async
包装的函数相当于包了一层Promise
,因此返回的一定是一个Promise
- 执行到
await
,先执行await
右边的东西,执行完后后会暂停在await
这里,并且把后边的内容丢到then
中(再结合第5
点)。跳到外边接着执行。外边都执行完之后开始执行微任务队列 - 当
promise
变为resolve
或者reject
的时候才会将then
中注册的回调函数加入微任务队列中 setTimeout
产生宏任务
可以多读几遍下边开始正式练习,看代码的时候函数定义直接跳过,从执行函数开始看
练习
来一道魔鬼题:
1 | async function method() { |
上边的代码输出什么?
分析的时候我们需要明确什么时候产生了宏任务,什么时候产生了微任务,什么时候是直接执行的,结合上边总结 6
句话和注释可以看一下:
1 | async function method() { |
总
当然上边的规则也不是黄金原则,归根到底还依赖于我们运行的环境是什么,现在 js
的运行时有 V8
、Node.js
等,它们也有各自的版本。
对于下边的代码:
1 | const p = Promise.resolve(); |
按照之前规则,先执行 await p
,因为 p
已经 resolve
了,所以会把后边的代码 console.log("after:await");
加入到微任务队列中。
接着又依次把 () => console.log("tick:a")
、() => console.log("tick:b")
加到微任务队列中。
所以输出是 after:await,tick:a, tick:b
。
在浏览器中运行符合我们的想法:
在 Node.js V16
中运行符合我们的想法:
但在 Node.js V10
中运行就些许不一样了:
至于为什么就是文章开头说的了,不管输出什么,其实就是其底层代码所决定的了。再具体的原因就需要去看 Node.js
相应的源码了。
当底层的逻辑影响到我们的业务逻辑的时候,可能就真的得去看这些源码和解决方案了。