【面试题】面试官:为什么Promise中的错误不能被try/catch?_前端

大厂面试题分享 面试题库

前端面试题库 (面试必备) 

地址:​​前端面试题库​

前言

之前我写过一篇文章,讨论了为什么​​async await​​​中的错误可以被​​try catch​​​,而​​setTimeout​​​等api不能,有小伙伴提出之前面试被面试官问过为什么​​Promise​​​的错误不能​​try catch​​​,为什么要这么设计。好吧,虽然​​Promise​​这个话题大家都聊烂了,今天我们再来展开聊聊🤭。

什么是Promise

​Promise​​​是一个用来代表异步操作结果的对象,我们可以通过观察者模式观察异步操作的结果。在其它语言里面,我们多多少少接触过​​future​​​,​​deferred​​​这些概念,​​Promise​​​其实就是​​Javascript​​的类似实现。 根据​​MDN​​定义:

A ​​Promise​​ is in one of these states:

  • pending: initial state, neither fulfilled nor rejected.
  • fulfilled: meaning that the operation was completed successfully.
  • rejected: meaning that the operation failed.

一个​​fulfilled Promise​​​有一个​​fulfillment​​​值,而​​rejected Promise​​​则有一个​​rejection reason​​。

为什么要引入Promise?

异步处理在我们日常开发中是很常见的场景,在​​Promise​​出现之前,我们都是通过回调来处理异步代码的结果,但是出现了一些问题:

  • ​回调地狱​​,在有多个异步逻辑存在依赖关系时,我们只能在回调里嵌套,这些深度嵌套的代码让代码难以阅读和维护,业界称之为回调地狱
  • 回调也没用标准的方式来处理错误,大家都凭自己的喜好来处理错误,可能我们使用的库跟api都定义了一套处理错误的方式,那我们把多个库一起搭配使用时,就需要花额外的精力去把他们处理皮实
  • 有时候我们需要对一个已经完成的逻辑注册回调。这也没有统一的标准,对于大部分代码,我们根本就不能对这些已经执行完的代码注册回调,有些会同步执行回调,有些会异步执行回调,我们根本不可能记住所有api的机制,要么每次使用时我们都要研究这个api的实现机制,要么我们可能就在写bug
  • 而且,如果我们想对一个异步逻辑注册多个回调,这也要看api提供方支不支持
  • 最重要的,如果有统一的方式来处理错误跟正确结果的话,我们就有可能实现一套通用的逻辑来简化代码复杂度,这种自己发挥的情况就很难

是的,​​Promise​​的出现就是为了解决这所有的问题。

怎么创建Promise

Promise构造函数

​Promise​​​有一个构造函数,接收一个函数作为参数,这个传入构造函数里的函数被称作​​executor​​​。 ​​Promise​​​的构造函数会同步地调用​​executor​​​,​​executor​​​又接收​​resolve​​​函数跟​​reject​​​函数作为参数,然后我们就可以通过这两个函数俩决定当前​​Promise​​​的状态(​​resolve​​​进入​​fulfilled​​​或者​​reject​​​进入​​rejected​​)。

我们在​​resolve Promise​​​时,可以直接给它一个值,或者给它另外一个​​Promise​​​,这样最终是​​fulfilled​​​还是​​rejected​​​将取决于我们给它的这个​​Promise​​最后的状态。

假如我们现在有一个​​promise a​​:

  • 如果我们在​​promise a​​​里面调用​​resolve​​​,传入了另一个​​promise b​​​,​​promise a​​​的状态将取决于​​promise b​​的执行结果
  • 如果我们直接传给​​resolve​​​一个普通的值,则​​promise a​​​带着这个值进入​​fulfilled​​状态
  • 如果我们调用​​reject​​​,则​​promise a​​​带着我们传给​​reject​​​的值进入​​rejected​​状态

​Promise​​​在一开始都是​​pending​​​状态,之后执行完逻辑之后变成​​settled(fulfilled或者rejected)​​​,​​settled​​​不能变成​​pending​​​,​​fulfilled​​​不能变成​​rejected​​​,​​rejected​​​也不能变成​​fulfilled​​​。总之一旦变成​​settled​​状态,之后就不会再变了。

我们也不能直接拿到​​Promise​​​的状态,只能通过注册​​handler​​​的方式,​​Promise​​​会在恰当的时机调用这些​​handler​​​,​​JavaScript Promise​​​可以注册三种​​handler​​:

  • ​then​​​ 当​​Promise​​​进入​​fulfilled​​状态时会调用此函数
  • ​catch​​​ 当​​Promise​​​进入​​rejected​​状态时会调用此函数
  • ​finally​​​当​​Promnise​​​进入​​settled​​​状态时会调用此函数(无论​​fulfilled​​​还是​​rejected​​)

这三个​​handler​​​函数都会返回一个新的​​Promise​​​,这个新的​​Promise​​​跟前面的​​Promise​​​关联在一起,他的状态取决于前面​​Promise​​​状态以及当前​​handler​​的执行情况。

我们先来看一段代码直观感受下:

function maybeNum() {
// create a promise
return new Promise((resolve, reject)=>{
console.info('Promise Start')
setTimeout(()=>{
try{
const num=Math.random();
const isLessThanHalf=num<=0.5;
if(isLessThanHalf){
resolve(num)
}else{
throw new Error('num is grater than 0.5')
}
}catch (e) {
reject(e)
}
},100)
console.info('Promise End')
})
}

maybeNum().then(value => {
console.info('fulfilled',value)
}).catch(error=>{
console.error('rejected',error)
}).finally(()=>{
console.info('finally')
})
console.info('End')
复制代码

​maybeNum​​​函数返回了一个​​Promise​​​,​​Promise​​​里面我们调用了​​setTimeout​​​做了一些异步操作,以及一些​​console​​打印。

出现的结果类似这样:

Promise Start
Promise End
End
fulfilled 0.438256424793777
finally
复制代码

或者这样:

Promise Start
Promise End
End
rejected Error: num is grater than 0.5 ...
finally
复制代码

我们可以发现,除了​​setTimeout​​​里的部分,其它都是同步按顺序执行的,所以​​Promise​​​本身并没有做什么骚操作,它只是提供了一种观察异步逻辑的途径,而不是让我们的逻辑变成异步,比如在这里我们自己实现异步逻辑时还是要通过调用​​setTimeout​​。

此外,我们还可以通过​​Promise.resolve​​​跟​​Promise.reject​​​来创建​​Promise​​。

Promise.resolve

​Promise.resolve(x)​​等价于

x instanceof Promise?x:new Promise(resolve=>resolve(x))
复制代码

如果我们传给它的参数是一个​​Promise​​​,(而不是​​thenable​​​,关于什么是​​thenable​​​我们稍后会讲)它会立即返回这个​​Promise​​​,否则它会创建一个新的​​Promise​​​,​​resolve​​​的结果为我们传给它的参数,如果参数是一个​​thenable​​​,那会视这个​​thenable​​​的情况而定,否则直接带着这个值进入​​fulfilled​​状态。

这样我们就可以很轻松地把一个​​thenable​​​转换为一个原生的​​Promise​​​,而且更加方便的是如果有时候我们不确定我们接收到的对象是不是Promise,用它包裹一下就好了,这样我们拿到的肯定是一个​​Promise​​。

Promise.reject

​Promise.reject​​等价于

new Promise((resolve,reject)=>reject(x))
复制代码

也就是说,不管我们给它什么,它直接用它​​reject​​​,哪怕我们给的是一个​​Promise​​。

Thenable

​JavaScript Promise​​​的标准来自​​Promise/A+​​​,,所以​​JavaScript​​​的​​Promise​​​符合​​Promise/A+​​​标准,但是也增加了一些自己的特性,比如​​catch​​​跟​​finally​​​。(​​Promise/A+​​​只定义了​​then​​)

在​​Promise/A+​​​里面有个​​thenable​​​的概念,跟​​Promise​​有一丢丢区别:

  • A “promise” is an object or function with a ​​then​​ method whose behavior conforms to [the Promises/A+ specification].
  • A “thenable” is an object or function that defines a ​​then​​ method.

所以​​Promise​​​是​​thenable​​​,但是​​thenable​​​不一定是​​Promise​​​。之所以提到这个,是因为互操作性。​​Promise/A+​​​是标准,有不少实现,我们刚刚说过,我们在​​resolve​​​一个​​Promise​​​时,有两种可能性,​​Promise​​​实现需要知道我们给它的值是一个可以直接用的值还是​​thenable​​​。如果是一个带有​​thenable​​​方法的对象,就会调用它的​​thenable​​​方法来​​resolve​​​给当前​​Promise​​​。这听起来很挫,万一我们恰好有个对象,它就带​​thenable​​​方法,但是又跟​​Promise​​​没啥关系呢? 这已经是目前最好的方案了,在​​Promise​​​被添加进​​JavaScript​​​之前,就已经存在很多​​Promise​​​实现了,通过这种方式可以让多个​​Promise​​​实现互相兼容,否则的话,所有的​​Promise​​​实现都需要搞个​​flag​​​来表示它的​​Promise​​​是​​Promise​​。

再具体谈谈使用Promise

刚刚的例子里,我们已经粗略了解了一下​​Promise​​​的创建使用,我们通过​​then``catch``finally​​​来“hook”进​​Promise​​​的​​fulfillment​​​,​​rejection​​​,​​completion​​​阶段。大部分情况下,我们还是使用其它api返回的​​Promise​​​,比如​​fetch​​​的返回结果,只有我们自己提供api时或者封装一些老的api时(比如包装​​xhr​​​),我们才会自己创建一个​​Promise​​​。所以我们现在来进一步了解一下​​Promise​​的使用。

then

​then​​的使用很简单,

const p2=p1.then(result=>doSomethingWith(result))
复制代码

我们注册了一个​​fulfillment handler​​​,并且返回了一个新的​​Promise(p2)​​​。​​p2​​​是​​fulfilled​​​还是​​rejected​​​将取决于​​p1​​​的状态以及​​doSomethingWith​​​的执行结果。如果​​p1​​​变成了​​rejected​​​,我们注册的​​handler​​​不会被调用,​​p2​​​直接变成​​rejected​​​,​​rejection reason​​​就是​​p1​​​的​​rejection reason​​​。如果​​p1​​​是​​fulfilled​​​,那我们注册的​​handler​​​就会被调用了。根据​​handler​​的执行情况,有这几种可能:

  • ​doSomethingWith​​​返回一个​​thenable​​​,​​p2​​​将会被​​resolve​​​到这个​​thenable​​​(取决于这个​​thenable​​​的执行情况,决定​​p2​​​是​​fulfilled​​​还是​​rejected​​)
  • 如果返回了其它值,​​p2​​​直接带着那个值进入​​fulfilled​​状态
  • 如果​​doSomethingWith​​​中途出现​​throw​​​,​​p2​​​进入​​rejected​​状态

这词儿怎么看着这么眼熟?没错我们刚刚介绍​​resolve​​​跟​​reject​​​时就是这么说的,这些是一样的行为,在我们的​​handler​​​里​​throw​​​跟调用​​reject​​​一个效果,​​return​​​跟​​resolve​​一个效果。

而且我们知道了我们可以在​​then/catch/finally​​​里面返回​​Promise​​​来​​resolve​​​它们创建的​​Promise​​​,那我们就可以串联一些依赖其它异步操作结果且返回​​Promise​​的api了。像这样:

p1.then(result=>secondOperation(result))
.then(result=>thirdOperation(result))
.then(result=>fourthOperation(result))
.then(result=>fifthOperation(result))
.catch(error=>console.error(error))
复制代码

其中任何一步出了差错都会调用​​catch​​。

如果这些代码都改成回调的方式,就会形成​​回调地狱​​​,每一步都要判断错误,一层一层嵌套,大大增加了代码的复杂度,而​​Promise​​的机制能够让代码扁平化,相比之下更容易理解。

catch

​catch​​​的作用我们刚刚也讨论过了,它会注册一个函数在​​Promise​​​进入​​rejected​​状态时调用,除了这个,其他行为可以说跟then一模一样。

const p2=p1.catch(error=>doSomethingWith(error))
复制代码

这里我们在​​p1​​​上注册了一个​​rejection handler​​​,并返回了一个新的​​Promise p2​​​,​​p2​​​的状态将取决于​​p1​​​跟我们在这个​​catch​​​里面做的操作。如果​​p1​​​是​​fulfilled​​​,这边的​​handler​​​不会被调用,​​p2​​​就直接带着​​p1​​​的​​fulfillment value​​​进入​​fulfilled​​​状态,如果​​p1​​​进入​​rejected​​​状态了,这个​​handler​​​就会被调用。取决于我们的​​handler​​做了什么:

  • ​doSomethingWith​​​返回一个​​thenable​​​,​​p2​​​将会被​​resolve​​​到这个​​thenable​
  • 如果返回了其它值,​​p2​​​直接带着那个值进入​​fulfilled​​状态
  • 如果​​doSomethingWith​​​中途出现​​throw​​​,​​p2​​​进入​​rejected​​状态

没错,这个行为跟我们之前讲的​​then​​的行为一模一样,有了这种一致性的保障,我们就不需要针对不同的机制记不同的规则了。

这边尤其需要注意的是,如果我们从​​catch handler​​​里面返回了一个​​non-thenable​​​,这个​​Promise​​​就会带着这个值进入​​fulfilled​​​状态。这将​​p1​​​的​​rejection​​​转换成了​​p2​​​的​​fulfillment​​​,这有点类似于​​try/catch​​​机制里的​​catch​​,可以阻止错误继续向外传播。

这是有一个小问题的,如果我们把​​catch handler​​放在错误的地方:

someOperation()
.catch(error => {
reportError(error);
})
.then(result => {
console.log(result.someProperty);
});
复制代码

这种情况如果​​someOperation​​​失败了,​​reportError​​​会报告错误,但是​​catch handler​​​里什么都没返回,默认就返回了​​undefined​​​,这会导致后面的​​then​​​里面因为返回了​​undefined​​​的​​someProperty​​而报错。

Uncaught (in promise) TypeError: Cannot read property 'someProperty' of undefined
复制代码

由于这时候的错误没有​​catch​​​来处理,​​JavaScript​​​引擎会报一个​​Unhandled rejection​​​。 所以如果我们确实需要在链式调用的中间插入​​catch handler​​的话,我们一定要确保整个链路都有恰当的处理。

finally

我们已经知道,​​finally​​​方法有点像​​try/catch/finally​​​里面的​​finally​​​块,​​finally handler​​​到最后一定会被调用,不管当前​​Promise​​​是​​fulfilled​​​还是​​rejected​​​。它也会返回一个新的​​Promise​​​,然后它的状态也是根据之前的​​Promise​​​以及​​handler​​​的执行结果决定的。不过​​finally handler​​能做的事相比而言更有限。

function doStuff() {
loading.show();
return getSomething()
.then(result => render(result.stuff))
.finally(() => loading.hide());
}
复制代码

我们可以在做某件耗时操作时展示一个加载中的组件,然后在最后结束时把它隐藏。我在这里没有去处理​​finally handler​​可能出现的错误,这样我代码的调用方既可以处理结果也可以处理错误,而我可以保证我打开的一些副作用被正确销毁(比如这里的隐藏loading)。

细心的同学可以发现,​​Promise​​​的三种​​handler​​​有点类似于传统的​​try/catch/finally​​:

try{
// xxx
}catch (e) {
// xxx
}finally {

}
复制代码

正常情况下,​​finally handler​​​不会影响它之前的​​Promise​​​传过来的结果,就像​​try/catch/finally​​​里面的​​finally​​​一样。除了返回的​​rejected​​​的​​thenable​​​,其他的值都会被忽略。也就是说,如果​​finally​​​里面产生了异常,或者返回的​​thenable​​​进入​​rejected​​​状态了,它会改变返回的​​Promise​​​的结果。所以它即使返回了一个新的值,最后调用方拿到的也是它之前的​​Promise​​​返回的值,但是它可以把​​fulfillment​​​变成​​rejection​​​,也可以延迟​​fulfillment​​​(毕竟返回一个​​thenable​​的话,要等它执行完才行)。

简单来说就是,它就像​​finally​​​块一样,不能包含​​return​​,它可以抛出异常,但是不能返回新的值。

function returnWithDelay(value, delay = 10) {
return new Promise(resolve => setTimeout(resolve, delay, value));
}

// The function doing the work
function work() {
return returnWithDelay("original value")
.finally(() => {
return "value from finally";
});
}

work()
.then(value => {
console.log("value = " + value); // "value = original value"
});
复制代码

这边我们可以看到最后返回的值并不是​​finally​​里面返回的值,主要有两方面:

  • ​finally​​​主要用来做一些清理操作,如果需要返回值应该使用​​then​
  • 没有​​return​​​的函数、只有​​return​​​的函数、以及​​return undefined​​​的函数,从语法上来说都是返回​​undefined​​​的函数,​​Promise​​​机制无法区分这个​​undefined​​要不要替换最终返回的值

then其实有两个参数

我们目前为止看到的​​then​​​都是接受一个​​handler​​​,其实它可以接收两个参数,一个用于​​fulfillment​​​,一个用于​​rejection​​​。而且​​Promise.catch​​​等价于​​Promise.then(undefined,rejectionHadler)​​。

p1.then(result=>{

},error=>{

})
复制代码

这个跟

p1.then(result=>{

}).catch(error=>{

})
复制代码

可不等价,前者两个​​handler​​​都注册在同一个​​Promise​​​上,而后者​​catch​​​注册在​​then​​​返回的​​Promnise​​​上,这意味着如果前者里只有​​p1​​​出错了才会被处理,而后者​​p1​​​出错,以及​​then​​​返回的​​Promise​​出错都能被处理。

解答开头的问题

现在我们知道要提供​​Promise​​​给外部使用,​​Promise​​​设计成在外面是没有办法获取​​resolve​​​函数的,也就改变不了一个已有​​Promise​​​的状态,我们只能基于已有​​Promise​​​去生成新的​​Promise​​​。如果允许异常向外抛出,那我们该怎么恢复后续​​Promise​​​的执行?比如​​Promise a​​​出现异常了,异常向外抛出,外面是没办法改变​​Promise a​​​的数据的。设计成在​​Promise​​​里面发生任何错误时,都让当前​​Promise​​​进入​​rejected​​​状态,然后调用之后的​​catch handler​​​,​​catch handler​​​有能力返回新的​​Promise​​​,提供​​fallback​​方案,可以大大简化这其中的复杂度。

工具方法

​Promise​​​还提供了一些工具方法,我们可以使用它们来同时处理多个​​Promise​​​,例如​​Promise.all​​​,​​Promise.race​​​,​​Promise.allsettled​​​,​​Promise.any​​,今天我就不一一介绍了,大家感兴趣的可以自行了解一下。

写在结尾

​Promise​​的出现,让我们:

  1. ​Promise​​提供了标准的方式来处理结果
  2. ​Promise​​​的​​then​​​返回新的​​Promise​​,可以多个串联,达到注册多个回调的效果
  3. 对于已经完成的异步操作,我们后来注册的​​then​​也能被调用
  4. 我们只能通过​​executor​​​函数提供的两个函数来改变​​Promise​​​的状态,没有其他办法可以​​resolve​​​或者​​reject​​​ ​​Promise​​​,而且这两个方法也不存在于​​Promise​​​本身,所以我们可以把我们的​​Promise​​​对象给其他人去使用,比如我们提供给外部一个api,以​​Promise​​​返回,可以放心地让外部通过​​Promise​​​来观察最终的结果,他们也没办法来改变​​Promise​​的状态。
  5. 可以实现统一的同时处理多个​​Promise​​的逻辑

而且,我在本文开头提到过,回调地狱有两个问题是:

  • 向已经完成的操作添加回调并没有统一的标准
  • 很难向某个操作添加多个回调

这些都被​​Promise​​的标准解决了,标准确保了两件事:

  • ​handler​​一定会被调用
  • 调用是异步的

也就是说,如果我们获取到了其它api提供的​​Promise​​,有了类似如下的代码:

console.log('before')
p1.then(()=>{
console.log('in')
})
console.log('after')
复制代码

标准确保了,执行结果是​​before​​​,然后是​​after​​​,最后是(在​​p1​​​变成​​fulfilled​​​状态或者已经变成​​fulfilled​​​状态时)​​in​​​。如果​​Promise​​​在经过一段时间之后才变成​​fulfilled​​​,这个​​handler​​​也会被往后调度。如果​​Promise​​​已经变成​​fulfilled​​​了,那​​fulfillment handler​​​会被立即调度(不是立即执行),调度指的是被加入微任务队列,确保这些​​handler​​​被异步调用大概是​​Promise​​唯一让同步代码被异步调用的情形了。

​Promise​​​推出也好多年了,我们日常开发中已经离不开它了,即使是​​async​​​ ​​await​​​背地里还是在跟它打交道,希望本文带给大家对​​Promise​​​更全面的认识,当然了,关于​​Promise​​还有一些最佳实践跟反模式,由于篇幅的原因下次再见啦,Happy coding~