异步与协程精讲 / 第3章:回调函数 —— 最早的异步方案
第3章:回调函数 —— 最早的异步方案
3.1 什么是回调函数?
回调函数(Callback Function)是一个被作为参数传递给另一个函数的函数,在某个操作完成后被**“回调”**执行。
同步回调 vs 异步回调
# 同步回调 — 立即执行
def apply(func, value):
return func(value)
result = apply(lambda x: x * 2, 21)
print(result) # 42
// 异步回调 — 延迟执行
fs.readFile('/data.txt', 'utf8', function(err, content) {
if (err) {
console.error('读取失败:', err);
return;
}
console.log('文件内容:', content);
});
console.log('文件正在读取中...');
// 输出顺序:
// 文件正在读取中...
// 文件内容: ...
关键区别:异步回调不是在调用时执行,而是在操作完成后的某个时刻由事件循环执行。
3.2 回调地狱(Callback Hell)
当多个异步操作需要按顺序执行时,回调函数会层层嵌套,形成"金字塔"结构,这就是臭名昭著的回调地狱。
典型示例
// 用户下单流程:查询用户 → 查询商品 → 创建订单 → 发送通知
function placeOrder(userId, productId, callback) {
getUser(userId, function(err, user) {
if (err) return callback(err);
getProduct(productId, function(err, product) {
if (err) return callback(err);
if (product.stock <= 0) {
return callback(new Error('商品已售罄'));
}
createOrder(user, product, function(err, order) {
if (err) return callback(err);
sendNotification(user.email, order, function(err) {
if (err) return callback(err);
callback(null, order);
});
});
});
});
}
回调地狱的三大问题
| 问题 | 描述 | 影响 |
|---|---|---|
| 可读性差 | 代码向右不断缩进,形成三角形 | 维护困难,review 成本高 |
| 错误处理繁琐 | 每层都要检查 err | 容易遗漏,导致静默失败 |
| 控制流受限 | 无法使用 try/catch、for 循环、return | 逻辑复杂时代码爆炸 |
视觉化回调地狱
getUser(userId, function(err, user) {
│ getProduct(productId, function(err, product) {
│ │ createOrder(user, product, function(err, order) {
│ │ │ sendNotification(user.email, order, function(err) {
│ │ │ │ callback(null, order);
│ │ │ });
│ │ });
│ });
});
3.3 Node.js 的 Error-First 回调风格
Node.js 社区建立了统一的回调约定:Error-First Callback(错误优先回调)。
规则
// 约定:回调函数的第一个参数是 error,第二个参数是数据
function asyncOperation(input, callback) {
// 成功时:callback(null, result)
// 失败时:callback(error)
}
Node.js 标准库示例
const fs = require('fs');
// 读取文件
fs.readFile('/path/to/file', 'utf8', (err, data) => {
if (err) {
// 错误处理
if (err.code === 'ENOENT') {
console.error('文件不存在');
} else if (err.code === 'EACCES') {
console.error('权限不足');
} else {
console.error('未知错误:', err.message);
}
return;
}
console.log('文件内容:', data);
});
// DNS 查询
const dns = require('dns');
dns.lookup('example.com', (err, address, family) => {
if (err) throw err;
console.log('IP 地址:', address);
});
各语言回调风格对比
| 语言 | 风格 | 示例 |
|---|---|---|
| Node.js | Error-First | callback(err, result) |
| Python | 异常 + 回调 | def on_complete(result): / def on_error(e): |
| Java | 接口 | CompletableFuture.thenAccept(result -> {...}) |
| C | 函数指针 | void (*callback)(int status, void* data) |
| Go | 通常不用回调 | 使用 Channel 或 goroutine |
3.4 拯救回调地狱:扁平化技巧
在 Promise 和 async/await 出现之前,社区发明了多种技巧来缓解回调地狱。
技巧一:提取命名函数
// ❌ 匿名回调嵌套
getUser(userId, function(err, user) {
getProduct(productId, function(err, product) {
createOrder(user, product, function(err, order) {
sendNotification(user.email, order, function(err) {
callback(null, order);
});
});
});
});
// ✅ 提取命名函数
function onGetUser(err, user) {
if (err) return callback(err);
getProduct(productId, onGetProduct.bind(null, user));
}
function onGetProduct(user, err, product) {
if (err) return callback(err);
createOrder(user, product, onOrderCreated.bind(null, user));
}
function onOrderCreated(user, err, order) {
if (err) return callback(err);
sendNotification(user.email, order, onNotified.bind(null, order));
}
function onNotified(order, err) {
if (err) return callback(err);
callback(null, order);
}
getUser(userId, onGetUser);
技巧二:使用 async.js 库
const async = require('async');
// 串行执行
async.waterfall([
(cb) => getUser(userId, cb),
(user, cb) => getProduct(productId, (err, product) => cb(err, user, product)),
(user, product, cb) => createOrder(user, product, cb),
(order, cb) => sendNotification(user.email, order, (err) => cb(err, order)),
], (err, order) => {
if (err) console.error('下单失败:', err);
else console.log('下单成功:', order);
});
// 并行执行
async.parallel({
user: (cb) => getUser(userId, cb),
product: (cb) => getProduct(productId, cb),
}, (err, results) => {
// results.user 和 results.product 都就绪
});
技巧三:生成器(Generator)
// 使用 co 库 + Generator(Promise 前的过渡方案)
const co = require('co');
co(function* () {
const user = yield getUser(userId);
const product = yield getProduct(productId);
const order = yield createOrder(user, product);
yield sendNotification(user.email, order);
return order;
}).then(order => {
console.log('下单成功:', order);
}).catch(err => {
console.error('下单失败:', err);
});
3.5 回调的错误处理陷阱
陷阱一:忘记检查错误
// ❌ 危险:忽略了 err 参数
fs.readFile('/file.txt', 'utf8', (err, data) => {
console.log(data.length); // 如果 err 不为 null,data 是 undefined,会崩溃
});
// ✅ 正确:始终检查 err
fs.readFile('/file.txt', 'utf8', (err, data) => {
if (err) {
console.error('读取失败:', err);
return;
}
console.log(data.length);
});
陷阱二:多次调用回调
// ❌ 危险:回调可能被调用两次
function dangerousOperation(input, callback) {
try {
const result = processData(input);
callback(null, result);
} catch (e) {
callback(e); // 如果 processData 抛出,callback 被调用
callback(null, null); // ← 这行也可能执行!
}
}
// ✅ 正确:使用标志位防止重复调用
function safeOperation(input, callback) {
let called = false;
function safeCallback(...args) {
if (called) return;
called = true;
callback(...args);
}
try {
const result = processData(input);
safeCallback(null, result);
} catch (e) {
safeCallback(e);
}
}
陷阱三:异常无法被 try/catch 捕获
// ❌ try/catch 无法捕获异步回调中的异常
try {
fs.readFile('/file.txt', 'utf8', (err, data) => {
throw new Error('boom!'); // 不会被下面的 catch 捕获
});
} catch (e) {
console.error('捕获到:', e); // 不会执行
}
// ✅ 使用 process.on('uncaughtException') 或 domain
process.on('uncaughtException', (err) => {
console.error('未捕获异常:', err);
// 注意:此时进程状态可能不一致,应考虑优雅退出
});
3.6 业务场景:批量文件处理
场景
需要遍历一个目录,读取所有 .json 文件,合并内容,然后写入一个汇总文件。
回调风格实现
const fs = require('fs');
const path = require('path');
function processDirectory(dirPath, callback) {
fs.readdir(dirPath, (err, files) => {
if (err) return callback(err);
const jsonFiles = files.filter(f => f.endsWith('.json'));
if (jsonFiles.length === 0) {
return callback(null, []);
}
const results = [];
let pending = jsonFiles.length;
let hasError = false;
jsonFiles.forEach(file => {
const filePath = path.join(dirPath, file);
fs.readFile(filePath, 'utf8', (err, data) => {
if (hasError) return;
if (err) {
hasError = true;
return callback(err);
}
results.push(JSON.parse(data));
pending--;
if (pending === 0) {
callback(null, results);
}
});
});
});
}
processDirectory('./data', (err, data) => {
if (err) {
console.error('处理失败:', err);
return;
}
console.log('汇总数据:', JSON.stringify(data, null, 2));
});
注意:这段代码已经很复杂了——手动管理计数器、错误标志位。这正是回调模式的痛点。
3.7 回调模式并非一无是处
尽管回调地狱让人痛苦,回调模式本身仍有其价值:
| 优点 | 说明 |
|---|---|
| 底层基础 | Promise 和 async/await 底层仍然依赖回调机制 |
| 极致灵活 | 可以在任何支持函数作为一等公民的语言中使用 |
| 零依赖 | 不需要额外的运行时支持 |
| 性能好 | 没有 Promise 对象的额外分配和 GC 压力 |
| 简单场景 | 对于单次异步操作,回调是最简洁的方式 |
何时仍然使用回调
// 事件监听器 — 天然的回调场景
server.on('request', (req, res) => {
res.end('Hello World');
});
// 流式处理 — 回调是自然的选择
stream.on('data', (chunk) => {
process.stdout.write(chunk);
});
stream.on('end', () => {
console.log('\n处理完成');
});
stream.on('error', (err) => {
console.error('流错误:', err);
});
3.8 本章小结
| 要点 | 说明 |
|---|---|
| 回调函数 | 作为参数传递,在操作完成后执行 |
| 回调地狱 | 多层嵌套导致可读性差、错误处理繁琐 |
| Error-First | Node.js 社区的统一回调约定 |
| 扁平化技巧 | 命名函数、async.js、Generator |
| 错误处理陷阱 | 忘记检查、多次调用、无法 try/catch |
| 仍然有价值 | 事件监听、流处理、底层机制 |
下一章预告:回调地狱的痛推动了 Promise 的诞生。下一章我们将学习 Promise 如何用链式调用和统一的错误传播来拯救异步编程。
扩展阅读
- Node.js Error-First Callbacks
- Callback Hell — 回调地狱的生动说明
- async.js 文档 — 经典的异步工具库
- Futures and Promises — Wikipedia
- Designing Event-Driven Systems — Ben Stopford