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

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

大厂面试题分享 面试题库

前端面试题库 (面试必备)   推荐:★★★★★

地址:前端面试题库

前言

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

什么是Promise

Promise是一个用来代表异步操作结果的对象,我们可以通过观察者模式观察异步操作的结果。在其它语言里面,我们多多少少接触过futuredeferred这些概念,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有一个构造函数,接收一个函数作为参数,这个传入构造函数里的函数被称作executorPromise的构造函数会同步地调用executorexecutor又接收resolve函数跟reject函数作为参数,然后我们就可以通过这两个函数俩决定当前Promise的状态(resolve进入fulfilled或者reject进入rejected)。

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

假如我们现在有一个promise a

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

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

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

  • thenPromise进入fulfilled状态时会调用此函数
  • catchPromise进入rejected状态时会调用此函数
  • finallyPromnise进入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函数返回了一个PromisePromise里面我们调用了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.resolvePromise.reject来创建Promise

Promise.resolve

Promise.resolve(x)等价于

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

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

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

Promise.reject

Promise.reject等价于

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

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

Thenable

JavaScript Promise的标准来自Promise/A+,,所以JavaScriptPromise符合Promise/A+标准,但是也增加了一些自己的特性,比如catchfinally。(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.

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

再具体谈谈使用Promise

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

then

then的使用很简单,

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

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

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

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

而且我们知道了我们可以在then/catch/finally里面返回Promiseresolve它们创建的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 p2p2的状态将取决于p1跟我们在这个catch里面做的操作。如果p1fulfilled,这边的handler不会被调用,p2就直接带着p1fulfillment value进入fulfilled状态,如果p1进入rejected状态了,这个handler就会被调用。取决于我们的handler做了什么:

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

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

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

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

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

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

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

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

finally

我们已经知道,finally方法有点像try/catch/finally里面的finally块,finally handler到最后一定会被调用,不管当前Promisefulfilled还是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一样。除了返回的rejectedthenable,其他的值都会被忽略。也就是说,如果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 handlercatch handler有能力返回新的Promise,提供fallback方案,可以大大简化这其中的复杂度。

工具方法

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

写在结尾

Promise的出现,让我们:

  1. Promise提供了标准的方式来处理结果
  2. Promisethen返回新的Promise,可以多个串联,达到注册多个回调的效果
  3. 对于已经完成的异步操作,我们后来注册的then也能被调用
  4. 我们只能通过executor函数提供的两个函数来改变Promise的状态,没有其他办法可以resolve或者rejectPromise,而且这两个方法也不存在于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推出也好多年了,我们日常开发中已经离不开它了,即使是asyncawait背地里还是在跟它打交道,希望本文带给大家对Promise更全面的认识,当然了,关于Promise还有一些最佳实践跟反模式,由于篇幅的原因下次再见啦,Happy coding~

 

大厂面试题分享 面试题库

前端面试题库 (面试必备)   推荐:★★★★★

地址:前端面试题库

相关文章
|
2月前
|
SQL 存储 Oracle
Oracle 面试题及答案整理,最新面试题
Oracle 面试题及答案整理,最新面试题
81 0
|
24天前
|
存储 缓存 安全
兄弟面试了百度,面试题分享一波
兄弟面试了百度,面试题分享一波
39 0
|
2月前
|
SQL 监控 大数据
DataGrip 面试题及答案整理,最新面试题
DataGrip 面试题及答案整理,最新面试题
71 0
|
2月前
|
监控 jenkins 持续交付
Jenkins 面试题及答案整理,最新面试题
Jenkins 面试题及答案整理,最新面试题
144 0
|
2月前
|
存储 开发框架 .NET
C# 面试题及答案整理,最新面试题
C# 面试题及答案整理,最新面试题
45 0
|
2月前
|
存储 安全 API
Swift 面试题及答案整理,最新面试题
Swift 面试题及答案整理,最新面试题
104 0
|
2月前
|
存储 安全 Java
Android 面试题及答案整理,最新面试题
Android 面试题及答案整理,最新面试题
93 2
|
27天前
|
存储 安全 Java
大厂面试题详解:java中有哪些类型的锁
字节跳动大厂面试题详解:java中有哪些类型的锁
53 0
|
2月前
|
Java 程序员
java线程池讲解面试
java线程池讲解面试
63 1
|
12天前
|
Java 调度
Java面试必考题之线程的生命周期,结合源码,透彻讲解!
Java面试必考题之线程的生命周期,结合源码,透彻讲解!
38 1