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 引擎只负责执行同步代码,所有”等待”都不发生在主线程上。定时器由宿主计时,网络请求由宿主发起,主线程只负责在结果就绪时把回调取回来执行。

二、事件循环的核心模型

一个极简但准确的模型可以概括为:

  1. 执行同步代码,函数依次入栈、出栈,直到调用栈清空;
  2. 清空微任务队列(Microtask Queue)——全部执行完;
  3. 从宏任务队列(Macrotask Queue)取出一个任务执行;
  4. 该宏任务执行完,再次清空微任务队列;
  5. 循环步骤 3~4,直到所有队列为空。
1
2
3
4
5
6
7
8
9
10
11
12
13
console.log('1 同步')

setTimeout(() => {
console.log('4 宏任务 setTimeout')
}, 0)

Promise.resolve().then(() => {
console.log('3 微任务 then')
})

console.log('2 同步')

// 输出顺序:1 同步 → 2 同步 → 3 微任务 then → 4 宏任务 setTimeout

为什么是 3 在 4 前面?因为同步代码跑完后,事件循环优先清空微任务队列,setTimeout 注册的是宏任务,要等微任务全部结束才轮到它。

三、宏任务 vs 微任务

这是理解异步执行顺序的”分水岭”,务必记牢分类:

类型 常见来源 执行时机
宏任务(Macrotask) setTimeout、setInterval、setImmediate(Node)、I/O、UI 渲染、MessageChannel 每轮事件循环取一个执行
微任务(Microtask) Promise.then/catch/finally、queueMicrotask、MutationObserver、process.nextTick(Node) 每个宏任务之后全部清空

两个最容易踩的坑:

  1. 微任务会”插队”:只要还有微任务,就绝不会执行下一个宏任务。如果一个微任务里又产生了新的微任务,它会被追加到当前微任务队列末尾,本轮继续执行——写不好会饿死宏任务甚至页面渲染。
  2. setTimeout(fn, 0) 不是立即执行:它只是把回调尽早放进宏任务队列,实际延迟受最小延迟限制(浏览器通常 4ms 起)和前面排队任务影响,所以定时器”不精确”是必然的。

微任务插队演示

1
2
3
4
5
6
7
8
Promise.resolve().then(() => {
console.log('微任务 A')
Promise.resolve().then(() => console.log('微任务 A 里新产生的微任务'))
})

setTimeout(() => console.log('宏任务'), 0)

// 输出:微任务 A → 微任务 A 里新产生的微任务 → 宏任务

可以看到,微任务 A 里再产生的微任务依然在本轮清空,宏任务被一路延后。

四、从回调地狱到 Promise

在 Promise 出现之前,多个异步操作串行只能层层嵌套:

1
2
3
4
5
6
7
8
9
10
11
// 回调地狱:错误处理分散、可读性差、难以维护
getUser(id, (err, user) => {
if (err) return handle(err)
getOrders(user.id, (err, orders) => {
if (err) return handle(err)
getOrderDetail(orders[0].id, (err, detail) => {
if (err) return handle(err)
console.log(detail)
})
})
})

Promise 把”回调”变成了”值”,让异步代码可以像同步一样链式书写。

Promise 的三种状态

状态 含义 是否可变
pending 进行中 可转为 fulfilled / rejected
fulfilled 成功,带 value 一旦确定不可再变
rejected 失败,带 reason 一旦确定不可再变

状态”不可逆”是 Promise 的基石:一旦 resolve 后再调用 reject 会被忽略,这保证了异步结果只交付一次,也避免了回调被重复调用的问题。

链式调用与错误穿透

1
2
3
4
5
6
fetch('/api/user')
.then(res => res.json())
.then(user => fetch(`/api/orders/${user.id}`))
.then(res => res.json())
.catch(err => console.error('任意一环出错都会落到这里', err))
.finally(() => console.log('无论成功失败都会执行'))

then 返回的是新的 Promise,且上一环的返回值会作为下一环的入参;如果某一环抛错或返回 rejected,错误会沿链条一路向后”穿透”,直到被 catch 捕获。

静态方法怎么选

方法 语义 失败行为 典型场景
Promise.all 全部成功才成功 任一失败立刻 reject 多个强依赖接口并行拉取
Promise.allSettled 等待全部结束 永不 reject,返回状态数组 批量上报,允许部分失败
Promise.race 第一个敲定者胜出 第一个 reject 则整体 reject 超时控制、竞速
Promise.any 第一个成功者胜出 全部失败才 reject(AggregateError) 多镜像源择优
1
2
3
4
5
6
7
// 用 race 实现超时控制
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('请求超时')), ms)
)
return Promise.race([promise, timeout])
}

五、async/await:语法糖的本质

async/await 并非新机制,它是基于 Promise + 生成器的语法糖:async 函数永远返回一个 Promise,await 相当于在 Promise 上挂了 then,把后续代码放进微任务里继续。

1
2
3
4
5
6
7
8
9
10
async function load() {
console.log('A')
const user = await fetchUser() // 等价于 .then(() => { 后续 })
console.log('B') // 这一行进入微任务
return user
}

load()
console.log('C')
// 输出:A → C → B

await 的串行陷阱

最常见的性能问题:把本可并行的请求写成了串行。

1
2
3
4
5
6
// ❌ 串行:t1 完成后才开始 t2,耗时翻倍
const a = await fetchA()
const b = await fetchB()

// ✅ 并行:同时发起,一起等待
const [a, b] = await Promise.all([fetchA(), fetchB()])

只有在后面的请求依赖前面结果时才应该串行 await。互相独立的任务一定要用 Promise.all 并行,否则接口越多、总耗时越长。判断口诀:“谁依赖谁”,无依赖就并行。

错误处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 方式一:try/catch(可读性好,推荐)
async function run() {
try {
const data = await fetchData()
return data
} catch (err) {
console.error('捕获到错误', err)
}
}

// 方式二:捕获后继续,避免中断整个流程
const [ok, err] = await fetchData().then(
data => [data, null],
error => [null, error]
)

注意: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
2
3
4
5
6
7
// Node.js 输出顺序
setTimeout(() => console.log('setTimeout'), 0)
setImmediate(() => console.log('setImmediate'))
Promise.resolve().then(() => console.log('promise'))
process.nextTick(() => console.log('nextTick'))

// nextTick → promise → (setTimeout / setImmediate,顺序受当前阶段影响)

七、一道综合题彻底吃透

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
console.log('start')

setTimeout(() => {
console.log('timeout1')
Promise.resolve().then(() => console.log('micro-in-timeout'))
}, 0)

Promise.resolve()
.then(() => {
console.log('then1')
return Promise.resolve()
})
.then(() => console.log('then2'))

queueMicrotask(() => console.log('queueMicrotask'))

console.log('end')

推演过程:

  1. 同步执行 start、end;
  2. 清空微任务队列:then1 → queueMicrotask → then2(return Promise.resolve() 会多等一轮微任务);
  3. 取宏任务 timeout1,执行完清空其产生的微任务 micro-in-timeout。

最终输出:start → end → then1 → queueMicrotask → then2 → timeout1 → micro-in-timeout。

八、生产环境避坑清单

  1. forEach + async 是假并行:array.forEach(async item => await fn(item)) 不会等待,回调整体立即返回。需要串行用 for...of + await,需要并行用 Promise.all(array.map(...))。
  2. 别忘了处理 rejection:未被捕获的 Promise 会触发 unhandledrejection,浏览器控制台报错、Node 进程可能直接退出。挂全局兜底 window.addEventListener('unhandledrejection', ...) / process.on('unhandledRejection', ...)。
  3. 并行别失控:Promise.all 一次并发几百个请求会打爆连接数,需要并发控制(信号量 / 分批)。
1
2
3
4
5
6
7
8
9
10
11
12
13
// 简易并发控制器:最多同时跑 limit 个任务
async function pool(tasks, limit = 5) {
const results = []
let i = 0
const workers = Array.from({ length: limit }, async () => {
while (i < tasks.length) {
const cur = i++
results[cur] = await tasks[cur]()
}
})
await Promise.all(workers)
return results
}
  1. 用 AbortController 取消请求:组件卸载或超时后及时取消,避免”过期响应”覆盖新状态。
1
2
3
4
5
6
7
8
9
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), 5000)

fetch('/api/slow', { signal: ctrl.signal })
.then(res => res.json())
.catch(err => {
if (err.name === 'AbortError') console.log('已主动取消')
})
.finally(() => clearTimeout(timer))
  1. 长微任务会卡住渲染:微任务会一直清空才让出主线程,递归产生微任务会导致页面无法绘制。大量计算应切分到宏任务(setTimeout/MessageChannel)或交给 Web Worker。
  2. async 函数里的同步异常会变成 rejected Promise:调用处务必 await 或 .catch,否则错误会被吞掉。
  3. await 在循环里要警惕:for 循环里逐条 await 是串行的,数据量大时极其缓慢,确认业务是否真的需要顺序。
  4. 定时器精度别迷信:setInterval 会累积误差且任务耗时过长会堆积,需要精确定时用”递归 setTimeout + 补偿”或 performance.now() 校时。

一句话总结事件循环:同步代码跑完 → 清空微任务 → 取一个宏任务 → 再清空微任务 → 循环。把这句话刻进脑子里,90% 的异步输出题都能秒推。异步的本质不是”多线程”,而是“主线程永不空等,结果就绪再回来处理”。