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,容易遗漏
- 难以复用:回调函数无法被外部直接调用
- 调试困难:堆栈信息不清晰,定位问题费劲
第一步:用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里,不会漏掉。
第二步:用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
我们来看一个更真实的场景。假设你有一个用户注册流程:
- 检查用户名是否已存在
- 如果不存在,创建用户
- 发送欢迎邮件
- 记录日志
用回调写出来,大概是这样的:
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单独处理
- 逻辑顺序和代码顺序完全一致
避坑指南
我曾经在重构一个支付系统时,踩过一个坑。当时我把所有回调都改成了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,每一步都在让代码更接近人类的思维方式。
我个人建议,新项目直接上async/await。老项目的话,可以分两步走:先把回调封装成Promise,再逐步替换成async/await。别想着一步到位,容易出bug。
最后送你一句话:好的异步代码,看起来就像同步代码一样自然。如果你读一段异步代码需要停下来画调用图,那说明它还有重构的空间。