面對 Promise 的思考路徑
Promise 是常見的面試問題,相信各位打工仔肯定是不陌生,但往往會是:用的時候覺得理所當然,成為考題時卻覺得絆手絆腳難以解釋。這邊我想用一篇文章來整理思考路徑,以讓下次碰到這類提問是有個思考模型。
前言(廢話)
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:
- Order concerned (一個接著一個)
- 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:
- 誰先結束誰贏 Promise.race 、Promise.any
- 大家一起結束 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 有關的文章:
- Promise 的 then 可以一直串下去,什麼是鏈式調用?——如何封裝一個可被鏈式調用的函式庫?
- 透過 Promise 把難搞的回呼函數變成 Promise 物件吧?——一起來把煩人 XMLHttpRequest 變成 Fetch 怎麼樣?
- 只能 Callback 方式寫作的非同步函數無腦變成 Promise 好爽喔!——Promisify 與 Callbackify — 你或許用不到,但了解一下也無妨
- Promise 好像是一個有限狀態機,那麼有辦法從零開始寫一個 Promise 的 class 嗎?——JavaScript Promise 類的實作與逐步解說