JavaScript 异步编程与事件循环机制:从回调地狱到 Promise、async/await 与宏微任务

JavaScript 异步编程与事件循环机制:从回调地狱到 Promise、async/await 与宏微任务
神经蛙“JS 是单线程的”这句话只说对了一半。真正让它扛住海量并发的,是事件循环(Event Loop)这套调度机制。理解了宏任务与微任务的排队规则,setTimeout 为什么不准、await 之后代码什么时候执行、页面为什么会卡顿,这些问题会一次性讲清楚。本文从底层模型讲到 async/await 实战,配可运行的输出推演,帮你把异步的”直觉”换成”确定性”。
一、为什么 JavaScript 必须是异步的
JavaScript 诞生于浏览器,天生要操作 DOM。如果允许多线程同时修改同一棵 DOM 树,就得引入复杂的锁机制——这显然不现实。于是它选择了单线程模型:同一时刻只有一段 JS 代码在执行。
但单线程有个致命问题:遇到耗时操作(网络请求、定时器、文件读取)如果傻等,页面就会彻底卡死。解决方案就是非阻塞:把耗时任务交给浏览器(宿主环境)去办,主线程继续跑后面的代码,等结果回来了再通知主线程执行回调。
| 概念 | 说明 |
|---|---|
| 调用栈(Call Stack) | 单线程执行 JS 的”工作台”,函数调用入栈、返回出栈 |
| 宿主环境(Host) | 浏览器 / Node.js,负责定时器、网络、I/O 等真正耗时的活儿 |
| 任务队列(Task Queue) | 异步任务完成后,回调排队等待执行的地方 |
| 事件循环(Event Loop) | 不断检查调用栈是否为空,空了就把队列里的任务搬进栈执行 |
关键心法:JS 引擎只负责执行同步代码,所有”等待”都不发生在主线程上。定时器由宿主计时,网络请求由宿主发起,主线程只负责在结果就绪时把回调取回来执行。
二、事件循环的核心模型
一个极简但准确的模型可以概括为:
- 执行同步代码,函数依次入栈、出栈,直到调用栈清空;
- 清空微任务队列(Microtask Queue)——全部执行完;
- 从宏任务队列(Macrotask Queue)取出一个任务执行;
- 该宏任务执行完,再次清空微任务队列;
- 循环步骤 3~4,直到所有队列为空。
1 | console.log('1 同步') |
为什么是 3 在 4 前面?因为同步代码跑完后,事件循环优先清空微任务队列,setTimeout 注册的是宏任务,要等微任务全部结束才轮到它。
三、宏任务 vs 微任务
这是理解异步执行顺序的”分水岭”,务必记牢分类:
| 类型 | 常见来源 | 执行时机 |
|---|---|---|
| 宏任务(Macrotask) | setTimeout、setInterval、setImmediate(Node)、I/O、UI 渲染、MessageChannel |
每轮事件循环取一个执行 |
| 微任务(Microtask) | Promise.then/catch/finally、queueMicrotask、MutationObserver、process.nextTick(Node) |
每个宏任务之后全部清空 |
两个最容易踩的坑:
- 微任务会”插队”:只要还有微任务,就绝不会执行下一个宏任务。如果一个微任务里又产生了新的微任务,它会被追加到当前微任务队列末尾,本轮继续执行——写不好会饿死宏任务甚至页面渲染。
setTimeout(fn, 0)不是立即执行:它只是把回调尽早放进宏任务队列,实际延迟受最小延迟限制(浏览器通常 4ms 起)和前面排队任务影响,所以定时器”不精确”是必然的。
微任务插队演示
1 | Promise.resolve().then(() => { |
可以看到,微任务 A 里再产生的微任务依然在本轮清空,宏任务被一路延后。
四、从回调地狱到 Promise
在 Promise 出现之前,多个异步操作串行只能层层嵌套:
1 | // 回调地狱:错误处理分散、可读性差、难以维护 |
Promise 把”回调”变成了”值”,让异步代码可以像同步一样链式书写。
Promise 的三种状态
| 状态 | 含义 | 是否可变 |
|---|---|---|
| pending | 进行中 | 可转为 fulfilled / rejected |
| fulfilled | 成功,带 value | 一旦确定不可再变 |
| rejected | 失败,带 reason | 一旦确定不可再变 |
状态”不可逆”是 Promise 的基石:一旦 resolve 后再调用 reject 会被忽略,这保证了异步结果只交付一次,也避免了回调被重复调用的问题。
链式调用与错误穿透
1 | fetch('/api/user') |
then 返回的是新的 Promise,且上一环的返回值会作为下一环的入参;如果某一环抛错或返回 rejected,错误会沿链条一路向后”穿透”,直到被 catch 捕获。
静态方法怎么选
| 方法 | 语义 | 失败行为 | 典型场景 |
|---|---|---|---|
Promise.all |
全部成功才成功 | 任一失败立刻 reject | 多个强依赖接口并行拉取 |
Promise.allSettled |
等待全部结束 | 永不 reject,返回状态数组 | 批量上报,允许部分失败 |
Promise.race |
第一个敲定者胜出 | 第一个 reject 则整体 reject | 超时控制、竞速 |
Promise.any |
第一个成功者胜出 | 全部失败才 reject(AggregateError) | 多镜像源择优 |
1 | // 用 race 实现超时控制 |
五、async/await:语法糖的本质
async/await 并非新机制,它是基于 Promise + 生成器的语法糖:async 函数永远返回一个 Promise,await 相当于在 Promise 上挂了 then,把后续代码放进微任务里继续。
1 | async function load() { |
await 的串行陷阱
最常见的性能问题:把本可并行的请求写成了串行。
1 | // ❌ 串行:t1 完成后才开始 t2,耗时翻倍 |
只有在后面的请求依赖前面结果时才应该串行 await。互相独立的任务一定要用 Promise.all 并行,否则接口越多、总耗时越长。判断口诀:“谁依赖谁”,无依赖就并行。
错误处理
1 | // 方式一:try/catch(可读性好,推荐) |
注意:await 只能捕获它等待的那个 Promise 的 rejection;在 await 之前同步抛出的错误同样会被 try/catch 捕获,但 setTimeout 里的异步错误不会。
六、浏览器与 Node.js 事件循环的差异
浏览器的事件循环相对简单(宏任务 → 清空微任务 → 渲染),而 Node.js 的事件循环分为多个阶段(phases):
| 阶段 | 职责 |
|---|---|
| timers | 执行到期的 setTimeout / setInterval 回调 |
| pending callbacks | 处理部分系统操作回调 |
| poll | 轮询 I/O,等待新事件(大部分时间停留在此) |
| check | 执行 setImmediate 回调 |
| close callbacks | 处理关闭类回调,如 socket.on('close') |
Node.js 还有两个”特殊任务”:
process.nextTick:不属于事件循环任何阶段,优先级高于所有微任务,在每个阶段切换前清空;setImmediate:在 check 阶段执行,setTimeout(fn, 0)与setImmediate的先后在 I/O 回调内才稳定(setImmediate通常先)。
1 | // Node.js 输出顺序 |
七、一道综合题彻底吃透
1 | console.log('start') |
推演过程:
- 同步执行
start、end; - 清空微任务队列:
then1→queueMicrotask→then2(return Promise.resolve()会多等一轮微任务); - 取宏任务
timeout1,执行完清空其产生的微任务micro-in-timeout。
最终输出:start → end → then1 → queueMicrotask → then2 → timeout1 → micro-in-timeout。
八、生产环境避坑清单
forEach+async是假并行:array.forEach(async item => await fn(item))不会等待,回调整体立即返回。需要串行用for...of+await,需要并行用Promise.all(array.map(...))。- 别忘了处理 rejection:未被捕获的 Promise 会触发
unhandledrejection,浏览器控制台报错、Node 进程可能直接退出。挂全局兜底window.addEventListener('unhandledrejection', ...)/process.on('unhandledRejection', ...)。 - 并行别失控:
Promise.all一次并发几百个请求会打爆连接数,需要并发控制(信号量 / 分批)。
1 | // 简易并发控制器:最多同时跑 limit 个任务 |
- 用
AbortController取消请求:组件卸载或超时后及时取消,避免”过期响应”覆盖新状态。
1 | const ctrl = new AbortController() |
- 长微任务会卡住渲染:微任务会一直清空才让出主线程,递归产生微任务会导致页面无法绘制。大量计算应切分到宏任务(
setTimeout/MessageChannel)或交给 Web Worker。 async函数里的同步异常会变成 rejected Promise:调用处务必await或.catch,否则错误会被吞掉。await在循环里要警惕:for循环里逐条await是串行的,数据量大时极其缓慢,确认业务是否真的需要顺序。- 定时器精度别迷信:
setInterval会累积误差且任务耗时过长会堆积,需要精确定时用”递归setTimeout+ 补偿”或performance.now()校时。
一句话总结事件循环:同步代码跑完 → 清空微任务 → 取一个宏任务 → 再清空微任务 → 循环。把这句话刻进脑子里,90% 的异步输出题都能秒推。异步的本质不是”多线程”,而是“主线程永不空等,结果就绪再回来处理”。











