Skip to main content

事件循环和异步编程

JavaScript 是一门单线程语言。为了在执行耗时操作(如网络请求、定时器)时不阻塞主线程,JS 采用了异步编程模型,而支撑这个模型运转的底层机制就是事件循环(Event Loop)

异步编程的发展史

在面试中,经常需要梳理 JS 处理异步任务的演进过程:

  1. Callback (回调函数):最早的方案。缺点是容易造成“回调地狱(Callback Hell)”,代码向右横向发展,可读性极差,且难以处理错误。
  2. Promise:ES6 引入的规范。通过链式调用(.then().catch())解决了回调地狱,将嵌套逻辑拍平,统一了错误处理机制。
  3. Generator:ES6 引入的协程方案。可以随时暂停和恢复函数的执行,通过 yield 交出控制权,但需要配合执行器(如 co 库)才能实现自动管理异步流程。
  4. 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 / finally
    • async/await(底层也是 Promise)
    • MutationObserver(浏览器环境监听 DOM 变化)
    • process.nextTick(Node.js 环境,优先级比 Promise 还高)

宏任务 (Macrotask / Task)

  • 特点:优先级,每次 Event Loop 循环只取一个宏任务执行。执行完这个宏任务后,必须去清空微任务队列,然后再进行下一轮循环。
  • 常见来源
    • setTimeout / setInterval
    • 整体的 script 代码(最开始执行的第一个宏任务)
    • UI 交互事件(点击、滚动等)
    • 网络请求回调(Ajax / fetch)
    • requestAnimationFrame(通常在重新渲染前执行)

Event Loop 执行顺序口诀

  1. 执行当前宏任务(最开始是整段 script 同步代码)。
  2. 遇到微任务,塞入微任务队列;遇到宏任务,塞入宏任务队列。
  3. 当前宏任务的同步代码执行完毕。
  4. 检查微任务队列,依次取出执行,直到微任务队列彻底清空。(如果在执行微任务时又产生了新的微任务,也会在当前这一轮一起清空)。
  5. 浏览器视情况进行 UI 渲染。
  6. 开始下一轮 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

执行流拆解分析:

  1. 整体 script 作为第一个宏任务开始执行。
  2. 同步代码 console.log('1')打印 1
  3. 遇到 setTimeout,将其回调推入宏任务队列(记为 macro1)。
  4. 调用 async1()
    • 同步执行 console.log('2')打印 2
    • 遇到 await async2(),首先同步执行 async2(),于是进入 async2 函数内部。
    • async2 内部同步执行 console.log('4')打印 4
    • async2 执行完毕,默认返回一个 resolved 的 Promise。
    • 此时 await 接收到了结果,它会把 await 下面剩余的代码(即 console.log('3'))打包成一个回调,推入微任务队列(记为 micro1)。
    • async1 暂时挂起,跳出函数继续执行主线程。
  5. 遇到 new Promise
    • executor 内部是同步的,立即执行 console.log('6')打印 6
    • 调用 resolve(),状态变为 fulfilled。
    • 随后的 .then() 将回调推入微任务队列(记为 micro2)。
  6. 最后一行同步代码 console.log('8')打印 8
    • (至此,第一轮宏任务的同步代码全部执行完毕)
  7. 开始清空微任务队列
    • 当前微任务队列中有 [micro1, micro2]
    • 取出 micro1 执行,即 async1 中 await 后面的代码,打印 3
    • 取出 micro2 执行,即 Promise.then 的回调,打印 7
    • (微任务队列清空完毕,准备进入下一轮 Event Loop)
  8. 开始下一轮 Event Loop,从宏任务队列中取出 macro1(即 setTimeout 的回调)执行。
    • 打印 5
    • 结束。

参考