第 4 章 4.1 异步编程:Callback
🎯 开场 3 分钟:为什么要学这个?
上一章我们用学生成绩管理系统把循环、判断、函数这些基础功夫练了个遍。代码是写爽了,但你有没有发现——程序都是老老实实排队执行的?
打个比方:你去奶茶店点单,店员说"稍等 5 分钟",然后你杵在那儿干等 5 分钟,后面排队的人也跟着干等。这种"卡住等"的编程方式,叫同步编程。
但现实世界不是这样的。你点完单完全可以刷会儿手机,等做好了店员喊你再来取。不用傻等,能干点别的,这,就是异步编程。
这一章我们学一个实现异步的经典手法——Callback(回调函数)。学完你就能:
- 理解为什么有些代码"不等它它自己就跑完了"
- 写出不会卡死界面的 JavaScript 代码
- 为下一章"Promise 链式调用"打牢地基
🧱 基础 25 分钟:核心概念
什么是 Callback?
人话版:把一个函数当参数传给别人,让别人"回头再调用你"。
生活类比:你去银行办业务,柜\n\n
\n\n
\n\n员说"材料我先收下,你别在这儿等,结果出来了我给你发短信"。你留了个"回头通知我"的函数(手机号)给柜员,这就叫回调——事情办完了,回头来调用你留下的东西。
JavaScript 里的 setTimeout——最简单的异步
浏览器里最经典的异步例子:
console.log("1. 我是先执行的");
setTimeout(function() {
console.log("3. 我是等了两秒才出来的");
}, 2000);
console.log("2. 我在 setTimeout 后面");
预期输出:
1. 我是先执行的
2. 我在 setTimeout 后面
3. 我是等了两秒才出来的
注意!输出的顺序是 1 → 2 → 3,不是你写代码的顺序 1 → 3 → 2。
为什么?
代码往下跑,碰到 setTimeout 就像碰到"去干等 2 分钟"的指令——JS 引擎说"这个我先存着,2 秒后再处理,我先继续往下走"。所以先打印 1 和 2,等 2 秒到了,再回来执行那个函数。
回调函数到底长什么样?
回调函数就是一个被当成参数传进去的函数。看这个:
function 好叫我(名字) {
console.log(名字 + ",你的咖啡好了!");
}
function 点单(回调) {
console.log("正在制作咖啡...");
setTimeout(function() {
回调("小明"); // 做完了,回头调用传入的函数
}, 1000);
}
点单(好叫我); // 把"好叫我"这个函数传进去
预期输出:
正在制作咖啡...
1秒后...
小明,你的咖啡好了!
同步回调 vs 异步回调
同步回调:数组的 map、filter 那些,你见过吧?
const 数字 = [1, 2, 3, 4, 5];
const 平方 = 数字.map(function(n) {
return n * n;
});
console.log(平方); // [1, 4, 9, 16, 25]
这个 map 里面的函数就是回调,但它是同步执行的——不涉及等待。
异步回调:setTimeout、鼠标点击事件、网络请求——这些需要"等一等"的场景。
坑预警:回调地狱
回调函数嵌套多了,代码会变成这样:
setTimeout(function() {
console.log("第一步完成");
setTimeout(function() {
console.log("第二步完成");
setTimeout(function() {
console.log("第三步完成");
setTimeout(function() {
console.log("第四步完成");
}, 1000);
}, 1000);
}, 1000);
}, 1000);
预期输出(每行隔 1 秒):
第一步完成
第二步完成
第三步完成
第四步完成
看着就头皮发麻对吧?这就是传说中的回调地狱(Callback Hell)——嵌套太深,缩进像金字塔,调试想哭。
下一章我们会学 Promise,就是为了解决这个问题的。
🔥 实战 35 分钟:3 个递进的小项目
项目 1:倒计时提醒器(5 分钟)
需求:用 setTimeout 做一个 3 秒倒计时,倒计时结束弹个提醒。
function 倒计时(秒数, 回调) {
console.log("开始倒计时:" + 秒数 + " 秒");
let 剩余秒数 = 秒数;
const 定时器 = setInterval(function() {
剩余秒数--;
console.log("还剩 " + 剩余秒数 + " 秒");
if (剩余秒数 <= 0) {
clearInterval(定时器); // 停止定时器
回调(); // 时间到了,调用回调
}
}, 1000);
}
function 时间到了() {
console.log("⏰ 时间到!该休息一下了!");
}
倒计时(3, 时间到了);
预期输出(每行隔 1 秒):
开始倒计时:3 秒
还剩 2 秒
还剩 1 秒
还剩 0 秒
⏰ 时间到!该休息一下了!
解释:setInterval 每秒执行一次内部函数,用 clearInterval 停止它。
项目 2:模拟加载用户资料(15 分钟)
需求:从网络加载用户数据,加载中显示"加载中...",加载完显示用户信息,失败显示错误。
function 模拟加载用户(用户ID, 成功回调, 失败回调) {
console.log("正在加载用户 " + 用户ID + " 的资料...");
setTimeout(function() {
// 模拟 50% 概率成功/失败
const 随机数 = Math.random();
if (随机数 > 0.5) {
const 用户数据 = {
id: 用户ID,
名字: "用户" + 用户ID,
等级: Math.floor(Math.random() * 100)
};
成功回调(用户数据);
} else {
失败回调("网络开小差了,请稍后重试");
}
}, 1500); // 模拟 1.5 秒网络延迟
}
function 加载成功(数据) {
console.log("✅ 加载成功!");
console.log("用户ID: " + 数据.id);
console.log("用户名: " + 数据.名字);
console.log("用户等级: " + 数据.等级);
}
function 加载失败(错误信息) {
console.log("❌ 加载失败:" + 错误信息);
}
// 测试加载用户 123
模拟加载用户(123, 加载成功, 加载失败);
预期输出(1.5 秒后,二选一):
正在加载用户 123 的资料...
✅ 加载成功!
用户ID: 123
用户名: 用户123
用户等级: 67
或者
正在加载用户 123 的资料...
❌ 加载失败:网络开小差了,请稍后重试
解释:这个模式在真实项目里超常见——网络请求、文件读取、数据库查询,都长这样。记住这个"成功/失败双回调"的套路。
项目 3:串行 + 并行加载多个用户资料(15 分钟)
需求:加载 3 个用户的资料,等所有用户都加载完再显示"全部加载完成"。
// 加载单个用户的函数(复用项目 2)
function 加载用户(用户ID) {
return new Promise(function(成功, 失败) {
console.log("开始加载用户 " + 用户ID);
setTimeout(function() {
const 随机数 = Math.random();
if (随机数 > 0.3) {
成功({ id: 用户ID, 名字: "用户" + 用户ID });
} else {
失败("用户 " + 用户ID + " 加载失败");
}
}, 1000);
});
}
function 加载完成回调(结果) {
console.log("========== 最终结果 ==========");
结果.forEach(function(用户) {
console.log("✅ " + 用户.名字);
});
console.log("总共成功加载 " + 结果.length + " 个用户");
}
// 串行加载:等一个完成再加载下一个
console.log("--- 串行加载模式 ---");
加载用户(1).then(function(用户1) {
console.log(用户1.名字 + " 加载完成");
return 加载用户(2);
}).then(function(用户2) {
console.log(用户2.名字 + " 加载完成");
return 加载用户(3);
}).then(function(用户3) {
console.log(用户3.名字 + " 加载完成");
console.log("所有用户串行加载完成!");
}).catch(function(错误) {
console.log("出错了:" + 错误);
});
预期输出(串行,大约 3 秒后):
--- 串行加载模式 ---
开始加载用户 1
用户1 加载完成
开始加载用户 2
用户2 加载完成
开始加载用户 3
用户3 加载完成
所有用户串行加载完成!
等等,你说要用 Callback?来,纯 Callback 版本:
// 纯 Callback 版本的串行加载
function 串行加载用户(用户ID列表, 全部完成回调) {
const 结果 = [];
let 当前索引 = 0;
function 加载下一个() {
if (当前索引 >= 用户ID列表.length) {
全部完成回调(结果);
return;
}
const 当前ID = 用户ID列表[当前索引];
console.log("开始加载用户 " + 当前ID);
setTimeout(function() {
// 模拟 80% 成功率
if (Math.random() > 0.2) {
结果.push({ id: 当前ID, 名字: "用户" + 当前ID });
console.log("用户 " + 当前ID + " 加载成功");
} else {
console.log("用户 " + 当前ID + " 加载失败");
}
当前索引++;
加载下一个(); // 递归加载下一个
}, 800);
}
加载下一个();
}
// 测试串行加载 3 个用户
串行加载用户([101, 102, 103], function(结果) {
console.log("========== 全部完成 ==========");
console.log("成功加载 " + 结果.length + " 个用户");
结果.forEach(function(用户) {
console.log("- " + 用户.名字);
});
});
预期输出(大约 2.4 秒后):
开始加载用户 101
用户 101 加载成功
开始加载用户 102
用户 102 加载成功
开始加载用户 103
用户 103 加载成功
========== 全部完成 ==========
成功加载 3 个用户
- 用户101
- 用户102
- 用户103
解释:纯 Callback 实现"等前一个完成再处理下一个",用的是递归 + 手动控制流程。代码长一点,但逻辑清晰。
💪 进阶 20 分钟:常见坑 + 性能小贴士
坑 1:回调函数 this 指向丢失
// ❌ 错误示例
const 用户 = {
名字: "小明",
打招呼: function() {
setTimeout(function() {
console.log("你好,我是 " + this.名字); // this 指向 window,不是用户对象
}, 100);
}
};
用户.打招呼(); // 输出:你好,我是 undefined
// ✅ 正确示例:用箭头函数或者 that 保存 this
const 用户 = {
名字: "小明",
打招呼: function() {
const that = this; // 保存 this
setTimeout(function() {
console.log("你好,我是 " + that.名字); // 用 that 代替 this
}, 100);
}
};
// 或者更简单,用箭头函数
const 用户2 = {
名字: "小红",
打招呼: function() {
setTimeout(() => {
console.log("你好,我是 " + this.名字); // 箭头函数不创建自己的 this
}, 100);
}
};
用户.打招呼();
用户2.打招呼();
预期输出:
你好,我是 小明
你好,我是 小红
坑 2:回调函数只传了一个参数
// ❌ 错误示例:忘记处理错误
function 读取文件(文件名, 回调) {
setTimeout(function() {
if (文件名 === "不存在的文件.txt") {
回调("文件不存在"); // 只传了错误,没传数据
} else {
回调(null, "文件内容"); // 正确:错误在前,数据在后
}
}, 100);
}
读取文件("不存在的文件.txt", function(内容) {
console.log("文件内容:" + 内容); // undefined!
});
// ✅ 正确示例:Node.js 风格回调(错误优先)
读取文件("不存在的文件.txt", function(错误, 内容) {
if (错误) {
console.log("出错了:" + 错误);
} else {
console.log("文件内容:" + 内容);
}
});
坑 3:回调函数被调用多次
// ❌ 错误示例:定时器没清理,回调被触发多次
let 计数 = 0;
const 定时器 = setInterval(function() {
计数++;
console.log("计数:" + 计数);
if (计数 >= 3) {
// 忘记 clearInterval,会一直跑下去
console.log("应该停止了但没有...");
}
}, 100);
// ✅ 正确示例:记得清理
let 计数 = 0;
const 定时器 = setInterval(function() {
计数++;
console.log("计数:" + 计数);
if (计数 >= 3) {
clearInterval(定时器); // 停止定时器
console.log("停止计数");
}
}, 100);
坑 4:异步循环的经典陷阱
// ❌ 错误示例:想依次打印 0 1 2,结果全是 3
for (var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i); // i 已经是 3 了
}, 100);
}
// ✅ 正确示例:把 var 换成 let,或者用闭包
for (let i = 0; i < 3; i++) { // let 会创建块级作用域
setTimeout(function() {
console.log(i);
}, 100);
}
// 或者用闭包保存每次的值
for (var i = 0; i < 3; i++) {
(function(当前值) {
setTimeout(function() {
console.log(当前值);
}, 100);
})(i);
}
预期输出:
0
1
2
调试技巧:console.log 打点位
遇到异步代码不知道卡哪儿了?疯狂加日志:
function 异步任务(回调) {
console.log("[DEBUG] 任务开始");
setTimeout(function() {
console.log("[DEBUG] setTimeout 触发");
回调();
}, 1000);
}
异步任务(function() {
console.log("[DEBUG] 回调执行完成");
});
✏️ 练习题 + 作业题
练习题(5 道,10 分钟内完成)
练习 1(1 分钟):抄改
- 输入:将项目 1 的倒计时从 3 秒改成 5 秒
- 预期输出:打印"还剩 4 秒"到"还剩 0 秒"
- 提示:改一个数字就行
练习 2(2 分钟):加判断
- 输入:在项目 2 的 加载成功 函数里,加一个 if 判断,等级大于 80 显示"VIP 用户",否则显示"普通用户"
- 预期输出:等级 85 → "VIP 用户",等级 60 → "普通用户"
- 提示:用 if (数据.等级 > 80) 做判断
练习 3(2 分钟):处理新数据
- 输入:把项目 2 的用户数据结构换成 {id, name, email},修改回调输出 email
- 预期输出:用户邮箱: xxx@example.com
- 提示:字段名变了,访问方式也要改
练习 4(3 分钟):串两个回调
- 输入:用项目 2 的 模拟加载用户 函数,先加载用户 1,成功后再加载用户 2
- 预期输出:两个用户的名字依次打印
- 提示:第一个 .then 里面返回第二个加载任务
练习 5(2 分钟):找 bug
- 输入:下面代码运行后会输出什么?为什么?
for (var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i);
}, 100);
}
- 预期输出:应该输出 0, 1, 2 但实际输出 3, 3, 3
- 提示:var 没有块级作用域,i 循环结束后已经是 3 了
作业题(30 分钟 - 2 小时)
作业:做一个「异步_callback 实战工具」
做一个批量新闻获取器,模拟从 3 个新闻源获取新闻标题:
- 需求描述:你是一个聚合新闻阅读器,需要从 3 个不同的"新闻源"(模拟)获取最新新闻标题
- 功能点:
1. 模拟 3 个新闻源,每个有 1-3 条新闻(用 setTimeout 模拟 500-1500ms 延迟)
2. 用纯 Callback 方式实现,等一个源完成再请求下一个
3. 所有新闻加载完成后,显示"获取了 X 条新闻"
4. 能处理某个源加载失败的情况(20% 概率失败),失败的不计入最终结果 - 加分项:
1. 显示每个源加载耗时
2. 按加载顺序显示新闻(哪个先回来哪个先显示) - 验收标准:
- 能跑起来(Node.js 或浏览器控制台都行)
- 输出符合预期格式
- 代码有注释说明每一步在干嘛
- 提交方式:评论区贴代码或 GitHub 链接
📚 总结 + 资源
一句话总结:Callback 就是"把函数当参数传给别人,事情办完了回头调用你",是异步编程的基石。
延伸学习:
- MDN Web Docs - 异步编程——权威文档,讲得细
- 《JavaScript 高级程序设计》第 10 章——经典书,系统学习异步
- JavaScript Event Loop 可视化——看完终于搞懂执行顺序
互动钩子:你在项目里用过回调函数吗?是在网络请求、文件读取还是别的地方?评论区聊聊你是怎么用的~
下章预告:Callback 虽好,但嵌套多了就变成"回调地狱"。下一章我们学 Promise,用链式调用的方式让异步代码写得跟同步一样舒服……

评论(0)