前言
文章标题中提到的问题,对于已经掌握手写Promise的同学来说,应该是小菜一碟。因为我们在手写Promise
的时候,去写resolve
函数时,会明确知道函数内部有这样一个判断:
let resolve = (val) => {
// 如果Promise处于非pending状态,直接return
if (this.state !== "pending") return;
// something todo...
};
也就是说,其实所有写在Promise
里的resolve
函数都会执行,只不过只有第1个会成功执行完毕,并改变Promise
的状态,将它从pending => fullfilled
,而剩下的进入执行后,由于条件语句的判断逻辑,直接return
。
我们也可以通过真实的代码验证一下这个结论:
new Promise((resolve, reject) => {
resolve('First call to resolve');
resolve('Second call to resolve'); // 这条调用会被忽略
reject('Call to reject'); // 这条调用也会被忽略
}).then(value => {
console.log(value); // 输出: First call to resolve
}).catch(reason => {
console.error(reason);
});
如果我们在自己手写的Promise
函数里,在resolve
和reject
中,在return
的逻辑前面加一行console.log
的话,就会是这样的效果
这里不知道你是否会有一个疑问,为啥还会在最后一行输出一次resolve
的console.log
语句,并且value
的值还是undefined
?
这就考察了你关于Promise
支持链式调用的知识点了(实例方法Promise.prototype.then
返回的是一个新的Promise
实例,并且这个.........)。
(先卖个关子,正文会有详解~)
因此,本篇文章以我本人遇到的一道面试题为引,结合红宝书(《JavaScript高级程序设计》),和大家一起聊一聊Promise
。
异步任务与ES6+
异步任务与同步任务
在正式开启本章节内容之前,我想先问各位一个问题,如果让你用自己的语言进行组织,描述一下什么是异步任务,什么是同步任务,你会如何描述?
如果是我的话,我可能会这样描述:
- 同步任务:按顺序在执行栈中被调用的任务。
- 异步任务:会阻塞主线程执行的任务,因此需要放到消息队列中等待事件循环调度。
是不是感觉怪怪的,好像说的并不是很好,难道异步任务就不会按顺序被执行栈调用了吗?只不过多了一步从消息队列到执行栈的转移过程罢了。
所以,我之所以会问大家这么一个问题,就是想说,每当我们在学习或者回顾某个知识点的时候,如果不能找到合适的语言描述它,那就说明我们对它的理解还是不够到位,也就表明,我们还是需要去学习和理解那些被大多数人认可的,用来描述它的语言。
比如官方文档、或者知名的技术图书等等。
因此免不了俗,在直接进入Promise
知识分享、代码实操等环节之前,我们来一起看一下《JavaScript高级程序设计》这本书,它对于同步任务和异步任务的描述。
定义
同步任务(行为)
同步行为对应内存中顺序执行的处理器指令。每条指令都会严格按照它们出现的顺序来执行,而每条指令执行后也能立即获得存储在系统本地(如寄存器或系统内存)的信息。这样的执行流程容易分析程序在执行到代码任意位置时的状态(比如变量的值)。——《JavaScript高级程序设计》
看一个代码例子:
let num = 1;
num += 2;
// num: 3
在程序执行的每一步,都可以推断出程序的状态。这是因为后面的指令总是在前面的指令完成后才会执行。等到最后一条指定执行完毕,存储在num
的值就立即可以使用。
异步任务(行为)
异步行为类似于系统中断,即当前进程外部的实体可以触发代码执行。异步操作经常是必要的,因为强制进程等待一个长时间的操作通常是不可行的(同步操作则必须要等)。如果代码要访问一些高延迟的资源,比如向远程服务器发送请求并等待响应,那么就会出现长时间的等待。——《JavaScript高级程序设计》
我们也可以在看一个代码例子:
let num = 1;
setTimeout(() => num += 2, 2000);
// num: 3
这段程序最终与同步代码执行的任务一样,都是把两个数加在一起,但这一次执行线程不知道num
值何时会改变,因为这取决于回调函数何时从消息队列出列并执行。
当我们了解完上方这些有关同步/异步任务的描述内容之后,我们也发现,在这些内容中,提到了“消息队列”这个名词,其实这就与事件循环相联系了。
为了提升本文后续内容的阅读体验,大家可以先回顾一下什么是事件循环。
👇感兴趣的话,可以阅读下方这篇文章👇:
回调地狱
人们为了更好地统计收成、计算支出,于是发明了算盘🧮;
人们为了更快地两地往返、走亲访友,于是发明了汽车🚗;
人们为了更方便地与电脑完成交互、输入信息,于是发明了鼠标和键盘🖱;
综上所述,Promise
的出现,也应该是为了解决某个困扰人们的问题吧?
没错,那就是臭名昭著的“回调地狱”
在这里我不想举那种只是把几个setTimeout
嵌套在一起,意思一下,说明回调地狱的代码示例,那其实没什么意思,对面试的回答帮助也不大。
我们结合一种业务场景,去使用setTimeout
模拟异步行为,循序渐进地完成业务需求,之后再对写好的代码review
,从而确切地去感受何谓“回调地狱”。
好的,让我们以一个电子商务网站的业务场景为例,实现处理一个订单的需求。
要对一个订单完成一次完整的处理,需要遵循以下步骤:
- 检查库存
- 扣除库存
- 处理支付
- 更新订单状态
- 发送确认邮件
针对上述步骤,我们写5个不同的功能函数
function checkInventory(order, callback) {
// 模拟异步检查库存
setTimeout(() => {
console.log("Checked inventory for order:", order.id);
// 假设库存充足
callback(null, true);
}, 1000);
}
function deductInventory(order, callback) {
// 模拟异步扣除库存
setTimeout(() => {
console.log("Deducted inventory for order:", order.id);
callback(null);
}, 1000);
}
function processPayment(order, callback) {
// 模拟异步处理支付
setTimeout(() => {
console.log("Processed payment for order:", order.id);
callback(null);
}, 1000);
}
function updateOrderStatus(order, callback) {
// 模拟异步更新订单状态
setTimeout(() => {
console.log("Updated order status for order:", order.id);
callback(null);
}, 1000);
}
function sendConfirmationEmail(order, callback) {
// 模拟异步发送确认邮件
setTimeout(() => {
console.log("Sent confirmation email for order:", order.id);
callback(null);
}, 1000);
}
假如我们要使用上面写好的5个函数,完成一次订单处理,则从代码层面上,会是这个样子:
// 假设我们有一个订单对象
const order = { id: 123, items: ["nail"], paymentInfo: { method: "BANKCARD" } };
checkInventory(order, (err, inStock) => {
if (err) {
console.error("Error checking inventory:", err);
return;
}
if (!inStock) {
console.error("Not enough inventory for order:", order.id);
return;
}
deductInventory(order, (err) => {
if (err) {
console.error("Error deducting inventory:", err);
return;
}
processPayment(order, (err) => {
if (err) {
console.error("Error processing payment:", err);
return;
}
updateOrderStatus(order, (err) => {
if (err) {
console.error("Error updating order status:", err);
return;
}
sendConfirmationEmail(order, (err) => {
if (err) {
console.error("Error sending confirmation email:", err);
return;
}
console.log("Order processed successfully:", order.id);
});
});
});
});
});
写完之后真的是已老实求放过了....
我们来review
上面的代码:
首先每个异步操作都接受一个数据和一个回调函数。操作完成后,它会打印一条消息,并通过回调函数传递结果(或错误)。
然后操作继续一步一步地进行,回调函数被嵌套在另一个回调函数内部。
每个回调函数都增加了额外的缩进,使得代码结构变得混乱,而且错误处理也变得更加困难。
随着代码越来越复杂,回调策略是不具有扩展性的。“回调地狱”这个称呼可谓名至实归。 嵌套回调的代码维护起来就是噩梦。——《JavaScript高级程序设计》
此情此景,很难不大喊一声:“广智,救我!”
哦不是,串台了。
应该是大喊一声:“Promise
,救我!”
Promise(期约)
定义
Promise(期约) 是对尚不存在结果的一个替身。期约(promise)这个名字最早是由Daniel Friedman和David Wise 在他们于1976年发表的论文“The Impact of Applicative Programming on Multiprocessing”中提出来的。 但直到十几年以后,Barbara Liskov和Liuba Shrira在 1988年发表了论文“Promises: Linguistic Support for Efficient Asynchronous Procedure Calls in Distributed Systems”,这个概念才真正确立下来。同一时期的计 算机科学家还使用了“终局”(eventual)、“期许”(future)、“延迟”(delay)和“迟付”(deferred)等术语指代同样的概念。所有这些概念描述的都是一种异步程序执行的机制。——《JavaScript高级程序设计》
如何创建一个Promise
对象?
let p = new Promise(() => {});
注意,我们需要传入一个执行器(executor)作为构造函数的参数,比如示例中的
() => {}
,避免报错。
状态
Promise
是一个有状态的对象,它可能会处于以下3种状态之一:
fullfilled
:兑现- 必须不能转变为任何其他状态。
- 必须有一个值(value),且这个值不能改变。
rejected
:拒绝- 必须不能转变为任何其他状态。
- 必须有一个原因(reason),且这个原因不能改变
pending
:待定,可能会转变为“兑现”或“拒绝”的状态。
(我给每种状态附上了Promises/A+规范所设置的约束条件,大家应该能够一目了然)
除此之外,还有一点比较重要,需要大家知悉(并且这一点也是我们后续手写Promise需要注意的地方)
重要的是,
Promise
的状态是私有的,不能直接通过JavaScript检测到,并且Promise
的状态也不能被外部JavaScript代码修改。
Promise
的状态不能被外部读取以及不能被外部修改的原因其实是一样的,避免外部的同步代码读取和修改Promise
的状态,防止它们以同步方式处理Promise
对象。
用途
在【回调地狱】这一章节,我们提到了Promise
能够帮我们解决回调函数嵌套的问题。至于怎么解决、怎么替代的,其实并没有展开说,所以我们还是要聊一聊Promise
的用途和使用方式。
在最开始的【定义】章节,我们学习了这么一句话:
所有这些概念描述的都是一种异步程序执行的机制。
Promise
的第1个用途就藏在它的定义里,也就是抽象地表示一个异步操作。
Promise
的状态则代表着这个异步操作是否完成。
fullfilled
:表示异步操作已经成功完成。rejected
:表示异步操作没有成功完成,失败了。pending
:表示异步操作未完成,比如尚未开始或者正在进行中。
Promise
的第2个用途则是提供程序在Promise
状态改变时,能够访问异步操作实际生成的某个值的能力。
比如:
- 使用
Promise
发起一个HTTP
请求(新的Web API:fetch
,就是这么做的),试图获取商品列表数据,如果成功则返回一个数组,反之,返回错误信息(比如一个Error
类型的数据)。- 当
HTTP
请求成功,返回2开头的状态码时,我们就认为此时异步操作已经成功完成,即Promise
的状态从pending => fullfilled
,此时在Promise
内部就可以获取到一个数组(value)。 - 当
HTTP
请求失败,返回非2开头的状态码时,我们就认为此时异步操作没有成功完成,失败了,即Promise
的状态从pending => rejected
,此时在Promise
内部就可以获取到一个错误对象(reason)。
- 当
综上所述,我们可以总结为2条性质:
- 每个
Promise
只要状态切换为兑现(fullfilled
),就会有一个私有的内部值(value) - 每个
Promise
只要状态切换为拒绝(rejected
),就会有一个私有的内部原因(reason)
并且这2条性质还有对应的一些约束:
无论是值(value)还是理由(reason),都是包含原始值或对象的不可修改的引用。
二者都是可选的,而且默认值为
undefined
。在期约到达某个落定状态(即
fullfilled
或rejected
)时执行的异步代码始终会收到这个值或理由。
在上面的内容里,我们讲了Promise
的2种用途,并且一直在频繁地提到状态上的切换。接下来我们就一起来讲一讲如何控制Promise
的状态,即实现它的状态切换。
状态切换
我们可以通过2种方式完成对Promise
的状态落定(settled),即从pending => fullfilled/rejected
。
- 执行函数
- 静态方法
执行函数
通过【状态】这一章节我们了解到,Promise
的状态是私有的,因此只能在内部进行操作。
内部操作在Promise
的执行器函数(executor)中完成。
还记得它吗?没错,就是一开始我们在聊如何创建一个Promise
实例时,传给构造函数的那个函数参数。
let p = new Promise(() => {});
执行器函数主要有两项职责:
- 初始化期约的异步行为
- 控制状态的最终转换:通过调用它的两个函数参数实现的,这两个函数参数通常都命名为
resolve()
和reject()
。- resolve():调用
resolve()
会把状态切换为兑现 - reject():调用
reject()
会把状态切换为拒绝,并抛出错误
- resolve():调用
所以如果我们要充分发挥执行器函数的职责的话,我们得这样去创建一个Promise
实例:
let p = new Promise((resolve, reject) => {
resolve("Hello");
});
静态方法
除了使用执行函数去实现Promise
的状态落定外,我们还可以通过调用静态方法,直接实例化一个状态已经落定的Promise
。
-
Promise.resolve():
Promise.resolve()
等价于new Promise((resolve,reject)=>{resolve()})
let p1_settled = new Promise((resolve, reject) => { resolve(); }); let p2_settled = Promise.resolve();
-
Promise.reject():
Promise.reject()
等价于new Promise((resolve,reject)=>{reject()})
let p3_settled = new Promise((resolve, reject) => { reject(); }); let p4_settled = Promise.reject();
静默失败
无论
resolve()
和reject()
中的哪个被调用,状态转换都不可撤销了。于是继续修改状态会静默失败。
这就是呼应标题中那个问题的答案,当Promise中存在多个resolve,最终会执行哪一个?
实际上当第1个resolve()
被调用后,接下来无论后面跟着的是resolve()
还是reject()
,都无法再去实现状态转换的操作,也不能被完全执行,它们的操作都会静默失败。
Promise.prototype.then
经过上面内容的学习,我们知道了Promise
的用途、知道了执行器函数的两个作用,知道了如何让Promise
处于落定状态。
但这些其实都是前置条件,关键的问题在于,当一个Promise
处于落定状态之后,后续呢?
我该如何拿到异步操作可能产生的值(value)或原因(reason)呢?
这时候就要使用到一些Promise
的实例方法了,比如then
、catch
、finally
。
期约实例的方法是连接外部同步代码与内部异步代码之间的桥梁。这些方法可以访问异步操作返回的数据,处理期约成功和失败的结果,连续对期约求值,或者添加只有期约进入终止状态时才会执行的 代码。
使用
Promise.prototype.then()
是为Promise
实例添加处理程序的主要方法。
then()方法接收最多两个参数:
- onResolved【可选】:如果提供
onResolved
,则会在Promise
进入“兑现”状态时执行. - onRejected【可选】:如果提供
onRejected
, 则会在Promise
进入“拒绝”状态时执行。
我们来看一些代码示例:
function onResolved(id) {
console.log(id, "resolved");
}
function onRejected(id) {
console.log(id, "rejected");
}
let p_fullfilled = new Promise((resolve, reject) => setTimeout(resolve, 3000));
let p_rejected = new Promise((resolve, reject) => setTimeout(reject, 3000));
p_fullfilled.then(
() => onResolved("p_fullfilled"),
() => onRejected("p_fullfilled")
);
p_rejected.then(
() => onResolved("p_rejected"),
() => onRejected("p_rejected")
);
可以看到,处于兑现状态的p_fullfilled
会执行onResolved
函数,而处于拒绝状态的p_rejected
会执行onRejected
函数。
这里还有一些使用上的规范,需要大家注意一下。
- 如果你期望这个
Promise
只有1种类型的处理函数,比如只有onRejected
函数,则期望你能够给then()
方法的第一个参数传入undefined
p_rejected.then( undefined, () => onRejected("p_rejected") );
- 请确保传给
then()
方法的参数是函数类型,否则会被静默忽略p_rejected.then("Hello");
返回值
还记得【前言】中卖关子的那个问题吗?
为啥会多输出一次,也就是为啥会有
resolve
函数被调用。
这是因为
Promise.prototype.then()
方法返回一个新的Promise
实例,这个新Promise
实例基于onResolved
处理程序的返回值构建。
我们可以看个代码示例:
let p_default = new Promise((resolve, reject) => resolve("123"));
let p_new = p_default.then(() => "456", undefined);
从上图可以看到,p_new
是一个处于落定状态且是兑现状态的Promise
实例,并且是基于() => "456"
的返回值构建的。
换句话说,该处理程序的返回值会通过Promise.resolve()
包装来生成新Promise
。
如果没有提供这个处理程序,则
Promise.resolve()
就会包装上一个Promise
解决之后的值(即resolve()
中的参数)。
我们也可以通过一个代码实例,验证此结论:
let p_default = new Promise((resolve, reject) => resolve("123"));
let p_new = p_default.then(undefined, undefined);
从上图可以看到,此时p_new
是基于上一个Promise
,即p_default
的解决之后的值(resolve("123")
)构建的。
如果没有显式的返回语句,则
Promise.resolve()
会包装默认的返回值undefined
。
同样的,我们再一次修改代码示例,论证这个结论:
let p_default = new Promise((resolve, reject) => resolve());
let p_new = p_default.then(undefined, undefined);
从上图可以看到,此时p_new
是基于undefined
构建的。
总结一下,
Promise.prototype.then()
方法会返回一个状态为已解决的Promise
对象
不过还有一种特殊情况需要注意:
如果有异常抛出,则会返回状态为已拒绝的
Promise
对象
懂得都懂,我们通过一个代码示例,论证这个结论:
let p_default = new Promise((resolve, reject) => reject("789"));
let p_new = p_default.then(undefined, ()=>{ throw 'I am Error'});
从上图可以看到,此时p_new
就是一个处于拒绝状态的Promise
实例了。
Promise.prototype.catch()
Promise.prototype.catch()
方法用于给Promise
添加拒绝处理程序。这个方法只接收一个参数: onRejected
处理程序。
事实上,这个方法就是一个语法糖,调用它就相当于调用Promise.prototype.then(undefined, onRejected)
。
let p_default = new Promise((resolve, reject) => reject("789"));
let p_new = p_default.then(undefined, (reason) => reason);
let p_new_catch = p_default.catch((reason) => reason);
从上图可以看到,效果是一模一样的。
还有需要注意,Promise.prototype.catch()
同样会返回一个新的Promise
实例。
在返回新
Promise
实例方面,Promise.prototype.catch()
的行为与Promise.prototype.then()
的onRejected
处理程序是一样的。
Promise.prototype.finally()
Promise.prototype.finally()
方法用于给Promise
添加onFinally
处理程序,这个处理程序在Promise
转换为兑现或拒绝状态时都会执行。
这个方法可以避免onResolved
和onRejected
处理程序中出现冗余代码。
(啥意思呢,就是相同的逻辑我可以写在onFinally
里,而不是onResolved
里写一遍,然后又在onRejected
里再写一遍)
但是世上岂有凭空出现的两全其美的好事,这时候就需要问一句了,但是古尔丹,代价是什么?
onFinally
处理程序没有办法知道Promise
的状态是兑现还是拒绝,所以这个方法主要用于添加清理代码。
// 假设有一个异步操作,比如从服务器获取数据
function fetchData() {
return new Promise((resolve, reject) => {
// 模拟异步操作
setTimeout(() => {
const success = Math.random() > 0.5;
if (success) {
resolve('数据获取成功');
} else {
reject('数据获取失败');
}
}, 1000);
});
}
// 使用 finally 方法添加清理代码
fetchData()
.then(data => {
console.log(data); // 如果成功,打印数据
})
.catch(error => {
console.error(error); // 如果失败,打印错误信息
})
.finally(() => {
console.log('清理代码,无论成功或失败都会执行');
// 这里可以放置例如关闭加载指示器、清理资源等代码
});
Promise.all()和 Promise.race()
考虑到篇幅,会在手写Promise文章里详细的说一说这俩兄弟,这里就先暂时提一下需要注意的几个考点。
这俩做的事情可以用4个体现,就是期约合成。
Promise.all()
Promise.all()
静态方法创建的Promise
会在一组Promise
全部解决之后再解决。这个静态方法接收一个可迭代对象,返回一个新Promise
。- 如果有一个包含的
Promise
拒绝,则合成的Promise
也会拒绝,类似于会签,只要有1个人反对,则结果就是不通过。 - 如果全部成功解决,进入兑现状态,则返回的合成的
Promise
的解决值会包含所有Promise
的解决值,是一个数组类型的数据,并且成员的顺序按照迭代器顺序。
Promise.race()
Promise.race()
静态方法返回一个包装Promise
,是一组Promise
集合中最先解决或拒绝的Promise
的镜像。这个方法接收一个可迭代对象,返回一个新Promise
。Promise.race()
不会对兑现或拒绝的Promise
区别对待。无论是兑现还是拒绝,只要是第一个落定的Promise
,Promise.race()
就会包装其解决值或拒绝理由并返回新Promise
。
“期约连锁”
其实也就是链式调用。
把
Promise
逐个地串联起来是一种非常有用的编程模式。之所以可以这样做,是因为每个Promise
实例的方法(then()、catch()和finally())都会返回一个新的Promise
对象,而这个新Promise
又有自己的实例方法。这样连缀方法调用就可以构成所谓的“期约连锁”。
我们可以直接用它来解决上文提到的【回调地狱】
Promise链式调用处理回调地狱
在【回调地狱】这一章节,我们使用setTimeout
模拟异步行为,展示了令人痛苦的代码写法。
而现在,我们已经今非昔比,通过对Promise
的回顾与学习,我们可以使用Promise去优化之前的写法,如下:
功能函数的改造
function checkInventory(order) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('Checked inventory for order:', order.id);
// 假设库存充足
resolve(true);
}, 1000);
});
}
function deductInventory(order) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('Deducted inventory for order:', order.id);
resolve();
}, 1000);
});
}
function processPayment(order) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('Processed payment for order:', order.id);
resolve();
}, 1000);
});
}
function updateOrderStatus(order) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('Updated order status for order:', order.id);
resolve();
}, 1000);
});
}
function sendConfirmationEmail(order) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('Sent confirmation email for order:', order.id);
resolve();
}, 1000);
});
}
实现
// 假设我们有一个订单对象
const order = { id: 123, items: ['nail'], paymentInfo: { method: 'BANKCARD' } };
checkInventory(order)
.then(inStock => {
if (!inStock) {
throw new Error('Not enough inventory for order: ' + order.id);
}
return deductInventory(order);
})
.then(() => processPayment(order))
.then(() => updateOrderStatus(order))
.then(() => sendConfirmationEmail(order))
.then(() => console.log('Order processed successfully:', order.id))
.catch(err => console.error('Error processing order:', err));
在这个优化后的代码中,每个异步函数都返回一个新的Promise
,该Promise
在异步操作成功时解决(resolve
),在出现错误时拒绝(reject
)。使用.then()
方法,我们可以创建一个流畅的链式操作流程,每个.then()
块都会等待前一个Promise
解决后执行。
如果在任何点发生错误,Promise
链将立即跳到.catch()
块,在那里我们可以处理错误。这种方法使得代码更加清晰,易于阅读和维护,并且避免了回调地狱。
此外,我们还可以在.then()
块中添加更多的逻辑,而不必担心增加额外的嵌套层级。
结语
在本篇文章里,我们学习了Promise
相关的知识点,了解了它的基本功能和一些特殊设计。这也为我们在之后要进行的手写Promise打下了基础(下一篇文章就是手写实战,使得我们手写的MyPromise
能够基本实现原生Promise
的功能,并符合Promise/A+
规范)
下方附上了Promise/A+
规范的链接,感兴趣的同学可以先阅读阅读,期待与你在手写篇相遇~