面對 Promise 的思考路徑

Promise 是常見的面試問題,相信各位打工仔肯定是不陌生,但往往會是:用的時候覺得理所當然,成為考題時卻覺得絆手絆腳難以解釋。這邊我想用一篇文章來整理思考路徑,以讓下次碰到這類提問是有個思考模型。

realdennis
12 min readApr 29, 2021

前言(廢話)

JavaScript 是一個非同步(異步)的語言,我們常常在解決使用者觸發的事件、Network IO 的請求、計時器觸發…之間打轉,對比其他同步語言,可以直落落的寫下來,JavaScript 仰賴的是回調函數(Callback)的幫助來分工合作。

但由於太多 callback 需要控制,往往就是會看到 callback 1 裡面塞 callback 2 來確保 order 正確,這也是俗稱的回調地獄(callback hell),而 Promise 的出現解決了這個問題。

Promise 是 ES6 的 built-in 函數,也就是說 15' 年開始寫 JS 的人,多半都會直接使用這個方法來取代 callback hell 的巢狀格式,在下個版本 ES7 中,更有 based on Promise 的 async / await 方法,也就是說,學 Promise 絕對是划算至極。

心智模型

學習、記憶、思考總是需要有一個如同樹圖的聯想方式,在這個章節我們先站在面試官的角度思考一下:如果你要生出 Promise 的題目來考面試者,該怎麼下手呢?

我們參考 MDN 的文件,來看看 Promise 有哪些 method:

我們可以看到這邊有 resolve()reject()all()allSettled()any()race()then()catch()finally() ,可以看到我沒有照著文檔排序,而是依照一個心中既有的分類去做排序:

  • 決定 Promise 狀態(結果)的: Promise.resolve()Promise.reject()
  • 決定 Promise tasks 競賽方式的: Promise.all()Promise.allSettled()Promise.any()Promise.race()
  • 決定 Promise task(s) 後續的: Promise.then()Promise.catch()Promise.finally()

如此我們便可以畫出一個脈絡圖:

決定 Promise 狀態(結果) — resolve & reject

當我們在 new 一個 Promise 實例的時候,Promise 會給我們兩個決定結果的函數,大致上如下:

const SimplePromise = new Promise((resolve,reject)=>{
if(somethingGood) resolve(result);
if(somethingBad) reject(new Error('something bad'));
})
SimplePromise.then().catch()

值得注意的是,resolve()reject()靜態方法,所以你爽的話也可以直接 call 這兩個方法,生成一個有 fulfilled/ rejected結果的物件:

> Promise.resolve(true);
< Promise {<fulfilled>: true}
> Promise.reject(false);
< Promise {<rejected>: false}
X VM581:1 Uncaught (in promise) false (anonymous) @ VM581:1

決定 Promise tasks 競賽方式— all 、 allSettled 、 race 、 any

當我們有多個 Promise 綁定在一起處理的時候,有兩種大宗的 scenario:

  1. Order concerned (一個接著一個)
  2. Asynchronous (去做各自的事)

針對第 1. 點,只有在兩個 Promise 之間有互相依賴的時候,才會有 order concerned 的狀況,如下:

promiseA
.then(result=>PromiseBFactory(result))
.then(finalResult=>console.log(finalResult))

通常這類的 case 最終會集成在同個 Promise 裡頭(如將上述的函數集成為一個 independent 的 PromiseAB),而內部或許會用 ES7 的 await 去實現,才不會用過度嵌套的 then chain。順帶一提,那種要求你用 Promise 實作 order concerned 的公司就別去了,他們需要的是一個會配置 ES7 await 的 babel 工程師。

那我們這邊提到的競賽方式,主要是針對第 2. 點,多個 tasks 競賽如下:

raceRule([Promise1,Promise2.Promise3]).then(???)

這個 raceRule 可以有幾種 case:

  1. 誰先結束誰贏 Promise.race 、Promise.any
  2. 大家一起結束 Promise.all 、 Promise.allSettled

我們來逐個討論。

誰先結束誰贏 —— 失敗算不算一種結束?

  • 失敗算一種結束 Promise.race
  • 只有 fulfilled 才算結束 Promise.any (一個 reject 等下一個,全部 reject 進入 rejected)

大家一起結束——失敗算不算一種結束

  • 失敗算一種結束 Promise.allSettled
  • 只有 fulfilled 才算結束 Promise.all (其中一個 rejected 的話會進入 catch)

用個生活的例子舉例吧:

假設現在有兩對情侶 A B,他們在大學校園的樹下埋下六人的合照,交往過程是一個 Promise pending , 最終結婚是 fulfill ,而分手則是是 rejected。他們約定好在以下的狀況下拆開時光膠囊:1. 如果其中有一對結婚了,拆時光膠囊吧! Promise.any([A,B])
2. 如果其中有人結婚或分手了,丟掉時光膠囊吧! Promise.race([A,B])
3. 如果大家都結婚了,拆時光膠囊吧! Promise.all([A,B])
4. 如果大家狀態確定了(分手、結婚),丟掉時光膠囊吧 Promise.allSettled([A,B])
最終情侶A在4年後結婚,情侶B則是在三年後就分手了,那麼以上四個case會是怎麼樣呢?

For case 1 :

//1. 如果其中有一對結婚了,拆時光膠囊吧! Promise.any([A,B])
const A = new Promise(resolve=>setTimeout(resolve,4,'A'));
const B = new Promise((_,reject)=>setTimeout(reject,3,'B'));
setTimeout(()=>Promise.any([A,B]).then(console.log));
// A

For case 2:

//2. 如果其中有人結婚或分手了,丟掉時光膠囊吧! Promise.race([A,B])
const A = new Promise(resolve=>setTimeout(resolve,4,'A'));
const B = new Promise((_,reject)=>setTimeout(reject,3,'B'));
setTimeout(()=>Promise.race([A,B]).then(console.log)).catch(console.error); // Error: B// B如果在五年後差分手,那麼不會走到 catch 喔!

For case 3:

//3. 如果大家都結婚了,拆時光膠囊吧! Promise.all([A,B])
const A = new Promise(resolve=>setTimeout(resolve,4,'A'));
const B = new Promise((_,reject)=>setTimeout(reject,3,'B'));
setTimeout(()=>Promise.all([A,B])
.then(console.log)
.catch(console.error)); // B
// B is the reject reason
// 儘管B在五年後差分手 一樣是導致all method失敗的reason喔!

For case 4:

// 4. 如果大家狀態確定了(分手、結婚),丟掉時光膠囊吧 Promise.allSettled([A,B])
const A = new Promise(resolve=>setTimeout(resolve,4,'A'));
const B = new Promise((_,reject)=>setTimeout(reject,3,'B'));
setTimeout(()=>Promise.allSettled([A,B]).then(console.log));
// [
// { status: 'fulfilled', value: 'A' },
// { status: 'rejected', reason: 'B' }
// ]

如果是從單一/全體來看,我們可以分成:

  • 單一完成為主的 race & any
  • 全體完成為主的 all & allSettled

如果是從完成狀態來看,我們可以分成:

  • 成功失敗都算的 race & allSettled
  • 只有成功才算的 any & all

決定競賽結果的後續回調的 — then、catch、finally

  • Promise 走到 fulfilled 狀態, result 傳進去 then 的 callback
  • Promise 走到 rejected 狀態, reason 傳進去 catch 的 callback
  • Promise 不論走到哪個狀態,當 then 與 catch 執行 完,接著執行 finally 的 callback
// 1. Promise fulfilled
Promise.resolve("I'm result").then(console.log) // I'm result
// 2. Promise rejected
Promise.reject("reject reason").catch(console.error) // reject reason
// 3. Promise finally
const APromise = new Promise((resolve,reject)=>{
// I don't know wtf will going on
})
APromise
.then(result=>{
// Processing if APromise fulfilled
})
.catch(reason=>{
// Processing if APromise rejected
})
.finally(()=>{
// MUST DO THIS WHENEVER FULFILLED OR REJECTED
})

then 、 catch 、 finally 之間的關係猶如 try 、 catch 、 finally

尾聲

透過這樣的分類方式,我們可以輕鬆的在任意的 Promise 實作考題中如魚得水(大概吧?),無論你是想從中抽考面試候選者,或是你自己在面對面試題的時候,都能輕易從 Promise 的既有方法中,找到題目想要引導的思路與本意。

有了兩個最終狀態的設定,我們也能做到一些防呆機制的 Promise 封裝,解決類似以下的問題:

1. 由於我們在UI測試實驗中,發現使用者只願意等待2~5秒的時間在請求加載上,能否為我們設計、封裝一回傳 Promise 的高階函數 foolProofRequest ,其接受一網路請求的函數 fn 作為第一參數,一個毫秒數 n 為第二參數,其會去做非同步的網路請求,但是 n 毫秒沒有回傳就 timeout 的函數。const foolProofRequest = (fn, n) => ?

and

2. 接續上題目,由於我們非常注重使用者交互,我們準備了三個同樣回傳格式的 endpoints A B C,能否將其設定為 A 容忍秒數為 2、B 容忍秒數為 3、C 容忍秒數為 1,且當一 endpoint 得到了回傳結果後,就將其傳入渲染函數 render 裡頭,當三個 endpoint 都失效、過期,請呼叫 exceptionHandler 函數,且在全部行為結束後,呼叫 Helloworld 函數。注1:若 A endpoint 例外或是超過兩秒,我們還是會等待 B endpoint 完成。
注2:若 A B C endpoint 皆例外或是過期,我們
Promise.???([foolProofRequest(A,2000),foolProofRequest(B,3000),foolProofRequest(C,1000)])
.then(???)
.catch(???)
.finally(???)

大家可以想想怎麼實作它。

延伸閱讀,一些以前寫過與 Promise 有關的文章:

--

--