这一篇是整个系列中含金量最高的文章之一。异步编程是 JavaScript 的核心难点,也是区分初级和中高级前端工程师的关键分水岭。我们将按照“回调 -> Promise -> async/await”的进化路径,带你一步步走出“回调地狱”。


1. 引言

很多初学者在 JavaScript 中遇到的最头疼的问题,莫过于“异步”。假设我们要完成一个业务:先登录,获取用户 ID,再根据 ID 获取用户详情,最后获取订单列表。

如果是用最原始的写法,代码大概长这样:

// 恐怖的“回调地狱”
login(function(user) {
    getUserDetails(user.id, function(details) {
        getOrders(details.id, function(orders) {
            console.log(orders);
            // 如果还想处理错误...
        });
    });
});

这种层层嵌套的代码被称为“回调地狱”,不仅难看,而且错误处理极其麻烦。

为了解决这个问题,JavaScript 社区经历了三次重大的进化:

  1. 回调函数(最原始的方式)
  2. Promise(ES6 引入,解决嵌套问题)
  3. async/await(ES7 引入,解决可读性问题,终极方案)

本文将带你彻底搞懂这套异步编程的“组合拳”。


2. 正文

2.1 回调函数与异步的起源

JavaScript 是单线程的,为了不阻塞 UI 渲染,对于耗时操作(网络请求、定时器),采用了异步机制。

回调函数简单来说就是:“把这件事做完,回头再来调用这个函数”。

console.log("1. 开始");
setTimeout(() => {
    console.log("2. 两秒后执行");
}, 2000);
console.log("3. 立即执行");
// 输出顺序:1 -> 3 -> 2

痛点:当我们有多个连续的异步操作时,必须在一个回调里嵌套下一个回调,代码就会向右无限延伸,形成“回调金字塔”。

2.2 Promise —— 承诺未来会给你结果

ES6(ES2015)原声引入了 Promise。它不是什么魔法,只是一个对象,用来代表一个未来才知道结果的异步操作。

1. Promise 的三种状态

  • Pending(进行中):初始状态。
  • Fulfilled(已成功):操作完成,回调 resolve()
  • Rejected(已失败):操作出错,回调 reject()
  • 注意:状态一旦改变,就不会再变。

2. 基本用法
Promise 接收一个函数,该函数接收 resolvereject 两个参数。

function login() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            // 模拟登录成功
            resolve({ id: 1001, name: "Jack" });
            // 如果失败:reject("用户名或密码错误");
        }, 1000);
    });
}

3. 链式调用 —— 消除嵌套
Promise 的强大之处在于 then 方法。它返回一个新的 Promise,因此可以一直链下去。

login()
    .then(user => {
        console.log("登录成功:", user);
        // 返回一个新的 Promise 给下一个 then
        return getUserDetails(user.id); 
    })
    .then(details => {
        console.log("用户详情:", details);
        return getOrders(details.id);
    })
    .then(orders => {
        console.log("订单列表:", orders);
    })
    .catch(error => {
        // 统一捕获所有链路上的错误
        console.error("出错了:", error);
    });

这样,代码就从“横向嵌套”变成了“纵向平铺”,逻辑清晰了很多。

4. Promise.all 并发处理
如果多个请求没有依赖关系(比如同时获取用户信息和配置信息),我们可以并发执行以节省时间。

Promise.all([getUserInfo(), getConfig()])
    .then(([user, config]) => {
        // 两个都完成后才执行
        console.log(user, config);
    });

2.3 async/await —— 同步写法的异步

虽然 Promise 解决了嵌套,但大量的 .then() 依然看着累。ES2017 引入了 asyncawait,它是 Promise 的“语法糖”,让我们可以用写同步代码的方式写异步代码。

1. 基本规则

  • async 放在函数声明前,表示该函数是异步的,且返回一个 Promise。
  • await 只能在 async 函数内部使用,它会暂停代码执行,等待 Promise 返回结果。

2. 重写之前的例子

async function initData() {
    try {
        // 看起来就像同步代码!
        const user = await login();
        const details = await getUserDetails(user.id);
        const orders = await getOrders(details.id);
        
        console.log(orders);
    } catch (error) {
        // 用 try/catch 统一处理错误
        console.error("出错了:", error);
    }
}

initData();

在这里插入图片描述

3. 核心:await 到底在等什么?
它等待的是 Promise.resolve() 的返回值。

  • 如果 await 后面不是 Promise,它会自动转成 Promise。
  • 如果 await 的 Promise 失败了(reject),它会抛出异常,需要用 try/catch 捕获。

3. 常见问题 (FAQ)

Q1:async 函数如果不加 await 会怎么样?
A: 它会立即执行,不会等待。比如 const a = promiseFunc();,如果 promiseFunc 返回的是 Promise 且你没用 await,那么 a 就是一个 Promise 对象,而不是你想要的数据。这是新手最容易犯的错!

Q2:我有 3 个请求互不依赖,是用 await 一个接一个写,还是用 Promise.all
A: 一定要用 Promise.all

// ❌ 错误:耗时 3 秒(1+1+1)
const a = await req1();
const b = await req2();
const c = await req3();

// ✅ 正确:耗时 1 秒(并发)
const [a, b, c] = await Promise.all([req1(), req2(), req3()]);

Q3:回调地狱完全消失了吗?
A: 在业务代码中,基本消失了。但在某些库的开发中(如 Webpack 插件、Node.js 流处理),为了极致的性能控制,仍然可能会用到回调。


4. 总结

异步编程是 JavaScript 的灵魂,我们见证了它的三次进化:

  1. 回调函数:异步的基础,但容易导致“回调地狱”。
  2. Promise:将异步操作对象化,通过链式调用解决了嵌套问题。
  3. async/await:终极形态,用同步的思维方式写异步代码,配合 try/catch 处理错误极佳。

最佳实践建议

在现代前端开发中,优先使用 async/await。在处理并发请求时,配合 Promise.all 使用。除非维护旧代码,否则不要再写深层嵌套的回调。

下一篇预告:代码越来越多,文件越来越大怎么办?我们需要模块化。下一篇我们将讲解 ES Modules,告别 <script src="..."> 的全局变量污染。


如果觉得本文对你有帮助,请点赞👍、收藏⭐、关注👀,三连支持一下!

有问题欢迎在评论区留言:你现在写异步还在用 .then() 还是已经全转 async/await 了?

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐