今天给大家带来一道出现频率很高的面试题,最初是在头条的面试中出现,主要考察大家对 event loop
、Promise
、async/await
等知识点的掌握情况。
题目
请写出下面代码的输出结果
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')
千万不要看到 async 就害怕,其实它并不神秘。
前置知识点
在解析这道题目之前,希望大家能先理解以下几个知识点:
event loop的执行顺序:
- 一开始整个脚本作为一个宏任务执行
- 执行过程中同步代码直接执行,宏任务进入宏任务队列,微任务进入微任务队列
- 当前宏任务执行完出队,检查微任务列表,有则依次执行,直到全部执行完
- 执行浏览器UI线程的渲染工作
- 检查是否有Web Worker任务,有则执行
- 执行完本轮的宏任务,回到第2步,依此循环,直到宏任务和微任务队列都为空
宏任务和微任务
- 微任务包括:
MutationObserver
、Promise.then()
或catch()
、Promise
为基础开发的其它技术,比如fetch API
、V8的垃圾回收过程
、Node独有的process.nextTick
。 - 宏任务包括:
script
、setTimeout
、setInterval
、setImmediate
、I/O
、UI rendering
。
注意:在所有任务开始的时候,由于宏任务中包括了script,所以浏览器会先执行一个宏任务,在这个过程中你看到的延迟任务(例如setTimeout)将被放到下一轮宏任务中来执行。
async/await
在这里也不赘述 async/await 的基础内容,大家需要知道:
- async 声明的函数,其返回值必定是 promise 对象,如果没有显式返回 promise 对象,也会用 Promise.resolve() 对结果进行包装,保证返回值为 promise 类型
- await 会先执行其右侧表达逻辑(从右向左执行),并让出主线程,跳出 async 函数,而去继续执行 async 函数外的同步代码
- 如果 await 右侧表达逻辑是个 promise,让出主线程,继续执行 async 函数外的同步代码,等待同步任务结束后,且该 promise 被 resolve 时,继续执行 await 后面的逻辑
- 如果 await 右侧表达逻辑不是 promise 类型,那么仍然异步处理,将其理解包装为 promise, async 函数之外的同步代码执行完毕之后,会回到 async 函数内部,继续执行 await 之后的逻辑
题目分析
基于以上知识点,我们来进行题目的分析:
- 首先执行同步代码,输出
script start
,并向下执行,遇见setTimeout
,将其回调放入宏任务当中 - 继续执行同步代码逻辑,遇见 async1(),执行 async1 内同步代码,输出
async1 start
,继续下后执行到await async2()
,执行async2
函数 - async2 函数内并没有 await,按顺序执行,同步输出
async2
,按照 async 函数规则,async2 函数仍然返回一个 promise,作为 async1 函数中的 await 表达式的值。相当于:Promise.resolve().then(() => {})
- 同时 async1 函数让出主线程,中断在 await 一行。
- 回到 async1 函数外,继续执行,输出 Promise 构造函数内 promise1,同时将这个 promise 的执行完成逻辑放到微任务当中
- 执行完最后一行代码,输出
script end
- 此时同步代码全部执行完毕,回到 async1 函数中断处,优先执行微任务
Promise.resolve().then(() => {})
其实什么也没做。但这时候 await 中断失效,继续执行 async1 函数,输出async1 end
- 这时候检查微任务,输出
promise2
- 这时候微任务全部执行完毕,检查宏任务,输出
setTimeout
我将代码重新拷贝,加上注释,我们再来回顾一下:
async function async1() {
console.log('async1 start') // step 4: 直接打印同步代码 async1 start
await async2() // step 5: 遇见 await,首先执行其右侧逻辑,并在这里中断 async1 函数
console.log('async1 end') // step 11: 再次回到 async1 函数,await 中断过后,打印代码 async1 end
}
async function async2() {
console.log('async2') // step 6: 直接打印同步代码 async2,并返回一个 resolve 值为 undefined 的 promise
}
console.log('script start') // step 1: 直接打印同步代码 script start
// step 2: 将 setTimeout 回调放到宏任务中,此时 macroTasks: [setTimeout]
setTimeout(function() {
console.log('setTimeout') //step 13: 开始执行宏任务,输出 setTimeout
}, 0)
async1() // step 3: 执行 async1
// step 7: async1 函数已经中断,继续执行到这里
new Promise(function(resolve) {
console.log('promise1') // step 8: 直接打印同步代码 promise1
resolve()
}).then(function() { // step 9: 将 then 逻辑放到微任务当中
console.log('promise2') // step 12: 开始执行微任务,输出 promise2
})
console.log('script end') // step 10: 直接打印同步代码 script end,并回到 async1 函数中继续执行
最后的输出顺序是:
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout
PS:这道题最后的async1 end
和 promise2
的顺序其实存在争议,与浏览器实现或者Node.js的版本有关。
总结
由上述例题可见,这一类面试常见的「必考题」灵活多变,且会受到语言规范以及浏览器实现的影响。虽然有些考察点「涉嫌」「刁难」面试者,但是掌握最基本的异步理论、清楚规范要求细节,确实是能够灵活运用的关键,也是能够避免或追查 bugs 的必备知识。
我对大家的建议是,对于这些内容不必头大,见一个分析一个,分析一个就「死记」一个,规范永远没有为什么,但是仔细思考却总有它的道理。不然你们想想,JavaScript 为什么一开始就是单线程异步的?