想要真正玩转现代前端,搞懂 Promise 的底层机制、链式调用以及宏任务/微任务的执行顺序是必经之路。这篇攻略将带你从零到一彻底吃透 Promise。
一、什么是 Promise?(三大状态与生命周期)
1.0 为什么需要 Promise?
JavaScript 是单线程语言——同一时刻只能做一件事。但现实中的操作往往很慢:请求接口、读取文件、下载图片……如果代码傻等这些操作完成再继续,整个页面就冻住了。
所以 JS 采用了异步编程:发起慢操作后不等待,先往下跑,等结果回来了再处理。
最早的写法是回调函数——把”拿到结果后做什么”作为函数传进去:
// 这些函数内部都是异步的——发网络请求,不等结果就先返回
function getUser(id, callback) {
var xhr = new XMLHttpRequest();
xhr.open("GET", "/api/user/" + id);
xhr.onload = function () {
if (xhr.status === 200) {
var user = JSON.parse(xhr.responseText);
callback(user); // 把 user 传给回调
}
};
xhr.send(); // 发出请求,函数立刻返回,等响应回来再调 callback
}
function getOrders(userId, callback) {
/* 同理 */
}
function getShipping(orderId, callback) {
/* 同理 */
}
// 回调写法:先查用户,再查订单,再查物流
getUser(userId, function (user) {
getOrders(user.id, function (orders) {
getShipping(orders[0].id, function (shipping) {
console.log(shipping); // 终于拿到了
});
});
});
三层就很难读了,真实项目里五层六层是家常便饭——这就是著名的回调地狱(Callback Hell):代码横向生长、错误处理混乱、逻辑难以追踪。
Promise 就是为了解决回调地狱而生的。 同样的逻辑,用 Promise 链式调用改写:
// 以下 getUser/getOrders/getShipping 是返回 Promise 的新版函数,与上面的回调版签名不同
getUser(userId)
.then(user => getOrders(user.id))
.then(orders => getShipping(orders[0].id))
.then(shipping => console.log(shipping))
.catch(err => console.error(err)); // 任何一步出错,统一捕获
代码从”向右缩进”变成”向下排列”,逻辑清晰,错误处理也不再四处散落。
后来 ES2017 又推出了 async/await,在 Promise 之上再加一层语法糖,让异步代码看起来和同步代码一模一样:
const user = await getUser(userId);
const orders = await getOrders(user.id);
const shipping = await getShipping(orders[0].id);
所以演进路线是:回调函数 → Promise → async/await。Promise 是承上启下的关键——理解了它,async/await 自然就会了。
async/await 并没有替代 Promise。 它是 Promise 的语法糖——
await后面接的就是 Promise,编译后跑的也是 Promise。两者是搭档,不是替代:
- async/await 处理串行逻辑:一步接一步,写起来像同步代码,阅读体验好
- Promise 静态方法处理并发场景:
Promise.all、Promise.race、Promise.any这些并发调度,async/await没有等价写法,必须直接用 Promise// 串行写法:一个接一个等,总耗时 = 请求1 + 请求2 + 请求3 const users = await fetchUsers(); // 等2秒 const orders = await fetchOrders(); // 再等3秒 const products = await fetchProducts(); // 再等1秒 // 总共等了 6 秒 // 并发写法:三个请求同时发出,总耗时 = 最慢的那个 const [users, orders, products] = await Promise.all([ fetchUsers(), // ┐ fetchOrders(), // ├─ 同时发出,同时等 fetchProducts(), // ┘ ]); // 总共只等了 3 秒(取最长的那个)实际开发中的常态是:async/await 为主,Promise 静态方法打配合。两者混着用才完整。
技术定义
Promise 在 ES6(ECMAScript 2015) 中被正式纳入 JavaScript 语言规范。它是内置对象——就像 Array、Object、Date 一样,只要你在浏览器或 Node.js 环境里写 JS,直接用 new Promise() 就能跑,不需要引入任何第三方库。规范由 TC39(ECMA 国际技术委员会第 39 号)制定,他们决定了 JS 语言每年会长出什么新特性。
抽象来说,Promise 是一个容器,里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果。
1.1 核心:三大状态
一个 Promise 必然处于以下三种状态之一,且状态的流转是单向、不可逆的:
- Pending(进行中):初始状态,异步操作还在进行。
- Fulfilled(已成功):操作成功完成,此时会持有一个终值(Value)。
- Rejected(已失败):操作失败,此时会持有一个拒因(Reason)。
铁律:状态只能从
Pending -> Fulfilled或Pending -> Rejected。一旦状态改变,就会永远”凝固”,之后任何操作都无法再改变它的状态。注意:Fulfilled 和 Rejected 统称为 Settled(已定型)。Settled 不是第三种状态,而是对两种终态的统称——表示 Promise 已经尘埃落定。
1.2 基础语法
const myPromise = new Promise((resolve, reject) => {
// 执行异步操作
setTimeout(() => {
const success = true;
if (success) {
resolve("这是成功返回的数据"); // 将状态变为 Fulfilled
} else {
reject("这是错误信息"); // 将状态变为 Rejected
}
}, 1000);
});
二、消费 Promise:实例方法
当 Promise 状态改变后,我们需要通过它的实例方法来编写后续的业务逻辑。
2.1 .then()、.catch() 与 .finally()
.then(onFulfilled, onRejected):接收两个回调函数。第一个在成功时触发,第二个在失败时触发(通常省略第二个,统一用 catch 捕获)。.catch():专门用来捕获 Promise 的异常或前面.then链条中抛出的错误。.finally():无论成功还是失败,最终都会执行。适合用来关闭 Loading 动画、释放资源等。
穿透特性:
.finally()的回调不接收参数,且会穿透原始值或拒因——即.finally()后面链的.then()拿到的仍然是.finally()之前的值,不会被吞掉。
Promise.resolve("hello")
.finally(() => {
console.log("清理资源"); // 不接收参数,也不知道前面的值
})
.then(val => {
console.log(val); // "hello" —— 值穿透了 finally
});
2.2 核心难点:.then() 的链式调用与”返回值规则”
.then() 方法执行后,一定会返回一个新的 Promise 实例,这也是为什么我们可以一直 .then().then() 链式调用的原因。新 Promise 的状态由上一个 .then() 回调函数的返回值决定:
- 返回普通值(或不返回):下一个
.then的新 Promise 会立刻变成Fulfilled,并将这个值传给下一个.then。 - 抛出错误(
throw new Error):新 Promise 会立刻变成Rejected,并被最近的.catch捕获。 - 返回一个新的 Promise:新 Promise 的状态和结果,完全取决于返回的这个 Promise 的状态(这正是串行异步请求的核心原理)。
// 示例:串行逻辑
fetchUser()
.then(user => {
// 规则 3:返回一个新的 Promise
return fetchUserOrders(user.id);
})
.then(orders => {
// 规则 1:返回普通值
return orders.length;
})
.then(count => {
console.log(`订单总数:${count}`);
})
.catch(err => {
// 整个链条中任何一个地方报错,都会直接跳到这里
console.error(err);
});
三、并发调度:六大静态方法
除了实例方法,Promise 构造函数本身还提供了 6 个极其强大的并发调度工具。
| 方法 | 核心机制 | 适用场景 |
|---|---|---|
Promise.all([p1, p2]) | 全胜才胜,一败俱败。所有人成功才返回结果数组;只要有一个失败,立刻返回该失败。 | 同时请求多个独立接口,全部拿到后才渲染页面。 |
Promise.allSettled([p1, p2]) | 永不失败。等待所有人结束(无论成败),返回包含每个 Promise 最终状态和结果的数组。 | 多个接口互不影响,只需要知道它们是不是都执行完了(如批量上传)。 |
Promise.race([p1, p2]) | 速度对决。看谁跑得快,最先改变状态的(无论成败)作为最终结果。 | 异步操作超时控制(让接口请求和超时定时器赛跑)。 |
Promise.any([p1, p2]) | 一胜即胜,全败才败。只要有一个成功就返回该成功;如果全部失败,抛出 AggregateError。 | 多源检测。比如从三个不同 CDN 镜像下载同一个文件,谁快用谁。 |
Promise.resolve(val) | 直接创建一个处于 Fulfilled 状态的 Promise。 | 将现有的同步数据包裹成 Promise,兼容异步接口。 |
Promise.reject(reason) | 直接创建一个处于 Rejected 状态的 Promise。 | 快速构建一个拒绝状态,用于测试或中断链条。 |
注意:
Promise.all的”一败俱败”不等于取消其余 Promise。某个 Promise reject 后,Promise.all返回的 Promise 立即 reject,但其余 Promise 仍然会执行完毕,只是结果被忽略。如果你需要真正取消,应使用AbortController。
四、终极语法糖:async / await
虽然 Promise 解决了回调地狱,但过多的 .then() 依然会让代码横向延伸。ES2017 引入了 async/await,允许我们用同步的写法来写异步代码。
4.1 黄金法则
async关键字修饰的函数,必然返回一个 Promise。await只能在async函数内部或 ES Module 顶层使用(Top-level await,ES2022 起支持)。await后面通常跟着一个 Promise。它会暂停当前 async 函数的执行,等待 Promiseresolve,并直接拿到它的终值。
暂停的是函数,不是程序。
await把当前函数从调用栈上拿下来,事件循环继续跑别的代码(页面不卡、其他函数正常执行)。等 Promise resolve 了,函数从暂停的位置恢复执行。对函数内部来说感觉像同步,对整个程序来说没有任何阻塞。
4.2 错误处理
由于没有了 .catch() 链条,在 async/await 中我们使用传统的 try...catch 来捕获异常:
async function getUserData() {
try {
const user = await fetchUser(); // 暂停,等结果
const orders = await fetchOrders(user.id); // 再暂停,等结果
return orders;
} catch (error) {
console.error("捕获到异步错误:", error);
}
}
五、底层进阶:Promise 与事件循环(Event Loop)
要想真正精通 Promise,必须明白它在 JavaScript 执行机制中的位置——微任务(Microtask)。而要理解微任务,首先要理解事件循环。
5.0 什么是事件循环
JavaScript 是单线程的——同一时刻只能执行一段代码。但现实中同时有很多事要处理:定时器到了、网络请求回来了、用户点了按钮……这些回调不可能同时执行,得排队。事件循环就是那个”叫号”的机制,它决定谁先跑谁后跑。
用伪代码理解事件循环的本质:
while (true) {
if (宏任务队列里有任务) {
取出一个宏任务,执行它
把微任务队列里的任务全执行完
(可能渲染页面)
}
}
事件循环是谁提供的?
事件循环不是 JavaScript 语言本身的特性,而是宿主环境提供的。
- ECMAScript 规范只定义了 Job Queue(作业队列)的概念,比如
PromiseJobs——这是 Promise 回调的内部队列 - HTML 规范定义了完整的 Event Loop 机制:Task Queue + Microtask Queue + 渲染时机
- Node.js 基于 libuv 实现了自己的事件循环(6 个阶段),跟浏览器的模型有差异
所以 Promise 的 .then 回调怎么排队,是 ECMAScript 说了算;但什么时候轮到它执行,是宿主环境(浏览器/Node.js)的事件循环说了算。
渲染什么时候发生?
事件循环每一轮末尾,浏览器可能会渲染页面。关键词是”可能”——不是每一轮都渲染:
| 场景 | 是否渲染 |
|---|---|
| 60Hz 屏幕,距上次渲染约 16.7ms | 渲染(对齐屏幕刷新率) |
| 距上次渲染才过了 5ms | 跳过,等下一个刷新周期 |
| 页面在后台标签页 | 大幅降频(约 4fps),甚至不渲染 |
| 没有任何 DOM 变化 | 跳过 |
这意味着:多个宏任务可能在同一帧内连续执行,只在最后统一渲染一次。
Task 1 → 微任务清空 → Task 2 → 微任务清空 → Task 3 → 微任务清空 → 渲染(一次)
|_________________________ 16.7ms 一帧 _________________________|
这也是为什么 requestAnimationFrame 既不是宏任务也不是微任务——它在渲染阶段执行,在微任务之后、实际绘制之前。
5.1 两种任务
JavaScript 是单线程的。同步代码直接在调用栈上执行,不经过事件循环;而异步回调需要排队等事件循环来调度,这些异步回调被分为宏任务(Macrotask)和微任务(Microtask):
- 宏任务:
setTimeout、setInterval、I/O、UI 事件等(脚本首次执行也是一个 task)。 - 微任务:
Promise的.then/catch/finally回调、MutationObserver回调、queueMicrotask()注册的函数。
术语纠正:社区习惯叫”宏任务”,但规范里的正式术语是 Task(任务),没有 “Macrotask” 这个词——它是社区为了跟 “Microtask” 对称而造的说法。真正的术语对应关系:
社区说法 规范正式术语 出处 宏任务 / Macrotask Task HTML 规范 宏任务队列 Task Queue HTML 规范 微任务 / Microtask Microtask HTML 规范 微任务队列 Microtask Queue HTML 规范 Promise 回调的内部队列 PromiseJobs ECMAScript 规范 两个规范的衔接:HTML 规范定义了事件循环的结构(Task Queue + Microtask Queue),ECMAScript 规范定义了
PromiseJobs。HTML 规范要求浏览器将 ECMAScript 的EnqueueJob操作映射到 Microtask Queue——所以 Promise 的.then/catch/finally回调就进了微任务队列。
注意:
requestAnimationFrame既不是宏任务也不是微任务,它是浏览器渲染流水线中的一步——在微任务执行完之后、浏览器绘制之前执行。requestIdleCallback则是优先级最低的 Task,只在浏览器空闲时才执行。
5.2 事件循环怎么跑
事件循环的核心规则只有一条:每次只执行一个宏任务,但必须把微任务队列全部执行完,才能进入下一个宏任务。
用一个完整的流程图来理解:
┌─────────────────────────────────────────────────┐
│ 事件循环(一轮) │
│ │
│ ① 从宏任务队列取出一个任务,放进调用栈执行 │
│ ↓ │
│ ② 执行过程中,可能往微任务队列里塞入新的微任务 │
│ (比如 .then 回调、queueMicrotask) │
│ ↓ │
│ ③ 宏任务执行完毕,调用栈空了 │
│ ↓ │
│ ④ 从微任务队列逐个取出任务执行 │
│ 执行一个微任务时,又可能产生新的微任务 │
│ → 继续取,继续执行,直到微任务队列彻底为空 │
│ ↓ │
│ ⑤ 微任务队列空了 → 浏览器可能渲染页面(rAF 在这执行) │
│ ↓ │
│ 回到 ①,取下一个宏任务 │
└─────────────────────────────────────────────────┘
关键理解点:
- “清空微任务队列”= 逐个取出来执行,直到没有为止,不是丢弃、不是跳过,是老老实实一个一个跑完
- 微任务可以在执行中产生新的微任务(比如
.then链),新产生的也会在当前这轮被一起执行完 - 微任务的优先级高于下一个宏任务——不管宏任务等了多久,只要微任务队列里有东西,就必须先处理
用生活中的例子来理解:
去银行办业务。柜员(主线程)一次只服务一个客户(宏任务),但这个客户可能有好几个小需求(微任务)——存钱、改密码、打印流水。柜员必须把当前客户的所有小需求都办完,才能叫下一个号。中间如果又产生新的小需求(比如改密码时发现要先解锁),也必须在当前客户这里处理完。
5.3 经典面试题测试
你能快速说出以下代码的打印顺序吗?
console.log("1"); // 同步
setTimeout(() => {
console.log("2"); // 宏任务
}, 0);
Promise.resolve()
.then(() => {
console.log("3"); // 微任务
})
.then(() => {
console.log("4"); // 微任务的微任务
});
console.log("5"); // 同步
逐行推演:
- 脚本作为第一个宏任务开始执行。
console.log('1')——同步,直接打印 1。setTimeout(..., 0)——遇到定时器,回调函数丢进宏任务队列,不执行。Promise.resolve().then(...)——遇到.then,回调函数丢进微任务队列,不执行。console.log('5')——同步,直接打印 5。- 第一个宏任务(脚本)执行完了,开始逐个执行微任务队列:
- 取出第一个微任务,打印 3。执行时第二个
.then被触发,新微任务丢进队列。 - 继续取,打印 4。微任务队列空了。
- 取出第一个微任务,打印 3。执行时第二个
- 微任务队列清空,取下一个宏任务:执行
setTimeout回调,打印 2。
最终顺序:
1 → 5 → 3 → 4 → 2
六、实战技能:如何用 Promise 解决实际问题?
6.1 实现一个 Promise 超时控制(Promise.race)
function timeoutWrapper(requestPromise, timeout = 3000) {
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error("请求超时!")), timeout);
});
// 赛跑:谁快听谁的
return Promise.race([requestPromise, timeoutPromise]);
}
// 使用
timeoutWrapper(fetch("/api/data"), 2000)
.then(res => console.log(res))
.catch(err => console.error(err)); // 如果2秒内没返回,触发超时错误
6.2 异步并发限制调度器(高频大厂面试题)
如果有一百个分片上传请求,同时并发会撑爆浏览器。如何限制同一时刻只有 N 个请求在执行?
class ConcurrentScheduler {
constructor(limit) {
this.limit = limit; // 最大并发数
this.runningCount = 0; // 当前正在运行的任务数
this.queue = []; // 等待队列
}
add(task) {
return new Promise((resolve, reject) => {
// 将任务包装后放入队列
this.queue.push({ task, resolve, reject });
this.runNext();
});
}
runNext() {
if (this.runningCount >= this.limit || this.queue.length === 0) return;
this.runningCount++;
const { task, resolve, reject } = this.queue.shift();
task()
.then(resolve)
.catch(reject)
.finally(() => {
this.runningCount--;
this.runNext(); // 释放位置后,立刻执行下一个
});
}
}
// 使用示例
const scheduler = new ConcurrentScheduler(2); // 限制并发数为 2
const timeoutTask = (time, id) => () =>
new Promise(resolve =>
setTimeout(() => {
console.log(`任务 ${id} 完成`);
resolve(id);
}, time)
);
scheduler.add(timeoutTask(1000, "A"));
scheduler.add(timeoutTask(500, "B"));
scheduler.add(timeoutTask(300, "C")); // 会在 B 完成后立刻加入执行
掌握了 Promise 的状态流转、链式返回规则、静态并发 API 以及事件循环微任务机制,你就拿到了通往现代前端高级架构的入场券。希望这份攻略能帮你彻底扫清异步盲区!