这两年的客户端js异步题型,无非就是Promise+seTimeout+async/await的各种变式,考察的其实是事件循环(Event Loop)。异步循环老生常谈,相信大家已经对理论知识了解了,宏任务,微任务,本文不赘述。这种题目拿到怎么看,脑子里记住以下规则: 0. 从上往下找,从外往里找,推入各队列的顺序就是最终执行顺序。
- 同步任务依次推入宏任务队列。
- 碰到async,只要还没碰到await,就正常推入宏任务。碰到await,就马上进入await标记的里面函数,该推进宏任务的推进宏任务,该放到微任务的放到微任务,await结束后,回来把await语句下面的代码推到微任务队列;
- 碰到promise,先把promise里的照常推入宏任务(因为promise新建后会立即执行,等价于async函数里await之前的代码),再把then里的安排进微任务(等价于await之后的代码)。
- 碰到setTimeout,直接推入下一个宏任务。
如果是笔试题,有人习惯在每个console后面写序号,我习惯在另外的空白处直接写输出,因为我觉得这样更符合事件循环队列的逻辑。
大约70%文章经常用来讲解的是这一道:
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2');
}
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0);
async1();
new Promise(function(resolve) {
console.log('promise1');
resolve();
}).then(function() {
console.log('promise2');
});
console.log('script end');
用上面我说的方法:
- 从上往下找,找到了一句落单的
console.log('script start');
,于是现在纸上写下“script start”,代表推入第一个宏任务; - 再往下看,setTimeout,直接在下面画条横线写到下面去,代表是这下一个宏任务的事情,先推入;
- 好,遇到async1函数,看看里面都发生了什么:先把“async1 start”推入宏任务,写到“script start”的下一行。然后碰到await,看看await里的async2干了啥,哦,原来是一句简单的输出“async2”,那直接写在第一遍宏任务的第三行。
- 回到async1,发现await语句的下面还有一句输出:
console.log('async1 end');
,对不起,这个只能推到微任务里了,在第一个宏任务输出表右边画一条竖线,线的右边代表第一次的微任务栈。 现在你的草稿纸应该长这样:

5.async1里的事情全部安排完了,继续往下看,哇,是Promise,众所周知,写在Promise里的代码是同步任务,直接推入当前宏任务。所以在最外层的宏任务队列里接着写下“promise1”。resolve在哪里并不重要,反正then里的代码也要放到微任务里执行,所以在右边微任务队列里写下“promise2”。
6.promise全部搞定,最后往下看,仍然有一只落单的"script end",同步任务,写到第一个宏任务队列里。
最终你的草稿纸长这样:

最终的执行顺序是:先执行左上宏任务,再执行右上的微任务,再执行下面的第二次宏任务。如果题目复杂,有更多次宏任务,那就是左右左右依执行下去。
我们用浏览器console验证一下:
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout
没毛病。
掌握了这种清晰明了的写法后,我们可以做奇怪一点题目:
async function a1() {
console.log('a1 start');
await a2();
console.log('a1 end');
}
async function a2() {
console.log('a2');
}
console.log('script start');
setTimeout(() => {
console.log('setTimeout');
}, 0);
Promise.resolve().then(() => {
console.log('promise1');
});
a1();
let promise2 = new Promise(resolve => {
resolve('promise2.then');
console.log('promise2');
});
promise2.then(res => {
console.log(res);
Promise.resolve().then(() => {
console.log('promise3');
});
});
console.log('script end');
这道题在我的草稿本上是这样的:

script start
a1 start
a2
promise2
script end
promise1
a1 end
promise2.then
promise3
setTimeout
你可能会对左上角的微任务列表右边还有一栏感到奇怪,其实因为promise2里还有一个嵌套的promise导致的,原因是因为JS每次执行完一轮宏任务后,都会去执行微任务直至清空微任务队列(Microtask Queue),而队列里新注册的微任务要等当前队列已有的任务全部执行完才会执行。这个道理在await上是相同的,毕竟await就是Promise的语法糖。 形如以下代码:
async function async1() {
console.log('async1');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2');
await async3();
console.log('async2 end');
}
async function async3() {
console.log('async3');
await async4();
console.log('async3 end');
}
async function async4() {
console.log('async4');
}
async1();
new Promise(function(resolve) {
console.log('promise1');
resolve();
}).then(function() {
console.log('promise2');
});
你觉得运行结果会是怎样呢?不妨自己在草稿纸上写写看。
我的草稿纸上是这样的:

所以结果如下:
async1
async2
async3
async4
promise1
async3 end
promise2
async2 end
async1 end
这个是await多层嵌套的逻辑形式。再次强调:推入执行队列的顺序就是最终的执行顺序。 对于setTimeout,也是这个原理,形如:
async function async1() {
console.log('async1');
await async2();
setTimeout(() => {
console.log('setTimeout1');
});
}
async function async2() {
await async3();
setTimeout(() => {
console.log('setTimeout2');
});
}
async function async3() {
setTimeout(() => {
console.log('setTimeout3');
});
}
async1();
setTimeout(() => {
console.log('setTimeout0');
});
草稿纸:

async1
setTimeout3
setTimeout0
setTimeout2
setTimeout1
为什么长这样?因为在微任务里有多层异步嵌套,setTimeout推入下一宏任务队列的顺序也是根据微任务的注册顺序执行的。
基本上变式就这些,如果之后看到更多变式,只要记住以上规则,都能灵活对付。