JavaScript Promise 類的實作與逐步解說

讓我們透過 ES6 的 Class 來實作一個簡單的 Promise

realdennis
11 min readJan 20, 2019

本篇文章著重在 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 是一個單純的有限狀態機

https://stackoverflow.com/questions/29268569/what-is-the-correct-terminology-for-javascript-promises

由於 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 。

讓我們再一次回顧開頭的圖片:

我們把這個狀態拆分成 PendingResolve → ( 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 分鐘文章,會不會讓讀者不願意看到最後。

參考資料

https://stackoverflow.com/questions/23772801/basic-javascript-promise-implementation-attempt/23785244#42057900

https://www.promisejs.org/implementing/

--

--