基础知识
JavaScript在设计上是单线程非阻塞的,仅有的一个主线程通过事件循环机制(Event Loop)执行全部任务。需要留意以下的几点:
- 任务分为宏任务(Macrotask)和微任务(Microtask),分别位于宏任务队列,或称任务队列(Task Queue) 和微任务队列,或称作业队列(Job Queue) ,遵循先进先出的原则(FIFO);(为免混淆,本文不使用加粗部分的表述,但它其实是更准确的说法)
- 执行上下文是一个栈,正在执行的任务位于执行栈(FILO)中;
- 每一次事件循环率先取出宏任务队列队首的那个宏任务进入执行栈,执行完毕后执行全部微任务,一次事件循环结束;
- 微任务的优先级更高,指的是执行完一个宏任务后执行全部微任务(清空微任务队列) ,而非先做微任务再做宏任务;
- 宏任务的例子有这些:script(整体的代码)、
setTimeout、setInterval、setImmediate、 I/O 任务等等; - 微任务的例子有这些:
Promise.then()、processes.nextTick等等,注意,Promise的执行器函数会在new Promise()时一起执行,执行器函数不是微任务,而是属于当前宏任务下的一个普通同步函数;
实例详解
1、判断输出顺序
接下来就是喜闻乐见的判断代码输出顺序环节,通过这个简单的例子彻底解惑,放弃尝试的同学可以直接看控制台输出,毕竟不是为了 考倒自己,而是学到东西
console.log('宏任务0开始')
let p = new Promise((resolve, reject)=>{
console.log('Promise执行器,包含在宏任务0内')
setTimeout(()=>{
console.log('Promise执行器里的异步函数,宏任务1')
resolve()
},0)
})
p.then(() => {
console.log('在p的resolve()后执行,微任务0')
})
.then(() => {
console.log('在p.then的resolve()后执行,微任务1')
})
setTimeout(() => {
console.log('定时器0秒后的回调,宏任务2')
}, 0)
console.log('宏任务0结束')
是游刃有余还是一头雾水呢?如果你尝试直接复制代码输出结果(图中红线代表一次宏任务的执行,绿线代表清空微任务队列,即执行微任务队列中的全部微任务):
控制台上输出的顺序竟然如此规律
- 执行第一个宏任务
- 清空微任务队列(此时队列空)
- 执行第二个宏任务
- 清空微任务队列
- 执行第三个宏任务
- 清空微任务队列(此时队列空)
2、分步详解
看到这里,可能已经会豁然开朗了,我们结合这段代码,再把它的执行过程好好捋一捋(引用格式表示当前任务队列和执行栈状态):
宏任务:script(整体代码); 微任务:空; 执行栈:空;
-
第一次事件循环开始,取宏任务队列队首的任务“script(整体代码)”,放入执行栈开始执行;
-
第1行:控制台输出: “宏任务0开始”
-
第2行:new Promise()时里面的执行器立即执行,①控制台输出: “Promise执行器,包含在宏任务0内” ;②
setTimeout开始计时器,其回调将在0秒后进入宏任务队列(注意延迟结束后进入队列排队,不是立即执行,也不是进入队列再延迟),由于这里的延迟是0秒,所以此时:宏任务:setTimeout(); 微任务:空; 执行栈:script->Promise执行器;
-
-
执行栈继续往后执行,
p.then()是一个微任务,但它特殊在要等到p落定为fulfilled(这里是执行resolve()时)或rejected之后才会进入微任务队列。与setTimeout()的相同点:都是异步的;与setTimeout()不同的是:setTimeout()等待的是计时器,then()等待的是Promise落定状态;以及setTimeout()进入宏任务队列,then()进入微任务队列。所以两个
then()之后,此时的任务队列并没有发生变化。 -
执行栈继续往后执行,遇到了第二个
setTimeout(),同样,其回调将等待0秒后进入宏任务排队,由于是0秒,所以立即进入宏任务队列排队了。最后一行代码进入执行栈,控制台输出: “宏任务0结束” ,此时:宏任务:setTimeout() 宏任务1 | setTimeout()宏任务2; 微任务:空; 执行栈:script->console.log()
-
宏任务0执行结束了,去执行微任务队列中的全部微任务。诶,没有微任务呀,舒服了。至此,第一次事件循环结束。
宏任务:setTimeout() 宏任务1 | setTimeout()宏任务2; 微任务:空; 执行栈:空
-
第二次事件循环开始,取宏任务队列队首的任务“setTimeout() 宏任务1”,放入执行栈开始执行;控制台打印: “Promise执行器里的异步函数,宏任务1” ,并执行
resolve()。宏任务:setTimeout()宏任务2; 微任务:空; 执行栈:setTimeout的回调:console.log(); resolve();
-
resolve()时,Promise落定,在一边等待的then()终于可以入队了,所以p.then()进入微任务队列,注意这个时候,p.then().then()没有进入队列哦,还在等待p.then()的返回值。宏任务:setTimeout()宏任务2; 微任务:p.then(); 执行栈:空;
-
宏任务1执行结束了,去执行微任务队列中的全部微任务。诶,这次有微任务了,将
p.then()取出放入执行栈开始执行,控制台打印: “在p的resolve()后执行,微任务0” 。宏任务:setTimeout()宏任务2; 微任务:空; 执行栈:p.then():console.log();
-
最精彩的来了,我们知道,默认情况下,
p.then()会返回一个value为undefined的已resolve的Promise给下一个then(),这就使得p.then()执行后,p.then().then()可以立即进入微任务队列。此时:宏任务:setTimeout()宏任务2; 微任务:p.then().then(); 执行栈:空;
诶呦,这中途加进来的可怎么处理呀,记住:微任务的执行原则是:依次执行微任务队列中的微任务直至微任务队列为空(即使中途有微任务加入) ,所以这个中途加进来的微任务也需要执行,控制台打印: “在p.then的resolve()后执行,微任务1” 。这时,微任务队列清空了,第二次事件循环结束。
宏任务:setTimeout()宏任务2; 微任务:空; 执行栈:空;
-
第三次事件循环开始,取宏任务队列队首的任务“setTimeout() 宏任务2”,放入执行栈开始执行;控制台打印: “定时器0秒后的回调,宏任务2” ,执行完后出栈。此时:
宏任务:空; 微任务:空; 执行栈:空;
-
让人激动,历经三次事件循环,全部执行完了! 看似非常复杂,但这么理一理,还是可以很清楚的。由这个例子,触类旁通,再怎么变也不怕了。
3、注释版代码
最后奉上这段代码的注释纯享版:
console.log('宏任务0开始') //本次宏任务开始
let p = new Promise((resolve, reject)=>{
//Promise执行器里的同步函数,new Promise时立即执行(包含在本次宏任务内,不会跳出当前执行栈)
console.log('Promise执行器,包含在宏任务0内')
setTimeout(()=>{
//Promise执行器里的异步函数,是一个宏任务(Macrotask),跳出当前执行栈,0ms后进入任务队列(Task Queue)成为宏任务1,这个0ms的延迟就决定了相比宏任务2进入任务队列的先后,如果保持宏任务2的0ms延迟不变,将宏任务1的延迟改为1000ms,宏任务2将排在前面率先执行
console.log('Promise执行器里的异步函数,宏任务1')
resolve()
},0)
})
p.then(() => {
//在p的resolve()后执行,是一个微任务(Microtask),跳出当前执行栈,进入作业队列(Job Queue)
console.log('在p的resolve()后执行,微任务0')
})
.then(() => {
//在p.then()的resolve()后执行,是一个微任务(Microtask),跳出当前执行栈,进入作业队列(Job Queue),排在上一个微任务后面
console.log('在p.then的resolve()后执行,微任务1')
})
setTimeout(() => {
//定时器0秒后的回调,是一个宏任务,在0ms的延迟后进入任务队列,排在上一个宏任务1后面
console.log('定时器0秒后的回调,宏任务2')
}, 0)
console.log('宏任务0结束')
本文首发于稀土掘金社区——司空明,如需转载,请注明引用来源。