前言
你有没有过这种体验:打开一个网页,点击按钮后页面直接 “僵住”,转圈圈转了半天才有反应?这背后,其实是 JS “单线程” 的小脾气在作祟。那什么是线程呢?进程又是什么?
进程:cpu 接受一个指令,到加载完上下文环境所需要的时间。
线程:cpu 执行指令所需的时间。
咱们先把专业词翻译成人话:进程就是你打开游戏时,电脑给它分配 “房间” 的时间;线程则是游戏里角色 “放技能” 的执行时间。而 JS 这货,默认只开一个 “技能槽”(单线程)—— 这是当年设计师为了省你家老电脑的性能才这么搞的。感兴趣的可以去了解一下 JS 的历史,10天就创造出来了,当时的电脑不像现在可以同时打开多个软件,只能打开一个。所以 js 默认是单线程运行的。
但问题来了:如果 JS 遇到 “加载 100 张图片” 这种慢活儿,总不能让页面卡成 PPT 吧?于是它想了个招:把耗时的活儿 “挂起”,先干快的(同步代码),等慢活儿干完了再回来处理。这就是 “异步” 的核心逻辑。
一、异步初体验:“先斩后奏” 的setTimeout
先看一个例子:
let a = 1;
setTimeout(() => {
a = 2;
}, 1000)
console.log(a);
大声告诉我a的值是多少?没错答案是1。那问题来了,为什么不是2呢?v8不是从上往下执行吗,那为啥不是2?这就是我们要讲的异步了。
这里 JS 就像个 “急性子”:看到setTimeout是个 1 秒后才做的事儿,直接把它丢到 “待办箱”,V8 主线程优先执行同步代码,异步任务(如 setTimeout 回调)会被丢进宏任务队列排队。先执行 let a=1 和 console.log(a)(输出 1),同步代码跑完后,主线程才会在 1 秒后执行队列里的回调,修改 a=2。
核心: 同步先执行,异步排队等主线程空闲。
二、回调地狱:嵌套到让你 “眼花缭乱”
为了处理异步,最早的办法是 “回调函数”—— 让慢活儿干完了自己喊一声。但如果要做 “连环任务”,就会变成这样:
let a = 1;
function foo() {
// 1秒后执行:改a为2,再喊 bar来干活
setTimeout(() => {
a = 2;
console.log('foo',a); // 1秒后输出foo 2
bar();
}, 1000)
}
function bar() {
// 再等2秒:改a为3,再喊 baz来干活
setTimeout(() => {
a = 3;
console.log('bar',a); // 3秒后输出bar 3
baz();
}, 2000)
}
function baz(){
console.log(a); // 3秒后输出3
}
console.log(a); // 先输出1
foo();
是不是看着贼乱了,解析一下:
1. 先执行同步代码:
- 定义
a = 1,再定义foo/bar/baz三个函数(函数定义不执行代码,只是 “存着”); - 直接执行
console.log(a),此时a还是初始值 1,所以先输出1; - 调用
foo()函数,进入foo内部。
2. 遇到异步任务,挂起后继续执行同步(此处同步已无其他代码,等待异步):
foo里的setTimeout是异步任务(1 秒后执行),JS 不等待,直接把它丢进 “异步任务队列”,然后foo函数执行完毕;- 1 秒后,
setTimeout的回调触发:把a改成 2,输出foo 2,接着调用bar()。
3. 链式触发下一个异步任务:
bar里的setTimeout也是异步任务(2 秒后执行),再次被丢进任务队列;- 2 秒后(从
bar调用开始算),回调触发:把a改成 3,输出bar 3,接着调用baz()。
4. 最后执行同步回调:
baz里是同步代码,直接输出当前a的值 3。
输出结果与分析一致(代码可以自己运行一下,因为照片看不出延迟~):
简单来说就是把下一个要调用的强制塞到上一个的函数体里面,这样程序就必须按你的顺序来执行。但是这种方法你看,foo里套bar,bar里套baz—— 如果任务再多几层,代码就会变成 “金字塔形”,缩进能把人看瞎!这就是前端老鸟闻之色变的 “回调地狱”。不推荐,不推荐,不推荐!
三、Promise:给异步穿件 “体面的外套”
那一直嵌套也不是个办法,为了拯救被嵌套折磨的程序员,ES6 推出了大名鼎鼎的 “Promise”—— 把异步任务包装成 “状态机”,让代码从 “嵌套” 变成 “链式调用”,瞬间清爽!
咱就拿我们年轻人最喜欢的旅游举例:
// 第一步:订高铁票(3秒后完成,结果是“购票成功”)
function train() {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('查询车次成功');
resolve('购票成功');
}, 3000)
});
}
// 第二步:订酒店(2秒后完成,结果是“预定酒店成功”)
function hotel() {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('筛选酒店成功');
resolve('预定酒店成功');
}, 2000)
})
}
// 第三步:出发旅行(1秒后完成)
function travel() {
setTimeout(() => {
console.log('收拾行李,快乐出发!');
}, 1000)
}
// 订高铁票→订酒店→出发旅行
train()
.then(() => {
return hotel(); // 高铁票订好后,才执行订酒店
})
.then(() => {
travel(); // 酒店订好后,才执行出发
})
.catch((err) => {
console.log('旅行计划泡汤:', err); // 只要中间有失败,就会跳到这里
})
这就是Promise 处理 “依赖型异步任务” 的典型案例—— 核心是模拟 “订高铁票→订酒店→出发旅行” 的流程:三步必须按顺序来,前一步失败,后续步骤直接跳过,统一走错误处理。我们一起来看看是怎么个事:
一、先认清楚:每个函数的作用(异步任务的包装)
1. train():订高铁票(第一步异步任务)
-
返回
Promise对象:Promise 就像一个 “任务跟踪器”,专门记录异步任务的 “成功 / 失败” 状态 -
内部用
setTimeout模拟 “3 秒查询 + 购票” 的耗时操作(比如真实场景中调用 12306 接口)- 3 秒后先打印
查询车次成功(表示 “查询” 这个前置动作完成) - 调用
resolve('购票成功'):告诉 Promise“这个任务成了!结果是‘购票成功’”,并把结果传递给下一个步骤
- 3 秒后先打印
2. hotel():订酒店(第二步异步任务)
-
和
train()结构完全一致,只是 “耗时更短、任务不同”:- 模拟 “2 秒筛选 + 预订酒店” 的耗时操作(比如调用携程 / 飞猪接口)
- 2 秒后打印
筛选酒店成功(前置动作完成) - 调用
resolve('预定酒店成功'):标记 “订酒店成功”,传递结果给下一个步骤
3. travel():出发旅行(第三步任务)
- 注意:它是普通异步函数(没有返回 Promise),只负责 “执行最终动作”
- 用
setTimeout模拟 “1 秒收拾行李” 的准备时间,最后打印收拾行李,快乐出发! - 只有前两步(订票、订酒店)都成功,它才会被执行
先看都成功的输出结果:
二、核心逻辑:链式调用的 “执行流程”(一步步看清楚)
train()
.then(() => {
return hotel(); // 高铁票订好后,才执行订酒店
})
.then(() => {
travel(); // 酒店订好后,才执行出发
})
.catch((err) => {
console.log('旅行计划泡汤:', err); // 只要中间有失败,就会跳到这里
})
Promise 链式调用的核心规则是 “成功则串行执行,全程无失败则不触发 catch” ,咱们模拟实际执行过程:
1. 代码开始执行,先调用 train():启动 “3 秒查询 + 购票” 的异步任务(主线程不等待,继续往下走,但这里没有其他同步代码,所以等异步结果)
2. 3 秒后,train() 执行完毕:
- 打印
查询车次成功 - 触发
resolve('购票成功'),标记 “第一步成功”
3. 第一步成功后,自动执行第一个 .then():
- 这个
.then()里返回hotel(),启动 “2 秒筛选 + 订酒店” 的异步任务(因为返回了 Promise,所以下一个.then()会等这个 Promise 完成)
4. 又过了 2 秒,hotel() 执行完毕:
-
打印
筛选酒店成功 -
触发
resolve('预定酒店成功'),标记 “第二步成功” 5. 第二步成功后,自动执行第二个.then(): -
调用
travel(),启动 “1 秒收拾行李” 的异步任务
6. 再等 1 秒,travel() 执行完毕:
- 打印
收拾行李,快乐出发!
7. 全程没有任何 reject(失败),所以最后的 .catch() 完全不会执行
如果有有一步有错误:
function train() {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('查询车次成功');
reject('票已售罄'); // 改为 '票已售罄'
}, 3000)
});
}
输出结果:
这样就打印出了计划泡汤,票已售罄。
三、关键知识点:为什么这么写比回调函数好?
1. 强制保证 “依赖顺序”
比如 “必须订完票才能订酒店,必须订完酒店才能出发”—— 如果不用 Promise,直接写三个 setTimeout,会因为异步任务 “谁先完成不一定”,导致 “还没订票就订酒店” 的逻辑错误。而 .then() 链式调用,天然保证 “前一步完成,后一步才执行”。
2. 代码简洁,无嵌套(告别回调地狱)
如果用传统回调函数实现同样的逻辑,会变成 “嵌套金字塔”,可读性极差:
// 回调地狱写法(不推荐)
train((trainRes) => {
console.log('查询车次成功');
if (trainRes === '购票成功') {
hotel((hotelRes) => {
console.log('筛选酒店成功');
if (hotelRes === '预定酒店成功') {
travel();
}
});
}
},
(err) => {
console.log('旅行计划泡汤:', err);
});
对比之下,Promise 的链式调用是 “平铺式” 的,一眼就能看明白流程是 “train→hotel→travel”。
四、小拓展:如果想拿到每个步骤的 “成功结果” 怎么办?
当前代码的 .then() 里没有接收 resolve 传递的结果(比如 “购票成功”“预定酒店成功”),如果想在后续步骤中使用这些结果,只需给 .then() 加参数即可:
train()
.then((trainResult) => {
console.log('第一步结果:', trainResult); // 输出“第一步结果:购票成功”
return hotel();
})
.then((hotelResult) => {
console.log('第二步结果:', hotelResult); // 输出“第二步结果:预定酒店成功”
travel();
})
.catch((err) => {
console.log('旅行计划泡汤:', err);
});
这样就能在每个 .then() 里拿到上一步的成功结果,灵活用于后续逻辑。
Promise的妙处在于:
- 用
resolve表示 “任务成了”,reject表示 “任务黄了”; - 用
.then()接下一个任务,用.catch()抓所有错误; - 彻底告别嵌套,代码像 “流水线” 一样清晰!
这也是 Promise 比传统回调函数更受欢迎的核心原因 —— 让异步流程 “看得见、管得住”~
四、手写 Promise:揭开它的 “神秘面纱” (面试手写题!)
最后咱们来扒一扒Promise的底层逻辑,Promise大概就长这样:
class MyPromise {
constructor(fn) {
// 初始化状态:pending(等待)、fulfilled(成功)、rejected(失败)
this.status = 'pending';
// 成功时的值
this.value = undefined;
// 失败时的原因
this.reason = undefined;
// 成功回调的队列(可能多个.then)
this.onFulfilledCallbacks = [];
// 失败回调的队列
this.onRejectedCallbacks = [];
// 成功函数:把状态改成fulfilled,执行所有成功回调
const resolve = (value) => {
if (this.status === 'pending') {
this.status = 'fulfilled';
this.value = value;
// 依次执行.then里的回调
this.onFulfilledCallbacks.forEach(cb => cb(this.value));
}
}
// 失败函数:把状态改成rejected,执行所有失败回调
const reject = (reason) => {
if (this.status === 'pending') {
this.status = 'rejected';
this.reason = reason;
// 依次执行.catch里的回调
this.onRejectedCallbacks.forEach(cb => cb(this.reason));
}
}
// 执行传入的异步函数
try {
fn(resolve, reject);
} catch (err) {
reject(err);
}
}
// .then方法:注册成功/失败的回调
then(onFulfilled, onRejected) {
// 如果没传成功回调,默认透传value
onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : value => value;
// 如果没传失败回调,默认透传reason
onRejected = typeof onRejected === 'function' ? onRejected : reason => { throw reason };
// 返回新的Promise,实现链式调用
return new MyPromise((resolve, reject) => {
if (this.status === 'fulfilled') {
// 成功状态:直接执行回调
setTimeout(() => {
try {
const result = onFulfilled(this.value);
// 回调的返回值传给下一个.then
resolve(result);
} catch (err) {
reject(err);
}
}, 0);
}
if (this.status === 'rejected') {
// 失败状态:直接执行回调
setTimeout(() => {
try {
const result = onRejected(this.reason);
resolve(result);
} catch (err) {
reject(err);
}
}, 0);
}
if (this.status === 'pending') {
// 等待状态:把回调存到队列里
this.onFulfilledCallbacks.push(() => {
setTimeout(() => {
try {
const result = onFulfilled(this.value);
resolve(result);
} catch (err) {
reject(err);
}
}, 0);
});
this.onRejectedCallbacks.push(() => {
setTimeout(() => {
try {
const result = onRejected(this.reason);
resolve(result);
} catch (err) {
reject(err);
}
}, 0);
});
}
});
}
// .catch方法:就是.then的失败回调版
catch(onRejected) {
return this.then(null, onRejected);
}
}
当然,一个简化版的Promise最核心的就是这样:
class Promise{
constructor(fn){
function resolve(){
}
function reject(){
}
fn(resolve,reject);
}
}
简单说,Promise就是通过 “状态管理 + 回调队列”,把异步任务变得可控、可链式调用 —— 这就是它能解决回调地狱的关键!
结语:异步是 JS 的 “超能力”
其实从 “卡成 PPT” 的单线程,到 “丝滑连招” 的异步处理,JS 的异步逻辑就像生活里的 “多线程摸鱼”—— 你煮着泡面(异步),同时刷着手机(同步),泡面好了手机也没停,效率直接拉满。
而回调、Promise 这些工具,就是帮你把 “摸鱼流程” 理得更顺的秘籍:回调是 “喊一嗓子”,Promise 是 “排好队的流水线”。掌握了它们,你就能从 “被异步折磨” 变成 “把异步玩明白”。
下次再遇到页面卡顿、代码嵌套成山,不妨问问自己:
这活儿,能不能让 Promise 帮我管管?