面試官:為什麼Promise中的錯誤不能被try/catch?
highlight: arduino-light
前言
之前我寫過一篇文章,討論了為什麼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~