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 外部修改它的状态,结果代码跑得莫名其妙。

核心要点:Promise 解决了回调地狱的「嵌套」问题,但并没有解决「异步代码像同步代码一样写」的问题。直到 async/await 出现。

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 来组织代码。

小技巧:如果多个异步任务没有依赖关系,别傻傻地一个个 await。用 Promise.all 并行执行,效率更高。
// 错误示范:串行执行
const user = await getUser();
const posts = await getPosts();  // 这两个没有依赖关系

// 正确做法:并行执行
const [user, posts] = await Promise.all([getUser(), getPosts()]);

4.4 事件循环:JavaScript 的运行时机制

为什么 setTimeout 的回调不能保证准时执行?为什么 Promise 的 then 比 setTimeout 先执行?这些问题的答案,都在事件循环里。

JavaScript 是单线程语言。它只有一个主线程,一次只能做一件事。那异步任务怎么处理?靠事件循环。

事件循环的流程,说白了就是:

  1. 执行同步代码(主线程上的任务)
  2. 遇到异步任务,交给 Web API 处理(浏览器环境)
  3. 异步任务完成后,回调函数进入任务队列
  4. 主线程空闲后,从任务队列取出回调执行
  5. 重复以上步骤

嗯,这里要注意。任务队列不是只有一个。它分两种:宏任务队列微任务队列

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
我曾经踩过的坑:在微任务里递归创建微任务,会导致页面卡死。因为微任务队列永远清不空,宏任务永远得不到执行。UI 渲染也是宏任务,所以页面直接白屏。

你想想看,理解了事件循环,很多看似「玄学」的问题就迎刃而解了。比如为什么 setTimeout 的延迟时间不准确?因为它在任务队列里排队,前面可能有其他任务在等着。

4.6 实战建议

说了这么多,总结几条我在项目中实际用到的经验:

  • 能用 async/await 就别用裸 Promise。代码可读性差,维护成本高。
  • 多个独立异步任务用 Promise.all。别串行执行,浪费性能。
  • 注意微任务和宏任务的执行顺序。特别是在处理动画和异步数据时。
  • 错误处理不能偷懒。async/await 一定要配合 try/catch,或者全局捕获。

异步编程是 JavaScript 的核心能力。回调地狱、Promise、async/await 这三步,是每个前端工程师的必经之路。我个人觉得,理解事件循环比背 API 更重要。API 忘了可以查文档,但事件循环的机制搞不懂,遇到复杂问题就抓瞎。

好了,这一章就到这里。下一章我们聊聊模块化,从 CommonJS 到 ES Module,看看 JavaScript 的模块系统是怎么演进的。