JavaScript Promise 類的實作與逐步解說
讓我們透過 ES6 的 Class 來實作一個簡單的 Promise
本篇文章著重在 Promise 機制的實現,以下程式碼透過 Class 與 箭頭函數撰寫,不會使用 Function 或是動態call、bind 改變語彙,所有看到的
this
皆是代表該類別的 instance 。
寫在前面
很多讀者們一定會感覺有些矛盾,心想:ES6 本來就有 Promise 了,為什麼要透過 ES6 的 Class 去實作呢?
回答:再次強調,本篇文章著重在 Promise 機制的實現,挑選 Class 只是為了讓程式碼更易讀。如果有心的話把 Class 變回 Function 建構式,我相信肯定不是什麼難事。
我們要實現哪些功能?
主要是最簡單的「實現狀態的變換」「 then 與 catch」。就與你在 Google 搜尋輸入『Promise implement JavaScript』會跑出的那些結果差不多,只是中文資源太少了。
讓我們馬上開始
為了不更動到原始的 Promise ,我們的類就取名為 Promises
。
class Promises{}
Promise 是一個單純的有限狀態機
由於 Promise 一確定狀態之後就不會改變,所以只有三種可能(等待中、成功、失敗),若是用數學上的狀態機來表示,那麼只會有像是上圖中的 3 個 state 以及兩個單向的 transition ,分別是 0 →1 以及 0 →2 。
讓我們來設計建構式
首先建構式中,我們希望先初始化狀態。
constructor(fn){
this.state = 0; //pending
this.value = undefined;
this.todos = [];
fn(value=>this.resolve(value), reason=>this.reject(reason));
/*
* fn (成功的resolver, 失敗的resolver);
* 無論成功失敗都會讓狀態改變 我們接下來繼續定義
*/
}
讓我們回憶一下平常使用 Promise 的方式
new Promise((resolve, reject)=> {
if(/* some case */)
resolve('YES');
if(/* some case */)
reject(new Error)
})
我們傳入的 resolver
正是每次在封裝 Promise
的倆參數,當某些 statement 成立,比如 timer 、 網路請求完成了…等等,resolver
將會被執行時,意味著實例上的狀態也必須改變。你可以把我講的實例想象成狀態機。
2 個最簡單的 state transition
resolve
resolve(value){
if(this.state!==0) return;
this.state = 1;
this.value = value;
this.run()
}
reject
reject(reason){
if(this.state!==0) return;
this.state = 2;
this.value = reason;
this.run()
}
由於狀態改變只允許由 Pending(0) 轉換,所以如果其他狀態已經完成,便不會做任何操作,若當前狀態為 0 ,則改變當前狀態,並且將參數存入實例上的 value。
講 run 之前先來定義一下 then
then(onFulfilled, onRejected) {
let todo = new Promises(() => {}); todo.onFulfilled =
typeof onFulfilled === 'function' ? onFulfilled : null;
todo.onRejected =
typeof onRejected === 'function' ? onRejected : null; this.todos.push(todo);
this.run();
return todo;
}
由於 Promise 的 then 方法也會回傳一個已經 Promise 化的結果,所以我們必須回傳一個 Promise 實例,這裡取名為 todo
,這些 Promise 的 then 鏈會被收集在 todos 陣列裡頭,待會 run
的時候,會依序取出來依照對應狀態來決定要執行 onFulfilled
or onRejected
。
run 方法
run() {
let callbackName, resolver; if (this.state === 0) return;
if (this.state === 1) {
callbackName = 'onFulfilled';
resolver = 'resolve';
}
if (this.state === 2) {
callbackName = 'onRejected';
resolver = 'reject';
} setTimeout(() => {
this.todos.forEach(todo => {
try {
let cb = consumer[callbackName];
if (cb)
todo.resolve(cb(this.value));
else
todo[resolver](this.value);
}catch (e){
consumer.reject(e);
}
});
});
}
run 在做的什麼?
Promise.resolve().then( /* callback1 , callback2 */ )
由於 then
裡頭的可以接受兩個回呼參數,分別是 成功 與 失敗 的 callback
,所以 run
依照 this.state
來決定該執行哪個,並且將 this.value
傳進去 callback
裡頭。
這樣就完了嗎?
會遇到什麼問題呢?
const sleep = sec
=> new Promises(resolve=>setTimeout(resolve,sec*1000));sleep(1)
.then(()=>{
console.log('1 秒過去');
sleep(2)
})
.then(
()=>console.log('2 秒過去了')
)
結果你會發現兩個 console.log
會一起印出來,為什麼?
我們仔細來看看第一個 then 的 callback 實際上會回傳一個 Pending 2 秒的 Promises ,那麼我們應該去確認 callback 是否為 thenable 的 Promise ,並且等待它完成,而不是直接 resolve 。
讓我們再一次回顧開頭的圖片:
我們把這個狀態拆分成 Pending
→ Resolve
→ ( Fulfilled
or Rejected
)
所以原先的 resolve 被我們拆分成 resolve 以及 fulfill,fufill 與原始的 resolve 一樣,負責的任務是完事後的狀態 transition 。
fulfill(value) {
if (this.state !== 0) return;
this.state = 1;
this.value = value;
this.run();
}
新的 resolve 則身負重任
虛擬碼如下
resolve(result){
1. 如果(this === result)代表有人在 circular waiting
2. 如果 result 具有 then 方法,則代表是一 Promise,針對其做處理
3. 如果都不是 直接 call fulfill 幫我transition狀態
}
我們直接討論第 2 點:
當 resolve 接受到的 result 具有 then 方法:
let done = false;
if (result && typeof result.then === 'function') {
try {
result.then(
value => {
if (done) return;
done = true;
this.resolve(value);
},
reason => {
if (done) return;
done = true;
this.reject(reason);
}
);
} catch (e) {
if (done) return;
done = true;
this.reject(e);
}
}
我知道又臭又長,我當初也是看的很久很久才看懂,但是莫驚莫慌莫害怕。
我們直接來舉一個最直接的例子:
sleep(2)
.then( ()=> sleep(1).then(()=>sleep(2)) )
/* ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* 這串 thenable 的 東東我們就叫他 tb 好了
* 我們想要拿到 tb 的結果並且傳給下面的 then
* 把 tb 提出來 並且在他最後面呼叫 then
* 成功的話遞迴呼叫 resolve 失敗就 reject
* /
.then(()=>console.log('5 secs'))
OK 結束。
再給各位看一次完整程式碼,並一行一行廢話一下。
constructor (line 2–7)
主要初始化三個東西,狀態機的狀態、將來會傳進來的 value、todos 陣列。到底為什麼要 todos 呢?原因很簡單,舉個例子你就懂了。
const c = sleep(10);
c.then (()=> console.log('10 second pass'))
c.then (()=> console.log('Hello,'))
c.then (()=> console.log('World!'))
你期待 10 秒後以什麼順序印出來呢?
transition methods (line 8–46)
- pending → resolve → fulfill
- pending → resolve → reject
- pending → reject
then (line 71–78)
- 每一次呼叫 then 方法都會回傳一個 Promise 的實例(instance),這樣的做法能夠讓後續繼續的鏈式呼叫。
- 另外這些註冊好的 todo 會依序被放進 todos 陣列裡頭
run method (line 47–70)
- 搭配 then 的設計,每一個 todo 實際上就是一個 Promise 實例,但是多了兩個方法分別是成功回呼與失敗回呼,端看狀態決定使用哪個。
- 如果回呼存在,原本外層的 value 狀態就會丟進去這個 then 的 callback。
Promise.resolve(()=>20).then(console.log)
- 如果回呼不存在,那簡單了,我們這層做什麼事,裡面那層就做什麼事!
todo.resolve
或是todo.reject
。 - 為什麼要用
setTimeout
去 wrapper?觀看下列例子,請憑直覺想一下輸出結果:
console.log('a');
const c = () => new Promise(resolve => resolve());
c().then(() => console.log('then'))
console.log('b')
你會覺得是 a → then →b
還是 a → b → then
呢?快打開你的 console 試試看吧!
好啦是 a →b → then 啦,透過 setTimeout
,我們才能把這些 handler 排隊到非同步佇列中。
寫在最後
做個小測試:
- 你終於看到頁尾了,按 1 下鼓掌代表看完這篇 10 min 的文章
- 如果覺得我的文章對你有幫助的話,請按下 5下鼓掌
- 如果你曾經精讀過這篇文章 請按下 10 下鼓掌
主要是幫助我了解一下,大於 5 分鐘文章,會不會讓讀者不願意看到最後。