上一篇我们掌握了 ES6 class 语法,彻底搞定了现代 JS 面向对象的写法。从这一篇开始,我们进入 JS 学习的另一个核心难点 ------异步编程。
在 JS 中,代码默认是 "同步执行" 的(从上到下,一行执行完再执行下一行),但实际开发中,我们经常会遇到 "需要等待某个操作完成后,再执行后续代码" 的场景(比如请求接口、读取文件、定时器),这就需要用到异步编程。
本文全程贴合前 4 篇风格,无多余格式,直接适配聊天窗口查看,重点讲解异步的本质、回调函数的用法,以及回调函数的常见问题(回调地狱),为后续学习 Promise、async/await 打下基础,所有代码可直接复制运行。
一、先搞懂:同步 vs 异步(核心区别)
1. 同步执行(默认)
- 定义:代码从上到下,顺序执行,前一行代码执行完成后,才会执行下一行;
- 特点:执行效率低,但逻辑简单,不会出现 "代码顺序混乱";
- 案例:普通的变量赋值、函数调用、运算。
javascript
运行
// 同步执行案例
console.log("1. 开始执行");
let num = 1 + 2;
console.log(`2. 计算结果:${num}`);
function sayHi() {
console.log("3. 同步函数执行");
}
sayHi();
console.log("4. 执行结束");
// 输出顺序(固定不变):
// 1. 开始执行
// 2. 计算结果:3
// 3. 同步函数执行
// 4. 执行结束
2. 异步执行(重点)
- 定义:代码执行时,不会等待异步操作完成,而是继续执行后续代码,等异步操作完成后,再回头执行 "回调函数";
- 特点:执行效率高,不会阻塞后续代码,但逻辑相对复杂,需要处理 "等待" 逻辑;
- 常见异步场景:定时器(setTimeout/setInterval)、接口请求(fetch/ajax)、文件读取、事件绑定。
javascript
运行
// 异步执行案例(定时器)
console.log("1. 开始执行");
// 异步操作:1秒后执行回调函数
setTimeout(function() {
console.log("3. 异步定时器执行");
}, 1000);
console.log("2. 继续执行后续代码");
// 输出顺序(不固定中间等待时间,但顺序一定是 1→2→3):
// 1. 开始执行
// 2. 继续执行后续代码
// (等待1秒后)
// 3. 异步定时器执行
3. 异步的本质(必记)
JS 是单线程语言(同一时间只能执行一段代码),这是异步存在的核心原因 ------ 如果没有异步,当遇到需要等待的操作(比如等待接口返回),代码会一直阻塞,页面会卡死。
异步的本质:将需要等待的操作 "挂起",先执行不需要等待的代码,等等待的操作完成后,再执行对应的回调逻辑,避免代码阻塞。
二、回调函数:异步编程的第一种方式
1. 什么是回调函数?
回调函数就是 "作为参数传递给另一个函数,并且在某个事件(或异步操作)完成后被调用的函数"。
简单说:你告诉 JS,"等这个异步操作做完了,就执行我给你的这个回调函数",这个 "被传递的函数" 就是回调函数。
2. 回调函数的基本用法(结合异步场景)
场景 1:定时器中的回调函数(最基础)
javascript
运行
// 回调函数作为 setTimeout 的第二个参数,1秒后执行
setTimeout(function() {
console.log("异步操作完成,执行回调");
}, 1000);
// 可以简写为箭头函数(更简洁)
setTimeout(() => {
console.log("箭头函数写法的回调");
}, 1500);
场景 2:事件绑定中的回调函数(常用)
事件绑定也是异步场景(点击、鼠标移动等事件,不确定什么时候触发),回调函数会在事件触发时执行。
javascript
运行
// HTML:<button id="btn">点击我</button>
const btn = document.getElementById("btn");
// 回调函数:点击按钮时执行
btn.addEventListener("click", function() {
console.log("按钮被点击了(回调执行)");
});
// 箭头函数简写
btn.addEventListener("click", () => {
console.log("箭头函数写法,按钮被点击");
});
场景 3:自定义异步逻辑(模拟接口请求)
实际开发中,接口请求是最常见的异步场景,我们可以用 setTimeout 模拟接口请求,感受回调函数的作用。
javascript
运行
// 模拟接口请求(异步操作)
function requestData(url, callback) {
// 模拟接口请求需要1.5秒
setTimeout(() => {
// 模拟接口返回的数据
const data = { code: 200, message: "请求成功", data: { name: "张三" } };
// 异步操作完成,调用回调函数,将数据传递给回调
callback(data);
}, 1500);
}
// 调用接口,传入回调函数(处理接口返回的数据)
requestData("https://test.com/api/user", function(data) {
console.log("接口请求完成,返回数据:", data);
// 后续逻辑:根据返回数据渲染页面等
if (data.code === 200) {
console.log("渲染用户信息:", data.data.name);
}
});
console.log("请求已发送,等待返回..."); // 不会等待接口,直接执行
3. 回调函数的核心作用
- 解决 "异步操作等待" 的问题:让异步操作完成后,能精准执行后续逻辑;
- 实现 "代码解耦":将 "异步操作" 和 "后续逻辑" 分离,代码更清晰。
三、回调地狱:回调函数的致命问题(面试常考)
当我们需要执行 "多个有依赖关系的异步操作" 时(比如:先请求接口 1,拿到结果后,再请求接口 2,再拿到结果后请求接口 3),就会出现 "回调函数嵌套回调函数" 的情况,这就是回调地狱。
1. 回调地狱的案例(模拟多接口依赖)
javascript
运行
// 模拟接口1:获取用户ID
function getUserID(callback) {
setTimeout(() => {
const userId = 1001;
console.log("接口1:获取到用户ID:", userId);
callback(userId);
}, 1000);
}
// 模拟接口2:根据用户ID获取用户信息
function getUserInfo(userId, callback) {
setTimeout(() => {
const userInfo = { id: userId, name: "张三", age: 20 };
console.log("接口2:获取到用户信息:", userInfo);
callback(userInfo);
}, 1000);
}
// 模拟接口3:根据用户信息获取用户订单
function getUserOrder(userInfo, callback) {
setTimeout(() => {
const order = { id: 1, userId: userInfo.id, goods: "手机" };
console.log("接口3:获取到用户订单:", order);
callback(order);
}, 1000);
}
// 多个异步操作依赖,出现回调地狱
getUserID(function(userId) {
getUserInfo(userId, function(userInfo) {
getUserOrder(userInfo, function(order) {
console.log("所有异步操作完成,最终订单:", order);
});
});
});
2. 回调地狱的问题(必记)
- 代码可读性极差:嵌套层级越多,代码越像 "金字塔",后期难以维护和修改;
- 可调试性差:一旦某个回调出现错误,很难定位到具体问题;
- 代码复用性差:嵌套的回调函数,无法单独提取复用。
3. 回调地狱的临时解决方案(过渡)
在 Promise 出现之前,我们可以通过 "函数拆分" 的方式,临时缓解回调地狱(但不能彻底解决)。
javascript
运行
// 拆分回调函数,单独定义
function handleUserId(userId) {
getUserInfo(userId, handleUserInfo);
}
function handleUserInfo(userInfo) {
getUserOrder(userInfo, handleUserOrder);
}
function handleUserOrder(order) {
console.log("所有异步操作完成,最终订单:", order);
}
// 调用:无嵌套,代码更清晰
getUserID(handleUserId);
四、高频坑点与面试题
坑点 1:混淆同步和异步的执行顺序
javascript
运行
console.log("1");
setTimeout(() => {
console.log("2");
}, 0);
console.log("3");
// 输出顺序:1 → 3 → 2(重点!)
// 原因:即使定时器延迟为0,也是异步操作,会被挂起,先执行同步代码
坑点 2:回调函数中的 this 指向问题
异步回调函数(如 setTimeout、事件回调)中,this 指向全局对象(Window),容易出现指向混乱。
javascript
运行
const obj = {
name: "张三",
sayHi() {
// 异步回调中的 this 指向 Window
setTimeout(function() {
console.log(this.name); // undefined(Window 没有 name 属性)
}, 1000);
}
};
obj.sayHi();
// 解决方法1:用变量保存 this(that = this)
const obj2 = {
name: "张三",
sayHi() {
const that = this; // 保存当前 this(obj2)
setTimeout(function() {
console.log(that.name); // 张三
}, 1000);
}
};
// 解决方法2:用箭头函数(箭头函数无自己的 this,继承外层 this)
const obj3 = {
name: "张三",
sayHi() {
setTimeout(() => {
console.log(this.name); // 张三(继承 obj3 的 this)
}, 1000);
}
};
面试题 1:什么是异步编程?JS 为什么需要异步?
答:异步编程是指代码执行时,不等待异步操作完成,继续执行后续代码,等异步操作完成后,再执行回调函数的编程方式。JS 需要异步的核心原因是:JS 是单线程语言,若没有异步,遇到需要等待的操作(如接口请求),代码会阻塞,页面会卡死,异步能避免阻塞,提升代码执行效率。
面试题 2:什么是回调函数?什么是回调地狱?如何缓解回调地狱?
答:
- 回调函数:作为参数传递给另一个函数,在异步操作或事件完成后被调用的函数;
- 回调地狱:多个有依赖关系的异步操作,导致回调函数嵌套嵌套再嵌套,形成 "金字塔" 结构,可读性和可维护性极差;
- 缓解方式:函数拆分(将嵌套的回调拆分为单独函数),最终解决方案是用 Promise、async/await 替代回调函数。
面试题 3:setTimeout (fn, 0) 的执行机制是什么?
答:setTimeout (fn, 0) 并不是立即执行,而是将回调函数放入 "异步任务队列",等当前所有同步代码执行完成后,再执行该回调函数。即使延迟为 0,也会先执行同步代码,再执行异步回调。
五、总结(核心要点)
- 同步:顺序执行,前一行完成再执行下一行,会阻塞代码;异步:不等待,先执行后续代码,异步完成后执行回调,不阻塞;
- 回调函数:异步编程的基础,作为参数传递,在异步操作完成后被调用;
- 回调地狱:多异步依赖导致的回调嵌套问题,可通过函数拆分临时缓解;
- 关键坑点:异步回调的 this 指向(Window)、setTimeout (fn, 0) 不会立即执行;
- 后续预告:回调函数只是异步编程的入门方式,下一篇我们将学习 Promise,彻底解决回调地狱问题。
掌握了回调函数,就掌握了异步编程的基础,后续的 Promise、async/await 都是基于回调函数的优化,一定要吃透本文的核心逻辑。
📌 所有代码可直接复制到浏览器控制台运行,动手实操,重点感受同步和异步的执行顺序差异。