第 4 章 4.1 异步编程:Callback


🎯 开场 3 分钟:为什么要学这个?

上一章我们用学生成绩管理系统把循环、判断、函数这些基础功夫练了个遍。代码是写爽了,但你有没有发现——程序都是老老实实排队执行的

打个比方:你去奶茶店点单,店员说"稍等 5 分钟",然后你杵在那儿干等 5 分钟,后面排队的人也跟着干等。这种"卡住等"的编程方式,叫同步编程

但现实世界不是这样的。你点完单完全可以刷会儿手机,等做好了店员喊你再来取。不用傻等,能干点别的,这,就是异步编程

这一章我们学一个实现异步的经典手法——Callback(回调函数)。学完你就能:

  • 理解为什么有些代码"不等它它自己就跑完了"
  • 写出不会卡死界面的 JavaScript 代码
  • 为下一章"Promise 链式调用"打牢地基

🧱 基础 25 分钟:核心概念

什么是 Callback?

人话版:把一个函数当参数传给别人,让别人"回头再调用你"。

生活类比:你去银行办业务,柜\n\nSimple tech illustration expla\n\nAI comic creation scene, creat\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 异步回调

同步回调:数组的 mapfilter 那些,你见过吧?

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,用链式调用的方式让异步代码写得跟同步一样舒服……

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。