为什么需要异步

JavaScript 是单线程语言,同一个时刻只能做一件事。如果网络请求、文件读取都同步阻塞,页面就会卡死。异步编程的本质是”先不等待结果,把任务挂起,结果出来后通过回调/Promise 继续执行“。

一、从回调地狱到 Promise

回调时代最痛苦的是嵌套:

1
2
3
4
5
6
7
8
// 回调地狱
getUser(1, (user) => {
getOrders(user.id, (orders) => {
getDetails(orders[0].id, (detail) => {
console.log(detail)
})
})
})

Promise 把嵌套拍平:

1
2
3
4
5
getUser(1)
.then((user) => getOrders(user.id))
.then((orders) => getDetails(orders[0].id))
.then((detail) => console.log(detail))
.catch((err) => console.error('出错了:', err))

Promise 三种状态:pending(进行中)→ fulfilled(成功)或 rejected(失败),状态一旦改变不可逆。

二、Promise 常用方法

1
2
3
4
5
6
7
8
9
10
// 全部成功才成功,一个失败整体失败
Promise.all([fetchA(), fetchB()])
.then(([a, b]) => console.log(a, b))

// 谁先完成用谁的结果(适合超时控制)
Promise.race([fetchData(), timeout(3000)])

// 全部结束,无论成功失败(适合批量上报)
Promise.allSettled([fetchA(), fetchB()])
.then((results) => results.forEach((r) => console.log(r.status)))

超时控制是 race 的经典场景:

1
2
3
4
5
6
7
function timeout(ms) {
return new Promise((_, reject) =>
setTimeout(() => reject(new Error('请求超时')), ms)
)
}

Promise.race([fetch('/api/data'), timeout(5000)])

三、async/await:异步的同步写法

1
2
3
4
5
6
7
8
9
10
11
async function loadUserInfo() {
try {
const user = await getUser(1)
const orders = await getOrders(user.id)
const detail = await getDetails(orders[0].id)
return detail
} catch (e) {
console.error('加载失败:', e)
throw e
}
}

注意点:

  1. await 只能在 async 函数里使用(顶层 await 需要 ESM 模块环境);
  2. async 函数永远返回 Promise,返回值会被自动包装;
  3. 用 try/catch 捕获错误,和同步代码体验一致;
  4. 没有依赖关系的请求不要串行 await,用 Promise.all 并行:
1
2
3
4
5
6
// 慢:两个请求串行,共耗时 t1 + t2
const a = await fetchA()
const b = await fetchB()

// 快:两个请求并行,耗时 max(t1, t2)
const [a, b] = await Promise.all([fetchA(), fetchB()])

四、事件循环:微任务与宏任务

这是异步面试必考点。JavaScript 的异步任务分两类:

类型 例子 执行时机
宏任务(macrotask) setTimeout、setInterval、I/O、requestAnimationFrame 每轮事件循环取一个
微任务(microtask) Promise.then、queueMicrotask、MutationObserver 每个宏任务结束后清空全部微任务

执行顺序:同步代码 → 微任务 → 宏任务 → 微任务 → …

1
2
3
4
5
6
7
8
9
10
console.log('1')                        // 同步

setTimeout(() => console.log('2')) // 宏任务

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

console.log('4') // 同步
// 输出顺序:1, 4, 3, 2

为什么微任务先于下一个宏任务? 因为微任务队列会在每个宏任务之间被完全清空。这也解释了为什么 setTimeout(() => {}, 0) 不一定在”0 毫秒”执行——它要等当前宏任务和所有微任务结束。

五、高频踩坑

  1. 在 forEach 里 await 不生效:forEach 不支持异步等待,要用 for...of 或 Promise.all:
1
2
3
4
5
6
7
8
9
// 错误:forEach 不会等待 await
items.forEach(async (item) => {
await save(item)
})

// 正确
for (const item of items) {
await save(item)
}
  1. 忘记 catch 导致未处理拒绝:async 函数里的异常不处理会变成 rejected Promise,Node 高版本会直接报 UnhandledPromiseRejection;
  2. 滥用 await 拖慢速度:无依赖的请求务必并行;
  3. 回调里的 this 丢失:用箭头函数保持 this 指向。

总结

异步编程的核心理解顺序:事件循环(微任务/宏任务)→ Promise 状态机 → async/await 语法糖 → 并行与错误处理。日常开发把”无依赖并行、有依赖串行、所有入口 try/catch”这三条记住,就能写出健壮的异步代码。