事件循环和异步编程
JavaScript 是一门单线程语言。为了在执行耗时操作(如网络请求、定时器)时不阻塞主线程,JS 采用了异步编程模型,而支撑这个模型运转的底层机制就是事件循环(Event Loop)。
异步编程的发展史
在面试中,经常需要梳理 JS 处理异步任务的演进过程:
- Callback (回调函数):最早的方案。缺点是容易造成“回调地狱(Callback Hell)”,代码向右横向发展,可读性极差,且难以处理错误。
- Promise:ES6 引入的规范。通过链式调用(
.then().catch())解决了回调地狱,将嵌套逻辑拍平,统一了错误处理机制。 - Generator:ES6 引入的协程方案。可以随时暂停和恢复函数的执行,通过
yield交出控制权,但需要配合执行器(如co库)才能实现自动管理异步流程。 - async/await:ES8(ES2017)引入。它是 Generator 的语法糖,内置了自动执行器。它让我们能用最接近同步代码的写法来写异步代码,是目前公认的终极异步方案。
如何在循环中执行异步任务?
- 串行执行(一个接一个):使用
for...of配合await,或者使用reduce构建 Promise 链。- 并行执行(同时发起):使用
Promise.all(arr.map(item => fetch(item)))。- 注意陷阱:
forEach内部是同步调用回调函数的,它不会等待await执行完毕,因此无法在forEach中实现串行异步!
Event Loop (事件循环) 核心机制
“Event Loop 是 JS 处理异步任务的调度机制。由于 JS 是单线程的,所有的同步任务都在主线程(调用栈)上执行。当遇到异步任务时,会被挂起并交给浏览器的其他线程(如定时器线程、网络线程)处理。当异步任务有了结果,它的回调函数就会被塞入任务队列中。主线程空闲后,就会去任务队列中取出回调来执行。如此循环往复,就是 Event Loop。”
宏任务 (Macrotask) vs 微任务 (Microtask)
任务队列实际上被分为了两类:微任务队列 和 宏任务队列。它们的执行优先级是面试的重中之重。
微任务 (Microtask)
- 特点:优先级高,当前宏任务执行完毕后,在浏览器渲染(UI Render)之前,必须清空所有的微任务。
- 常见来源:
Promise.then / catch / finallyasync/await(底层也是 Promise)MutationObserver(浏览器环境监听 DOM 变化)process.nextTick(Node.js 环境,优先级比 Promise 还高)
宏任务 (Macrotask / Task)
- 特点:优先级低,每次 Event Loop 循环只取一个宏任务执行。执行完这个宏任务后,必须去清空微任务队列,然后再进行下一轮循环。
- 常见来源:
setTimeout/setInterval- 整体的
script代码(最开始执行的第一个宏任务) - UI 交互事件(点击、滚动等)
- 网络请求回调(Ajax / fetch)
requestAnimationFrame(通常在重新渲染前执行)
Event Loop 执行顺序口诀
- 执行当前宏任务(最开始是整段 script 同步代码)。
- 遇到微任务,塞入微任务队列;遇到宏任务,塞入宏任务队列。
- 当前宏任务的同步代码执行完毕。
- 检查微任务队列,依次取出执行,直到微任务队列彻底清空。(如果在执行微任务时又产生了新的微任务,也会在当前这一轮一起清空)。
- 浏览器视情况进行 UI 渲染。
- 开始下一轮 Event Loop,从宏任务队列中取出一个新的宏任务执行。回到步骤 1。
记忆要点:宏任务是一次执行一个,微任务是一次执行一队!
🌟 经典题目
console.log("1");
async function async1() {
console.log("2");
await async2();
console.log("3");
}
async function async2() {
console.log("4");
}
setTimeout(function () {
console.log("5");
}, 0);
async1();
new Promise(function (resolve) {
console.log("6");
resolve();
}).then(function () {
console.log("7");
});
console.log("8");
点击查看答案与超详细解析
正确输出顺序: 1 -> 2 -> 4 -> 6 -> 8 -> 3 -> 7 -> 5
执行流拆解分析:
- 整体 script 作为第一个宏任务开始执行。
- 同步代码
console.log('1'),打印 1。 - 遇到
setTimeout,将其回调推入宏任务队列(记为macro1)。 - 调用
async1():- 同步执行
console.log('2'),打印 2。 - 遇到
await async2(),首先同步执行async2(),于是进入async2函数内部。 async2内部同步执行console.log('4'),打印 4。async2执行完毕,默认返回一个 resolved 的 Promise。- 此时
await接收到了结果,它会把await下面剩余的代码(即console.log('3'))打包成一个回调,推入微任务队列(记为micro1)。 async1暂时挂起,跳出函数继续执行主线程。
- 同步执行
- 遇到
new Promise:executor内部是同步的,立即执行console.log('6'),打印 6。- 调用
resolve(),状态变为 fulfilled。 - 随后的
.then()将回调推入微任务队列(记为micro2)。
- 最后一行同步代码
console.log('8'),打印 8。- (至此,第一轮宏任务的同步代码全部执行完毕)
- 开始清空微任务队列:
- 当前微任务队列中有
[micro1, micro2]。 - 取出
micro1执行,即async1中 await 后面的代码,打印 3。 - 取出
micro2执行,即 Promise.then 的回调,打印 7。 - (微任务队列清空完毕,准备进入下一轮 Event Loop)
- 当前微任务队列中有
- 开始下一轮 Event Loop,从宏任务队列中取出
macro1(即setTimeout的回调)执行。- 打印 5。
- 结束。