原生es5封裝的Promise物件

袁小巨集發表於2019-01-21

前言

前一陣看了一些關於JS非同步操作的文章,發現Promise真是個好東西,配合Generator或者async/await使用更有奇效。完美解決非同步程式碼書寫的回撥問題,有助於書寫更優雅的非同步程式碼。花了幾天時間研究了Promise的工作機制,手癢癢用es6語法封裝了一個Promise物件,基本實現了原生Promise的功能,現在,用es5語法再寫一遍。


更新說明

  • 更新時間:2019/1/23

@logbn520兄弟的提醒,我把then方法的執行做成同步的了,是不符合規範的。

《Promises/A+規範》中,【Then 方法】小節【呼叫時機】部分寫道:“onFulfilled 和 onRejected 只有在執行環境堆疊僅包含平臺程式碼時才可被呼叫”,這裡特別要看一下注釋。

因此我要把onFulfilledonRejected 的程式碼放在“ then 方法被呼叫的那一輪事件迴圈之後的新執行棧中執行”,通過setTimeout方法將任務放到本輪任務佇列的末尾。程式碼已新增到最後一部分-第九步。

關於任務佇列的執行機制,感興趣可看一下阮一峰老師的《JavaScript 執行機制詳解:再談Event Loop》


實現功能:

  • 已實現 Promise 基本功能,與原生一樣,非同步、同步操作均ok,具體包括:
    • MyPromise.prototype.then()
    • MyPromise.prototype.catch() 與原生 Promise 略有出入
    • MyPromise.prototype.finally()
    • MyPromise.all()
    • MyPromise.race()
    • MyPromise.resolve()
    • MyPromise.reject()
  • rejected 狀態的冒泡處理也已解決,當前Promise的reject如果沒有捕獲,會一直冒泡到最後,直到catch
  • MyPromise 狀態一旦改變,將不能再改變它的狀態

不足之處:

  • 程式碼的錯誤被catch捕獲時,提示的資訊(捕獲的錯誤物件)比原生Promise要多

測試: index.html

  • 這個頁面中包含了30個測試例子,分別測試了各項功能、各個方法,還有一些特殊情況測試;或許還有有遺漏的,感興趣自己可以玩一下;
  • 更加友好的視覺化的操作,方便測試,每次執行一個例子,右邊皮膚可看到結果;
  • 自定義了console.mylog()方法用來輸出結果,第一個引數是當前使用的Promise物件,用以區分輸出,檢視程式碼時可忽略,後面的引數都是輸出結果,與系統console.log()相似;
  • 建議同時開啟 index.js 邊看程式碼邊玩;
  • 同一套程式碼,上面的 MyPromise 的執行結果,下面是原生 Promise 執行的結果;

收穫

  • 再寫一遍又有新收穫,寫的更順了,理解更深刻了;
  • then/catch方法是最難的,要不停地修修補補;
  • reject狀態的冒泡是個難題,但在下面的程式碼中我沒有專門提及,我也沒有辦法具體說清楚他,我是在整個過程中不停地調才最終調出來正確的冒泡結果。

程式碼

下面貼程式碼,包括整個思考過程,會有點長
為了說明書寫的邏輯,我使用以下幾個註釋標識,整坨變動的程式碼只標識這一坨的開頭處。
//++ ——新增的程式碼
//-+ ——修改的程式碼

第一步,基礎功能實現

名字隨便取,我的叫MyPromise,沒有取代原生的Promise。

  • 建構函式傳入回撥函式 callback 。當新建 MyPromise 物件時,我們需要執行此回撥,並且 callback 自身也有兩個引數,分別是 resolverrejecter ,他們也是回撥函式的形式;
  • 定義了幾個變數儲存當前的一些結果與狀態、事件佇列,見註釋;
  • 執行函式 callback 時,如果是 resolve 狀態,將結果儲存在 this.__succ_res 中,狀態標記為成功;如果是 reject 狀態,操作類似;
  • 同時定義了最常用的 then 方法,是一個原型方法;
  • 執行 then 方法時,判斷物件的狀態是成功還是失敗,分別執行對應的回撥,把結果傳入回撥處理。
//幾個狀態常量
const PENDING = 'pending';
const FULFILLED = 'fulfilled';
const REJECTED = 'rejected';

function MyPromise(callback) {
    this.status = PENDING; //儲存狀態
    this.__succ__res = null; //儲存resolve結果
    this.__err__res = null; //儲存reject結果
    var _this = this;//必須處理this的指向
    function resolver(res) {
        _this.status = FULFILLED;
        _this.__succ__res = res;
    };
    function rejecter(rej) {
        _this.status = REJECTED;
        _this.__err__res = rej;
    };
    callback(resolver, rejecter);
};
MyPromise.prototype.then = function(onFulfilled, onRejected) {
    if (this.status === FULFILLED) {
        onFulfilled(this.__succ__res);
    } else if (this.status === REJECTED) {
        onRejected(this.__err__res);
    };
};
複製程式碼

到這裡,MyPromise 可以簡單實現一些同步程式碼,比如:

new MyPromise((resolve, reject) => {
    resolve(1);
}).then(res => {
    console.log(res);
});
//結果 1
複製程式碼

第二步,加入非同步處理

執行非同步程式碼時,then 方法會先於非同步結果執行,上面的處理還無法獲取到結果。

  • 首先,既然是非同步,then 方法在 pending 狀態時就執行了,所以新增一個 else
  • 執行 else 時,我們還沒有結果,只能把需要執行的回撥,放到一個佇列裡,等需要時執行它,所以定義了一個新變數 this.__queue 儲存事件佇列;
  • 當非同步程式碼執行完畢,這時候把 this.__queue 佇列裡的回撥統統執行一遍,如果是 resolve 狀態,則執行對應的 resolve 程式碼。
const PENDING = 'pending';
const FULFILLED = 'fulfilled';
const REJECTED = 'rejected';

function MyPromise(callback) {
    this.status = PENDING; //儲存狀態
    this.__succ__res = null; //儲存resolve結果
    this.__err__res = null; //儲存reject結果
    this.__queue = []; //++     事件佇列

    var _this = this;
    function resolver(res) {
        _this.status = FULFILLED;
        _this.__succ__res = res;
        _this.__queue.forEach(item => {//++     佇列中事件的執行
            item.resolve(res);
        });
    };
    function rejecter(rej) {
        _this.status = REJECTED;
        _this.__err__res = rej;
        _this.__queue.forEach(item => {//++     佇列中事件的執行
            item.reject(rej);
        });
    };
    callback(resolver, rejecter);
};

MyPromise.prototype.then = function(onFulfilled, onRejected) {
    if (this.status === FULFILLED) {
        onFulfilled(this.__succ__res);
    } else if (this.status === REJECTED) {
        onRejected(this.__err__res);
    } else {//++        pending狀態,新增佇列事件
        this.__queue.push({resolve: onFulfilled, reject: onRejected});
    };
};
複製程式碼

到這一步,MyPromise 已經可以實現一些簡單的非同步程式碼了。測試用例 index.html 中,這兩個例子已經可以實現了。

  • 1 非同步測試--resolve
  • 2 非同步測試--reject

第三步,加入鏈式呼叫

實際上,原生的 Promise 物件的then方法,返回的也是一個 Promise 物件,一個新的 Promise 物件,這樣才可以支援鏈式呼叫,一直then下去。。。 而且,then方法可以接收到上一個then方法處理return的結果。根據Promise的特性分析,這個返回結果有3種可能:

  1. MyPromise物件;
  2. 具有then方法的物件;
  3. 其他值。 根據這三種情況分別處理。
  • 第一個處理的是,then方法返回一個MyPromise物件,它的回撥函式接收resFnrejFn 兩個回撥函式;
  • 把成功狀態的處理程式碼封裝為handleFulfilled函式,接受成功的結果作為引數;
  • handleFulfilled函式中,根據onFulfilled返回值的不同,做不同的處理:
    • 首先,先獲取onFulfilled的返回值(如果有),儲存為returnVal
    • 然後,判斷returnVal是否有then方法,即包括上面討論的1、2中情況(它是MyPromise物件,或者具有then方法的其他物件),對我們來說都是一樣的;
    • 之後,如果有then方法,馬上呼叫其then方法,分別把成功、失敗的結果丟給新MyPromise物件的回撥函式;沒有則結果傳給resFn回撥函式。

reject狀態的鏈式呼叫的處理思路是類似的,在定義的handleRejected函式中,檢查onRejected返回的結果是否含then方法,分開處理。值得一提的是,如果返回的是普通值,應該呼叫的是resFn,而不是rejFn,因為這個返回值屬於新MyPromise物件,它的狀態不因當前MyPromise物件的狀態而確定。即是,返回了普通值,未表明reject狀態,我們預設為resolve狀態。

MyPromise.prototype.then = function(onFulfilled, onRejected) {
    var _this = this;
    return new MyPromise(function(resFn, rejFn) {
        if (_this.status === FULFILLED) {
            handleFulfilled(_this.__succ__res);     // -+
        } else if (_this.status === REJECTED) {
            handleRejected(_this.__err__res);       // -+
        } else {//pending狀態
            _this.__queue.push({resolve: handleFulfilled, reject: handleRejected}); // -+
        };

        function handleFulfilled(value) {   // ++   FULFILLED 狀態回撥
            // 取決於onFulfilled的返回值
            var returnVal = onFulfilled instanceof Function && onFulfilled(value) || value;
            if (returnVal['then'] instanceof Function) {
                returnVal.then(function(res) {
                    resFn(res);
                },function(rej) {
                    rejFn(rej);
                });
            } else {
                resFn(returnVal);
            };
        };
        function handleRejected(reason) {   // ++   REJECTED 狀態回撥
            if (onRejected instanceof Function) {
                var returnVal = onRejected(reason);
                if (typeof returnVal !== 'undefined' && returnVal['then'] instanceof Function) {
                    returnVal.then(function(res) {
                        resFn(res);
                    },function(rej) {
                        rejFn(rej);
                    });
                } else {
                    resFn(returnVal);
                };
            } else {
                rejFn(reason)
            }
        }

    })
};
複製程式碼

現在,MyPromise物件已經很好地支援鏈式呼叫了,測試例子:

  • 4 鏈式呼叫--resolve
  • 5 鏈式呼叫--reject
  • 28 then回撥返回Promise物件(reject)
  • 29 then方法reject回撥返回Promise物件

第四步,MyPromise.resolve()和MyPromise.reject()方法實現

因為其它方法對MyPromise.resolve()方法有依賴,所以先實現這個方法。
先要完全弄懂MyPromise.resolve()方法的特性,研究了阮一峰老師的ECMAScript 6 入門對於MyPromise.resolve()方法的描述部分。 由此得知,這個方法功能很簡單,就是把引數轉換成一個MyPromise物件,關鍵點在於引數的形式,分別有:

  • 引數是一個 MyPromise 例項;
  • 引數是一個thenable物件;
  • 引數不是具有then方法的物件,或根本就不是物件;
  • 不帶有任何引數。

處理的思路是:

  • 首先考慮極端情況,引數是undefined或者null的情況,直接處理原值傳遞;
  • 其次,引數是MyPromise例項時,無需處理;
  • 然後,引數是其它thenable物件的話,呼叫其then方法,把相應的值傳遞給新MyPromise物件的回撥;
  • 最後,就是普通值的處理。

MyPromise.reject()方法相對簡單很多。與MyPromise.resolve()方法不同,MyPromise.reject()方法的引數,會原封不動地作為reject的理由,變成後續方法的引數。

MyPromise.resolve = function(arg) {
    if (typeof arg === 'undefined' || arg === null) {   //undefined 或者 null
        return new MyPromise(function(resolve) {
            resolve(arg);
        });
    } else if (arg instanceof MyPromise) {      // 引數是MyPromise例項
        return arg;
    } else if (arg['then'] instanceof Function) {   // 引數是thenable物件
        return new MyPromise(function(resolve, reject) {
            arg.then(function (res) {
                resolve(res);
            }, function (rej) {
                reject(rej);
            });
        });
    } else {    // 其他值
        return new MyPromise(function (resolve) {
            resolve(arg);
        });
    };
};
MyPromise.reject = function(arg) {
    return  new MyPromise(function(resolve, reject) {
        reject(arg);
    });
};
複製程式碼

測試用例有8個:18-25,感興趣可以玩一下。

第五步,MyPromise.all()和MyPromise.race()方法實現

MyPromise.all()方法接收一堆MyPromise物件,當他們都成功時,才執行回撥。依賴MyPromise.resolve()方法把不是MyPromise的引數轉為MyPromise物件。
每個物件執行then方法,把結果存到一個陣列中,當他們都執行完畢後,即i === arr.length,才呼叫resolve()回撥,把結果傳進去。
MyPromise.race()方法也類似,區別在於,這裡做的是一個done標識,如果其中之一改變了狀態,不再接受其他改變。

MyPromise.all = function(arr) {
    if (!Array.isArray(arr)) {
        throw new TypeError('引數應該是一個陣列!');
    };
    return new MyPromise(function(resolve, reject) {
        var i = 0, result = [];
        next();
        function next() {
            // 對於不是MyPromise例項的進行轉換
            MyPromise.resolve(arr[i]).then(function (res) {
                result.push(res);
                i++;
                if (i === arr.length) {
                    resolve(result);
                } else {
                    next();
                };
            }, reject);
        }
    })
};
MyPromise.race =  function(arr) {
    if (!Array.isArray(arr)) {
        throw new TypeError('引數應該是一個陣列!');
    };
    return new MyPromise(function(resolve, reject) {
        let done = false;
        arr.forEach(function(item) {
            MyPromise.resolve(item).then(function (res) {
                if (!done) {
                    resolve(res);
                    done = true;
                };
            }, function(rej) {
                if (!done) {
                    reject(rej);
                    done = true;
                };
            });
        })
    });
};
複製程式碼

測試用例:

  • 6 all方法
  • 26 race方法測試

第六步,Promise.prototype.catch()和Promise.prototype.finally()方法實現

他們倆本質上是then方法的一種延伸,特殊情況的處理。

MyPromise.prototype.catch = function(errHandler) {
    return this.then(undefined, errHandler);
};
MyPromise.prototype.finally = function(finalHandler) {
    return this.then(finalHandler, finalHandler);
};
複製程式碼

測試用例:

  • 7 catch測試
  • 16 finally測試——非同步程式碼錯誤
  • 17 finally測試——同步程式碼錯誤

第七步,程式碼錯誤的捕獲

目前而言,我們的catch還不具備捕獲程式碼報錯的能力。思考,錯誤的程式碼來自於哪裡?肯定是使用者的程式碼,2個來源分別有:

  • MyPromise物件建構函式回撥
  • then方法的2個回撥 捕獲程式碼執行錯誤的方法是原生的try...catch...,所以我用它來包裹這些回撥執行,捕獲到的錯誤進行相應處理。
function MyPromise(callback) {
    this.status = PENDING; //儲存狀態
    this.__succ__res = null; //儲存resolve結果
    this.__err__res = null; //儲存reject結果
    this.__queue = []; //事件佇列

    var _this = this;
    function resolver(res) {
        _this.status = FULFILLED;
        _this.__succ__res = res;
        _this.__queue.forEach(item => {
            item.resolve(res);
        });
    };
    function rejecter(rej) {
        _this.status = REJECTED;
        _this.__err__res = rej;
        _this.__queue.forEach(item => {
            item.reject(rej);
        });
    };
    try {   // -+   在try……catch……中執行回撥函式
        callback(resolver, rejecter);
    } catch (err) {
        this.__err__res = err;
        this.status = REJECTED;
        this.__queue.forEach(function(item) {
            item.reject(err);
        });
    };
};

MyPromise.prototype.then = function(onFulfilled, onRejected) {
    var _this = this;
    return new MyPromise(function(resFn, rejFn) {
        if (_this.status === FULFILLED) {
            handleFulfilled(_this.__succ__res);
        } else if (_this.status === REJECTED) {
            handleRejected(_this.__err__res);
        } else {//pending狀態
            _this.__queue.push({resolve: handleFulfilled, reject: handleRejected});
        };

        function handleFulfilled(value) {
            var returnVal = value;
            // 獲取 onFulfilled 函式的返回結果
            if (onFulfilled instanceof Function) {
                try {       // -+   在try……catch……中執行onFulfilled回撥函式
                    returnVal = onFulfilled(value);
                } catch (err) { // 程式碼錯誤處理
                    rejFn(err);
                    return;
                };
            };

            if (returnVal && returnVal['then'] instanceof Function) {
                returnVal.then(function(res) {
                    resFn(res);
                },function(rej) {
                    rejFn(rej);
                });
            } else {
                resFn(returnVal);
            };
        };
        function handleRejected(reason) {
            if (onRejected instanceof Function) {
                var returnVal
                try {// -+   在try……catch……中執行onRejected回撥函式
                    returnVal = onRejected(reason);
                } catch (err) {
                    rejFn(err);
                    return;
                };
                if (typeof returnVal !== 'undefined' && returnVal['then'] instanceof Function) {
                    returnVal.then(function(res) {
                        resFn(res);
                    },function(rej) {
                        rejFn(rej);
                    });
                } else {
                    resFn(returnVal);
                };
            } else {
                rejFn(reason)
            }
        }

    })
};
複製程式碼

測試用例:

  • 11 catch測試——程式碼錯誤捕獲
  • 12 catch測試——程式碼錯誤捕獲(非同步)
  • 13 catch測試——then回撥程式碼錯誤捕獲
  • 14 catch測試——程式碼錯誤catch捕獲

其中第12個非同步程式碼錯誤測試,結果顯示是直接報錯,沒有捕獲錯誤,原生的Promise也是這樣的,我有點不能理解為啥不捕獲處理它。

在這裡插入圖片描述

第八步,處理MyPromise狀態確定不允許再次改變

這是Promise的一個關鍵特性,處理起來不難,在執行回撥時加入狀態判斷,如果已經是成功或者失敗狀態,則不執行回撥程式碼。

function MyPromise(callback) {
    //略……

    var _this = this;
    function resolver(res) {
        if (_this.status === PENDING) {
            _this.status = FULFILLED;
            _this.__succ__res = res;
            _this.__queue.forEach(item => {
                item.resolve(res);
            });
        };
    };
    function rejecter(rej) {
        if (_this.status === PENDING) {
            _this.status = REJECTED;
            _this.__err__res = rej;
            _this.__queue.forEach(item => {
                item.reject(rej);
            });
        };
    };
    
    //略……
};
複製程式碼

測試用例:

  • 27 Promise狀態多次改變

第九步,onFulfilled 和 onRejected 方法非同步執行

到這裡為止,如果執行下面一段程式碼,

function test30() {
  function fn30(resolve, reject) {
      console.log('running fn30');
      resolve('resolve @fn30')
  };
  console.log('start');
  let p = new MyPromise(fn30);
  p.then(res => {
      console.log(res);
  }).catch(err => {
      console.log('err=', err);
  });
  console.log('end');
};
複製程式碼

輸出結果是:

//MyPromise結果
// start
// running fn30
// resolve @fn30
// end

//原生Promise結果:
// start
// running fn30
// end
// resolve @fn30
複製程式碼

兩個結果不一樣,因為onFulfilled 和 onRejected 方法不是非同步執行的,需要做以下處理,將它們的程式碼放到本輪任務佇列的末尾執行。

function MyPromise(callback) {
    //略……

    var _this = this;
    function resolver(res) {
        setTimeout(() => {      //++ 利用setTimeout調整任務執行佇列
            if (_this.status === PENDING) {
                _this.status = FULFILLED;
                _this.__succ__res = res;
                _this.__queue.forEach(item => {
                    item.resolve(res);
                });
            };            
        }, 0);
    };
    function rejecter(rej) {
        setTimeout(() => {      //++
            if (_this.status === PENDING) {
                _this.status = REJECTED;
                _this.__err__res = rej;
                _this.__queue.forEach(item => {
                    item.reject(rej);
                });
            };            
        }, 0);
    };
    
    //略……
};
複製程式碼

測試用例:

  • 30 then方法的非同步執行

以上,是我所有的程式碼書寫思路、過程。完整程式碼與測試程式碼到github下載


參考文章

相關文章