4、异步编程:回调地狱、Promise、async/await、事件循环、微任务与宏任务
异步编程,说白了就是处理「现在做不了,等会儿再说」的那些事。
我刚开始写 JavaScript 时,觉得这玩意儿挺简单。不就是等个请求、读个文件嘛。后来项目越做越大,回调一层套一层,代码直接变成了「圣诞树」。嗯,那感觉,谁经历谁知道。
4.1 回调地狱:从嵌套到扁平
先看个经典场景。你要按顺序做三件事:登录、获取用户信息、加载用户头像。
// 回调地狱的典型写法
login(function(err, user) {
if (err) {
console.error('登录失败', err);
return;
}
getUserInfo(user.id, function(err, info) {
if (err) {
console.error('获取信息失败', err);
return;
}
getAvatar(info.avatarId, function(err, avatar) {
if (err) {
console.error('加载头像失败', err);
return;
}
console.log('头像加载完成', avatar);
});
});
});
你想想看,这才三层。实际项目中,五层六层的回调很常见。代码横向发展,可读性极差。我曾在维护一个老项目时,见过一个回调嵌套了八层。改一个 bug,得先花半小时理清嵌套关系。
4.2 Promise:链式调用的救星
Promise 的出现,让异步代码从「嵌套」变成了「链式」。说白了,就是把回调地狱拍扁了。
// 用 Promise 改写上面的例子
login()
.then(user => getUserInfo(user.id))
.then(info => getAvatar(info.avatarId))
.then(avatar => {
console.log('头像加载完成', avatar);
})
.catch(err => {
console.error('出错了', err);
});
是不是清爽多了?每个 then 只做一件事,错误统一在 catch 里处理。我个人习惯把 Promise 的链式调用想象成「流水线」——每个工位只处理自己的零件,出问题就扔到废品区。
Promise 有三种状态:pending(进行中)、fulfilled(已成功)、rejected(已失败)。状态一旦改变,就不可逆。这一点很重要,我见过有人试图在 Promise 外部修改它的状态,结果代码跑得莫名其妙。
4.3 async/await:异步的终极形态
async/await 是 Promise 的语法糖。它让异步代码写起来像同步代码一样自然。
// 用 async/await 改写
async function loadUserAvatar() {
try {
const user = await login();
const info = await getUserInfo(user.id);
const avatar = await getAvatar(info.avatarId);
console.log('头像加载完成', avatar);
} catch (err) {
console.error('出错了', err);
}
}
你看,代码从上往下读,逻辑一目了然。没有回调,没有 then,只有 await 等待结果。我在项目中遇到复杂的异步流程时,基本都用 async/await 来组织代码。
// 错误示范:串行执行
const user = await getUser();
const posts = await getPosts(); // 这两个没有依赖关系
// 正确做法:并行执行
const [user, posts] = await Promise.all([getUser(), getPosts()]);
4.4 事件循环:JavaScript 的运行时机制
为什么 setTimeout 的回调不能保证准时执行?为什么 Promise 的 then 比 setTimeout 先执行?这些问题的答案,都在事件循环里。
JavaScript 是单线程语言。它只有一个主线程,一次只能做一件事。那异步任务怎么处理?靠事件循环。
事件循环的流程,说白了就是:
- 执行同步代码(主线程上的任务)
- 遇到异步任务,交给 Web API 处理(浏览器环境)
- 异步任务完成后,回调函数进入任务队列
- 主线程空闲后,从任务队列取出回调执行
- 重复以上步骤
嗯,这里要注意。任务队列不是只有一个。它分两种:宏任务队列和微任务队列。
4.5 微任务与宏任务:谁先谁后?
先看个经典面试题:
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
Promise.resolve().then(() => {
console.log('3');
});
console.log('4');
// 输出顺序:1, 4, 3, 2
为什么会这样?因为事件循环的规则是:每执行完一个宏任务,都会清空微任务队列。
常见的宏任务有:
- setTimeout / setInterval
- I/O 操作
- UI 渲染
- 事件回调
常见的微任务有:
- Promise.then / catch / finally
- MutationObserver
- queueMicrotask
| 类型 | 执行时机 | 常见 API |
|---|---|---|
| 宏任务 | 每次事件循环执行一个 | setTimeout, setInterval, I/O |
| 微任务 | 每个宏任务后清空全部 | Promise.then, queueMicrotask |
你想想看,理解了事件循环,很多看似「玄学」的问题就迎刃而解了。比如为什么 setTimeout 的延迟时间不准确?因为它在任务队列里排队,前面可能有其他任务在等着。
4.6 实战建议
说了这么多,总结几条我在项目中实际用到的经验:
- 能用 async/await 就别用裸 Promise。代码可读性差,维护成本高。
- 多个独立异步任务用 Promise.all。别串行执行,浪费性能。
- 注意微任务和宏任务的执行顺序。特别是在处理动画和异步数据时。
- 错误处理不能偷懒。async/await 一定要配合 try/catch,或者全局捕获。
异步编程是 JavaScript 的核心能力。回调地狱、Promise、async/await 这三步,是每个前端工程师的必经之路。我个人觉得,理解事件循环比背 API 更重要。API 忘了可以查文档,但事件循环的机制搞不懂,遇到复杂问题就抓瞎。
好了,这一章就到这里。下一章我们聊聊模块化,从 CommonJS 到 ES Module,看看 JavaScript 的模块系统是怎么演进的。