面试官:Promise中存在多个resolve,最终会执行哪一个?

11,042 阅读20分钟

前言

文章标题中提到的问题,对于已经掌握手写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);
});

image.png

如果我们在自己手写的Promise函数里,在resolvereject中,在return的逻辑前面加一行console.log的话,就会是这样的效果

image.png

这里不知道你是否会有一个疑问,为啥还会在最后一行输出一次resolveconsole.log语句,并且value的值还是undefined

image.png

这就考察了你关于Promise支持链式调用的知识点了(实例方法Promise.prototype.then返回的是一个新的Promise实例,并且这个.........)。

(先卖个关子,正文会有详解~)

因此,本篇文章以我本人遇到的一道面试题为引,结合红宝书(《JavaScript高级程序设计》),和大家一起聊一聊Promise

image.png

异步任务与ES6+

异步任务与同步任务

在正式开启本章节内容之前,我想先问各位一个问题,如果让你用自己的语言进行组织,描述一下什么是异步任务,什么是同步任务,你会如何描述?

如果是我的话,我可能会这样描述:

  • 同步任务:按顺序在执行栈中被调用的任务。
  • 异步任务:会阻塞主线程执行的任务,因此需要放到消息队列中等待事件循环调度。

是不是感觉怪怪的,好像说的并不是很好,难道异步任务就不会按顺序被执行栈调用了吗?只不过多了一步从消息队列到执行栈的转移过程罢了。

所以,我之所以会问大家这么一个问题,就是想说,每当我们在学习或者回顾某个知识点的时候,如果不能找到合适的语言描述它,那就说明我们对它的理解还是不够到位,也就表明,我们还是需要去学习和理解那些被大多数人认可的,用来描述它的语言。

比如官方文档、或者知名的技术图书等等。

image.png

因此免不了俗,在直接进入Promise知识分享、代码实操等环节之前,我们来一起看一下《JavaScript高级程序设计》这本书,它对于同步任务和异步任务的描述。

定义

同步任务(行为)

同步行为对应内存中顺序执行的处理器指令。每条指令都会严格按照它们出现的顺序来执行,而每条指令执行后也能立即获得存储在系统本地(如寄存器或系统内存)的信息。这样的执行流程容易分析程序在执行到代码任意位置时的状态(比如变量的值)。——《JavaScript高级程序设计》

看一个代码例子:

let num = 1;

num += 2;
// num: 3

在程序执行的每一步,都可以推断出程序的状态。这是因为后面的指令总是在前面的指令完成后才会执行。等到最后一条指定执行完毕,存储在num的值就立即可以使用。

异步任务(行为)

异步行为类似于系统中断,即当前进程外部的实体可以触发代码执行。异步操作经常是必要的,因为强制进程等待一个长时间的操作通常是不可行的(同步操作则必须要等)。如果代码要访问一些高延迟的资源,比如向远程服务器发送请求并等待响应,那么就会出现长时间的等待。——《JavaScript高级程序设计》

我们也可以在看一个代码例子:

let num = 1;

setTimeout(() => num += 2, 2000);

// num: 3

这段程序最终与同步代码执行的任务一样,都是把两个数加在一起,但这一次执行线程不知道num值何时会改变,因为这取决于回调函数何时从消息队列出列并执行

当我们了解完上方这些有关同步/异步任务的描述内容之后,我们也发现,在这些内容中,提到了“消息队列”这个名词,其实这就与事件循环相联系了。

为了提升本文后续内容的阅读体验,大家可以先回顾一下什么是事件循环

👇感兴趣的话,可以阅读下方这篇文章👇:

《【事件循环秘籍】人形浏览器的修炼之道(上篇)》

回调地狱

人们为了更好地统计收成、计算支出,于是发明了算盘🧮;

人们为了更快地两地往返、走亲访友,于是发明了汽车🚗;

人们为了更方便地与电脑完成交互、输入信息,于是发明了鼠标和键盘🖱;

综上所述,Promise的出现,也应该是为了解决某个困扰人们的问题吧?

image.png

没错,那就是臭名昭著的“回调地狱

在这里我不想举那种只是把几个setTimeout嵌套在一起,意思一下,说明回调地狱的代码示例,那其实没什么意思,对面试的回答帮助也不大。

我们结合一种业务场景,去使用setTimeout模拟异步行为,循序渐进地完成业务需求,之后再对写好的代码review,从而确切地去感受何谓“回调地狱”。

好的,让我们以一个电子商务网站的业务场景为例,实现处理一个订单的需求。

要对一个订单完成一次完整的处理,需要遵循以下步骤:

  1. 检查库存
  2. 扣除库存
  3. 处理支付
  4. 更新订单状态
  5. 发送确认邮件

针对上述步骤,我们写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);
        });
      });
    });
  });
});

写完之后真的是已老实求放过了....

image.png

我们来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(() => {});

image.png

注意,我们需要传入一个执行器(executor)作为构造函数的参数,比如示例中的() => {},避免报错。

image.png

状态

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条性质:

  1. 每个Promise只要状态切换为兑现(fullfilled),就会有一个私有的内部值(value)
  2. 每个Promise只要状态切换为拒绝(rejected),就会有一个私有的内部原因(reason)

并且这2条性质还有对应的一些约束:

无论是值(value)还是理由(reason),都是包含原始值对象不可修改的引用。

二者都是可选的,而且默认值为 undefined

在期约到达某个落定状态(即fullfilledrejected)时执行的异步代码始终会收到这个值或理由。

在上面的内容里,我们讲了Promise的2种用途,并且一直在频繁地提到状态上的切换。接下来我们就一起来讲一讲如何控制Promise的状态,即实现它的状态切换。

image.png

状态切换

我们可以通过2种方式完成对Promise的状态落定(settled),即从pending => fullfilled/rejected

  • 执行函数
  • 静态方法
执行函数

通过【状态】这一章节我们了解到,Promise的状态是私有的,因此只能在内部进行操作。

内部操作在Promise执行器函数(executor)中完成。

还记得它吗?没错,就是一开始我们在聊如何创建一个Promise实例时,传给构造函数的那个函数参数。

let p = new Promise(() => {});

执行器函数主要有两项职责:

  • 初始化期约的异步行为
  • 控制状态的最终转换:通过调用它的两个函数参数实现的,这两个函数参数通常都命名为resolve()reject()
    • resolve():调用resolve()会把状态切换为兑现
    • reject():调用reject()会把状态切换为拒绝,并抛出错误

所以如果我们要充分发挥执行器函数的职责的话,我们得这样去创建一个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();
    

    image.png

  • Promise.reject()Promise.reject() 等价于 new Promise((resolve,reject)=>{reject()})

    let p3_settled = new Promise((resolve, reject) => {
      reject();
    });
    
    let p4_settled = Promise.reject();
    

    image.png

静默失败

无论resolve()reject()中的哪个被调用,状态转换都不可撤销了。于是继续修改状态会静默失败

这就是呼应标题中那个问题的答案,当Promise中存在多个resolve,最终会执行哪一个?

实际上当第1个resolve()被调用后,接下来无论后面跟着的是resolve()还是reject(),都无法再去实现状态转换的操作,也不能被完全执行,它们的操作都会静默失败

Promise.prototype.then

经过上面内容的学习,我们知道了Promise的用途、知道了执行器函数的两个作用,知道了如何让Promise处于落定状态。

但这些其实都是前置条件,关键的问题在于,当一个Promise处于落定状态之后,后续呢?

我该如何拿到异步操作可能产生的值(value)或原因(reason)呢?

这时候就要使用到一些Promise的实例方法了,比如thencatchfinally

期约实例的方法是连接外部同步代码与内部异步代码之间的桥梁。这些方法可以访问异步操作返回的数据,处理期约成功和失败的结果,连续对期约求值,或者添加只有期约进入终止状态时才会执行的 代码。

使用

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")
);

image.png

可以看到,处于兑现状态的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);

image.png

从上图可以看到,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);

image.png

从上图可以看到,此时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);

image.png

从上图可以看到,此时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'});

image.png

从上图可以看到,此时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);

image.png

从上图可以看到,效果是一模一样的。

还有需要注意,Promise.prototype.catch()同样会返回一个新的Promise实例。

在返回新Promise实例方面,Promise.prototype.catch()的行为与Promise.prototype.then()onRejected处理程序是一样的。

Promise.prototype.finally()

Promise.prototype.finally()方法用于给Promise添加onFinally处理程序,这个处理程序在Promise转换为兑现拒绝状态时都会执行

这个方法可以避免onResolvedonRejected处理程序中出现冗余代码。

(啥意思呢,就是相同的逻辑我可以写在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区别对待。无论是兑现还是拒绝,只要是第一个落定的PromisePromise.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()块中添加更多的逻辑,而不必担心增加额外的嵌套层级。

image.png

结语

在本篇文章里,我们学习了Promise相关的知识点,了解了它的基本功能和一些特殊设计。这也为我们在之后要进行的手写Promise打下了基础(下一篇文章就是手写实战,使得我们手写的MyPromise能够基本实现原生Promise的功能,并符合Promise/A+ 规范)

下方附上了Promise/A+规范的链接,感兴趣的同学可以先阅读阅读,期待与你在手写篇相遇~

Promises/A+ (promisesaplus.com)

image.png