面試官:為什麼Promise中的錯誤不能被try/catch?

語言: CN / TW / HK

highlight: arduino-light

前言

之前我寫過一篇文章,討論了為什麼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或者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~