79、重构与异步编程:重构回调地狱,引入Promise/Async-Await

说实话,回调地狱这玩意儿,几乎是每个JavaScript开发者都会撞上的墙。

我记得刚入行那会儿,接手一个老项目,打开文件一看——三层嵌套的回调,里面还夹着错误处理。我当时就一个感觉:这代码,改一个功能得花半天去理清调用链。后来我慢慢意识到,回调地狱不只是代码丑的问题,它直接拉低了整个项目的可维护性。

今天我们就来聊聊,怎么把那些嵌套得跟俄罗斯套娃似的回调,重构为Promise,再进一步升级成async/await。说白了,就是让异步代码看起来像同步一样清爽。

什么是回调地狱?

先看一个典型场景:你需要依次读取三个文件,然后合并内容。用回调写出来是这样的:

fs.readFile('a.txt', 'utf8', function(err, dataA) {
    if (err) {
        console.error('读取a.txt失败', err);
        return;
    }
    fs.readFile('b.txt', 'utf8', function(err, dataB) {
        if (err) {
            console.error('读取b.txt失败', err);
            return;
        }
        fs.readFile('c.txt', 'utf8', function(err, dataC) {
            if (err) {
                console.error('读取c.txt失败', err);
                return;
            }
            const result = dataA + dataB + dataC;
            console.log('合并结果:', result);
        });
    });
});

你想想看,这还只是三个文件。如果业务逻辑再复杂一点,比如中间还要做数据转换、调用API、写数据库……那嵌套深度简直不敢想。

这种代码的问题在哪?

  • 可读性差:代码向右偏移,逻辑被嵌套打散
  • 错误处理混乱:每个回调都要单独处理err,容易遗漏
  • 难以复用:回调函数无法被外部直接调用
  • 调试困难:堆栈信息不清晰,定位问题费劲
注意:回调地狱不只是嵌套问题。更隐蔽的是,每个回调里的错误处理如果漏掉,整个流程就会静默失败。我在项目中遇到过好几次,数据没写进去,但没有任何报错,排查了一整天才发现是回调里的err被忽略了。

第一步:用Promise扁平化嵌套

Promise的出现,说白了就是为了解决回调地狱的。它把异步操作包装成一个对象,用.then()链式调用代替嵌套。

我们先看看怎么把上面的回调改写成Promise:

function readFilePromise(filePath) {
    return new Promise((resolve, reject) => {
        fs.readFile(filePath, 'utf8', (err, data) => {
            if (err) {
                reject(err);
            } else {
                resolve(data);
            }
        });
    });
}

readFilePromise('a.txt')
    .then(dataA => {
        return readFilePromise('b.txt').then(dataB => dataA + dataB);
    })
    .then(combined => {
        return readFilePromise('c.txt').then(dataC => combined + dataC);
    })
    .then(result => {
        console.log('合并结果:', result);
    })
    .catch(err => {
        console.error('读取文件失败', err);
    });

嗯,这里要注意:上面的写法虽然用了Promise,但.then里面又嵌套了.then,其实还是有点「回调味儿」。更好的做法是彻底扁平化:

readFilePromise('a.txt')
    .then(dataA => {
        return Promise.all([dataA, readFilePromise('b.txt')]);
    })
    .then(([dataA, dataB]) => {
        return Promise.all([dataA + dataB, readFilePromise('c.txt')]);
    })
    .then(([combined, dataC]) => {
        const result = combined + dataC;
        console.log('合并结果:', result);
    })
    .catch(err => {
        console.error('读取文件失败', err);
    });

这样写,每一层.then只做一件事,逻辑清晰多了。而且错误处理统一在.catch里,不会漏掉。

个人习惯:我一般会把每个异步操作都封装成返回Promise的函数。这样不管是测试还是复用,都方便。比如上面的readFilePromise,我项目中类似的工具函数有一整套。

第二步:用async/await让代码像同步一样

Promise虽然解决了嵌套问题,但链式调用写多了,其实还是有点绕。尤其是当你有条件判断、循环、或者多个异步操作交织的时候,.then链会变得很复杂。

这时候,async/await就派上用场了。它本质上是对Promise的语法糖,但写起来就像同步代码一样直观。

看看同样的逻辑用async/await怎么写:

async function mergeFiles() {
    try {
        const dataA = await readFilePromise('a.txt');
        const dataB = await readFilePromise('b.txt');
        const dataC = await readFilePromise('c.txt');
        const result = dataA + dataB + dataC;
        console.log('合并结果:', result);
        return result;
    } catch (err) {
        console.error('读取文件失败', err);
        throw err; // 或者做其他错误处理
    }
}

mergeFiles();

你看,代码从上往下执行,没有嵌套,没有链式调用。每一行就是一个异步操作,出错统一在try/catch里处理。这就是我为什么说,async/await是异步编程的终极形态。

为什么会这样?因为await关键字会暂停当前async函数的执行,等待Promise完成。这让我们可以用同步的思维去写异步代码。

实战重构:从回调到async/await

我们来看一个更真实的场景。假设你有一个用户注册流程:

  1. 检查用户名是否已存在
  2. 如果不存在,创建用户
  3. 发送欢迎邮件
  4. 记录日志

用回调写出来,大概是这样的:

function registerUser(username, password, callback) {
    db.query('SELECT id FROM users WHERE username = ?', [username], function(err, rows) {
        if (err) return callback(err);
        if (rows.length > 0) {
            return callback(new Error('用户名已存在'));
        }
        db.query('INSERT INTO users (username, password) VALUES (?, ?)', [username, password], function(err, result) {
            if (err) return callback(err);
            email.sendWelcome(username, function(err) {
                if (err) {
                    // 邮件发送失败,要不要回滚?这里逻辑很纠结
                    console.error('邮件发送失败', err);
                }
                logger.log('用户注册成功: ' + username, function(err) {
                    if (err) return callback(err);
                    callback(null, { userId: result.insertId });
                });
            });
        });
    });
}

这段代码的问题很明显:

  • 嵌套了4层,缩进已经到屏幕边缘了
  • 邮件发送失败的处理很尴尬——是忽略还是回滚?代码里只是打了个日志
  • 每个回调都要手动传递err,容易遗漏

现在,我们用async/await来重构:

async function registerUser(username, password) {
    // 1. 检查用户名
    const rows = await db.queryAsync('SELECT id FROM users WHERE username = ?', [username]);
    if (rows.length > 0) {
        throw new Error('用户名已存在');
    }

    // 2. 创建用户
    const result = await db.queryAsync(
        'INSERT INTO users (username, password) VALUES (?, ?)',
        [username, password]
    );

    // 3. 发送欢迎邮件(失败不影响注册)
    try {
        await email.sendWelcomeAsync(username);
    } catch (emailErr) {
        // 邮件发送失败,记录日志但不中断流程
        console.error('邮件发送失败', emailErr);
        await logger.logAsync('邮件发送失败: ' + username);
    }

    // 4. 记录成功日志
    await logger.logAsync('用户注册成功: ' + username);

    return { userId: result.insertId };
}

你看,重构后的代码:

  • 每一步都清晰可见,没有嵌套
  • 错误处理分两种:关键错误(用户名重复)直接throw,非关键错误(邮件发送失败)用try/catch单独处理
  • 逻辑顺序和代码顺序完全一致
核心思路:重构回调地狱的关键,不是把回调改成Promise就完事了。真正的目标是让代码的「执行顺序」和「阅读顺序」保持一致。async/await帮我们做到了这一点。

避坑指南

我曾经在重构一个支付系统时,踩过一个坑。当时我把所有回调都改成了async/await,但忘了处理并发问题。比如同时发起多个请求,我用await一个个等,结果性能直接掉了一半。

后来我才意识到,async/await虽然好用,但也要注意:

  • 不要滥用await:如果多个异步操作没有依赖关系,应该用Promise.all并发执行
  • 注意错误冒泡:async函数里的throw会被自动包装成rejected Promise,但如果你忘了用try/catch,错误可能会被吞掉
  • 循环里的异步:for循环里用await是串行的,如果希望并发,考虑用map + Promise.all

举个例子,正确的并发写法:

// 错误写法:串行执行,浪费时间
async function loadAllUsers(userIds) {
    const users = [];
    for (const id of userIds) {
        const user = await db.findUser(id);
        users.push(user);
    }
    return users;
}

// 正确写法:并发执行
async function loadAllUsers(userIds) {
    const promises = userIds.map(id => db.findUser(id));
    return Promise.all(promises);
}

知识体系总览

下面这张图,帮你理清从回调到async/await的演进路径:

异步编程演进路径 回调地狱 嵌套深、错误处理分散 可读性差、难以维护 重构 Promise 链式调用、扁平化 统一错误处理 语法糖 Async/Await 同步写法、异步执行 try/catch 错误处理 核心对比 回调地狱 Promise Async/Await 嵌套金字塔 链式扁平 线性顺序 分散的err处理 统一.catch try/catch

总结

重构回调地狱,本质上是一次「控制流」的升级。从回调到Promise,再到async/await,每一步都在让代码更接近人类的思维方式。

我个人建议,新项目直接上async/await。老项目的话,可以分两步走:先把回调封装成Promise,再逐步替换成async/await。别想着一步到位,容易出bug。

最后送你一句话:好的异步代码,看起来就像同步代码一样自然。如果你读一段异步代码需要停下来画调用图,那说明它还有重构的空间。

小技巧:重构时,可以先用Promise包装最底层的异步操作(比如数据库查询、文件读写),然后逐层往上替换。这样每次改动范围小,风险可控。

公众号:蓝海资料掘金营,微信deep3321