JavaScript 非同步與 Promise 實現

前端魔法師發表於2017-05-10

在閱讀本文之前,你應該已經瞭解JavaScript非同步實現的幾種方式:回撥函式,釋出訂閱模式,Promise,生成器(Generator),其實還有async/await方式,這個後續有機會會介紹。本篇將介紹Promise,讀完你應該瞭解什麼是Promise,為什麼使用Promise,而不是回撥函式,Promise怎麼使用,使用Promise需要注意什麼,以及Promise的簡單實現。

前言

如果你已經對JavaScript非同步有一定了解,或者已經閱讀過本系列的其他兩篇文章,那請繼續閱讀下一小節,若你還有疑惑或者想了解JavaScript非同步機制與程式設計,可以閱讀一遍這兩篇文章:

回撥函式

回撥函式,作為JavaScript非同步程式設計的基本單元,非常常見,你肯定對下面這類程式碼一點都不陌生:


    component.do('purchase', funcA);
    function funcA(args, callback) {
        //...
        setTimeout(function() {
            $.ajax(url, function(res) {
                if (res) {
                    callback(res)
                } else {//...}
            });
        }, 300);
        funcB();
        setTimeout(function() {
            $.ajax(arg, function(res) {
                if (res) {
                    callback(res);
                }
            });
        }, 400);
    }複製程式碼

上面這些程式碼,一層一層,巢狀在一起,這種程式碼通常稱為回撥地獄,無論是可讀性,還是程式碼順序,或者回撥是否可信任,亦或是異常處理角度看,都是不盡人意的,下面做簡單闡述。

順序性

上文例子中程式碼funcB函式,還有兩個定時器回撥函式,回撥內各自又有一個ajax非同步請求然後在請求回撥裡面執行最外層傳入的回撥函式,對於這類程式碼,你是否能明確指出個回撥的執行順序呢?如果funcB函式內還有非同步任務呢?,情況又如何?

假如某一天,比如幾個月後,線上出了問題,我們需要跟蹤非同步流,找出問題所在,而跟蹤這類非同步流,不僅需要理清個非同步任務執行順序,還需要在眾多回撥函式中不斷地跳躍,除錯(或許你還能記得諸如funcB這些函式的作用和實現),無論是出於效率,可讀性,還是出於人性化,都不希望開開發者們再經歷這種痛苦。

信任問題

如上,我們呼叫了一個第三方支付元件的支付API,進行購買支付,正常情況發現一切執行良好,但是假如某一天,第三方元件出問題了,可能多次呼叫傳入的回撥,也可能傳回錯誤的資料。說到底,這樣的回撥巢狀,控制權在第三方,對於回撥函式的呼叫方式、時間、次數、順序,回撥函式引數,還有下一節將要介紹的異常和錯誤都是不可控的,因為無論如何,並不總能保證第三方是可信任的。

錯誤處理

關於JavaScript錯誤異常,初中級開發接觸的可能並不多,但是其實還是有很多可以學習實踐的地方,如前端異常監控系統的設計,開發和部署,並不是三言兩語能闡述的,之後會繼續推出相關文章。

錯誤堆疊

我們知道當JavaScript丟擲錯誤或異常時,對於未捕獲異常,瀏覽器會預設在控制檯輸出錯誤堆疊資訊,如下,當test未定義時:


    function init(name) {
        test(name)
    }

    init('jh');複製程式碼

輸出如圖:

JavaScript 非同步與 Promise 實現
錯誤堆疊資訊

如圖中自頂向下輸出紅色異常堆疊資訊,Uncaught表示該異常未捕獲,ReferenceError表明該異常型別為引用異常,冒號後是異常的詳細資訊:test is not definedtest未定義;後面以at起始的行就是該異常發生處的呼叫堆疊。第一行說明異常發生在init函式,第二行說明init函式的呼叫環境,此處在控制檯直接呼叫,即相當於在匿名函式環境內呼叫。

非同步錯誤堆疊

上面例子是同步程式碼執行的異常,當異常發生在非同步任務內時,又會如何呢?,假如把上例中程式碼放在一個setTimeout定時器內執行:

    function init(name) {
        test(name)
    }
    setTimeout(function A() {
        setTimeout(function() {
            init();
        }, 0);
    }, 0);複製程式碼

如圖:

JavaScript 非同步與 Promise 實現
非同步回撥異常堆疊

可以看到,非同步任務中的未捕獲異常,也會在控制檯輸出,但是setTimeout非同步任務回撥函式沒有出現在異常堆疊,為什麼呢?這是因為當init函式執行時,setTimeout的非同步回撥函式不在執行棧內,而是通過事件佇列呼叫。

JavaScript錯誤處理

JavaScript的異常捕獲,主要有兩種方式:

  • try{}catch(e){}主動捕獲異常;

    JavaScript 非同步與 Promise 實現
    try{}catch同步異常

    如上,對於同步執行大程式碼出現異常,try{}catch(e){}是可以捕獲的,那麼非同步錯誤呢?

    JavaScript 非同步與 Promise 實現
    try{}catch與非同步異常

    如上圖,我們發現,非同步回撥中的異常無法被主動捕獲,由瀏覽器預設處理,輸出錯誤資訊。

  • window.onerror事件處理器,所有未捕獲異常都會自動進入此事件回撥

    JavaScript 非同步與 Promise 實現
    onerror事件處理器監聽非同步錯誤

    如上圖,輸出了script error錯誤資訊,同時,你也許注意到了,控制檯依然列印出了錯誤堆疊信 息,或許你不希望使用者看到這麼醒目的錯誤提醒,那麼可以使window.onerror的回撥返回true即可阻止瀏覽器的預設錯誤處理行為:

    JavaScript 非同步與 Promise 實現
    阻止瀏覽器的預設錯誤處理行為

    當然,一般不隨意設定window.onerror回撥,因為程式通常可能需要部署前端異常監控系統,而通常就是使用window.onerror處理器實現全域性異常監控,而該事件處理器只能註冊一個回撥。

回撥與Promise

以上我們談到的諸多關於回撥的不足,都很常見,所以必須是需要解決的,而Promise正是一種很好的解決這些問題的方式,當然,現在已經提出了比Promise更先進的非同步任務處理方式,但是目前更大範圍使用,相容性更好的方式還是Promise,也是本篇要介紹的,之後會繼續介紹其他處理方式。

Promises/A+

分析了一大波問題後,我們知道Promise的目標是非同步管理,那麼Promise到底是什麼呢?

  • 非同步,表示在將來某一時刻執行,那麼Promise也必須可以表示一個將來值;
  • 非同步任務,可能成功也可能失敗,則Promise需要能完成事件,標記其狀態值(這個過程即決議-resolve,下文將詳細介紹);
  • 可能存在多重非同步任務,即非同步任務回撥中有非同步任務,所以Promise還需要支援可重複使用,新增非同步任務(表現為順序鏈式呼叫,註冊非同步任務,這些非同步任務將按註冊的順序執行)。

所以,Promise是一種封裝未來值的易於複用的非同步任務管理機制。

為了更好的理解Promise,我們介紹一下Promises/A+,一個公開的可操作的Promises實現標準。先介紹標準規範,再去分析具體實現,更有益於理解。

Promise代表一個非同步計算的最終結果。使用promise最基礎的方式是使用它的then方法,該方法會註冊兩個回撥函式,一個接收promise完成的最終值,一個接收promise被拒絕的原因。

Promises/A

你可能還會想問Promises/A是什麼,和Promises/A+有什麼區別。Promises/A+在Promises/A議案的基礎上,更清晰闡述了一些準則,擴充覆蓋了一些事實上的行為規範,同時刪除了一些不足或者有問題的部分。

Promises/A+規範目前只關注如何提供一個可操作的then方法,而關於如何建立,決議promises是日後的工作。

術語

  1. promise: 指一個擁有符合規範的then方法的物件;
  2. thenable: 指一個定義了then方法的物件;
  3. 決議(resolve): 改變一個promise等待狀態至已完成或被拒絕狀態, 一旦決議,不再可變;
  4. 值(value): 一個任意合法的JavaScript值,包括undefined,thenable物件,promise物件;
  5. exception/error: JavaScript引擎丟擲的異常/錯誤
  6. 拒絕原因(reject reason): 一個promise被拒絕的原因

Promise狀態

一個promise只可能處於三種狀態之一:

  • 等待(pending):初始狀態;
  • 已完成(fulfilled):操作成功完成;
  • 被拒絕(rejected):操作失敗;

這三個狀態變更關係需滿足以下三個條件:

  • 處於等待(pending)狀態時,可以轉變為已完成(fulfilled)或者被拒絕狀態(rejected);
  • 處於已完成狀態時,狀態不可變,且需要有一個最終值;
  • 處於被拒絕狀態時,狀態不可變,且需要有一個拒絕原因。

then方法

一個promise必須提供一個then方法,以供訪問其當前狀態,或最終值或拒絕原因。

引數

該方法接收兩個引數,如promise.then(onFulfilled, onRejected):

  • 兩個引數均為可選,均有預設值,若不傳入,則會使用預設值;
  • 兩個引數必須是函式,否則會被忽略,使用預設函式;
  • onFulfilled: 在promise已完成後呼叫且僅呼叫一次該方法,該方法接受promise最終值作引數;
  • onRejected: 在promise被拒絕後呼叫且僅呼叫一次該方法,該方法接受promise拒絕原因作引數;
  • 兩個函式都是非同步事件的回撥,符合JavaScript事件迴圈處理流程

返回值

該方法必須返回一個promise:


    var promise2 = promise1.then(onFulfilled, onRejected);
    // promise2依然是一個promise物件複製程式碼

決議過程(resolution)

決議是一個抽象操作過程,該操作接受兩個輸入:一個promise和一個值,可以記為;[[resolve]](promise, x),如果x是一個thenable物件,則嘗試讓promise引數使用x的狀態值;否則,將使用x值完成傳入的promise,決議過程規則如下:

  1. 如果promisex引用自同一物件,則使用一個TypeError原因拒絕此promise;
  2. x為Promise,則promise直接使用x的狀態;
  3. x為物件或函式:
    1. 獲取一個x.then的引用;
    2. 若獲取x.then時丟擲異常e,使用該e作為原因拒絕promise;
    3. 否則將該引用賦值給then;
    4. then是一個函式,就呼叫該函式,其作用域為x,並傳遞兩個回撥函式引數,第一個是resolvePromise,第二個是rejectPromise
      1. 若呼叫了resolvePromise(y),則執行resolve(promise, y);
      2. 若呼叫了rejectPrtomise(r),則使用原因r拒絕promise;
      3. 若多次呼叫,只會執行第一次呼叫流程,後續呼叫將被忽略;
      4. 若呼叫then丟擲異常e,則:
        1. promise已決議,即呼叫了resolvePromiserejectPrtomise,則忽略此異常;
        2. 否則,使用原因e拒絕promise;
    5. then不是函式,則使用x值完成promise;
  4. x不是物件或函式,則使用x完成promise

自然,以上規則可能存在遞迴迴圈呼叫的情況,如一個promsie被一個迴圈的thenable物件鏈決議,此時自然是不行的,所以規範建議進行檢測,是否存在遞迴呼叫,若存在,則以原因TypeError拒絕promise

Promise

在ES6中,JavaScript已支援Promise,一些主流瀏覽器也已支援該Promise功能,如Chrome,先來看一個Promsie使用例項:


    var promise = new Promise((resolve, reject) => {
        setTimeout(function() {
            resolve('完成');
        }, 10);
    });
    promise.then((msg) => {
        console.log('first messaeg: ' + msg);
    })
    promise.then((msg) => {
        console.log('second messaeg: ' + msg);
    });複製程式碼

輸出如下:

JavaScript 非同步與 Promise 實現
promise例項

構造器

建立promise語法如下:


    new Promise(function(resolve, reject) {});複製程式碼
  • 引數

    一個函式,該函式接受兩個引數:resolve函式和reject函式;當例項化Promise建構函式時,將立即呼叫該函式,隨後返回一個Promise物件。通常,例項化時,會初始一個非同步任務,在非同步任務完成或失敗時,呼叫resolve或reject函式來完成或拒絕返回的Promise物件。另外需要注意的是,若傳入的函式執行丟擲異常,那麼這個promsie將被拒絕。

靜態方法

Promise.all(iterable)

all方法接受一個或多個promsie(以陣列方式傳遞),返回一個新promise,該promise狀態取決於傳入的引數中的所有promsie的狀態:

  1. 當所有promise都完成是,返回的promise完成,其最終值為由所有完成promsie的最終值組成的陣列;
  2. 當某一promise被拒絕時,則返回的promise被拒絕,其拒絕原因為第一個被拒絕promise的拒絕原因;
    var p1 = new Promise((resolve, reject) => {
        setTimeout(function(){
            console.log('p1決議');
            resolve('p1');
        }, 10);
    });
    var p2 = new Promise((resolve, reject) => {
        setTimeout(function(){
            console.log('p2決議');
            resolve('p2');
        }, 10);
    });
    Promise.all( [p1, p2] )
    .then((msgs) => {
        // p1和p2完成並傳入最終值
        console.log(JSON.stringify(msgs));
    })
    .then((msg) => {
        console.log( msg );
    });複製程式碼

輸出如下:

JavaScript 非同步與 Promise 實現
Promise.all例項

Promise.race(iterable)

race方法返回一個promise,只要傳入的諸多promise中的某一個完成或被拒絕,則該promise同樣完成或被拒絕,最終值或拒絕原因也與之相同。

Promise.resolve(x)

resolve方法返回一個已決議的Promsie物件:

  1. x是一個promise或thenable物件,則返回的promise物件狀態同x;
  2. x不是物件或函式,則返回的promise物件以該值為完成最終值;
  3. 否則,詳細過程依然按前文Promsies/A+規範中提到的規則進行。

該方法遵循Promise/A+決議規範。

Promsie.reject(reason)

返回一個使用傳入的原因拒絕的Promise物件。

例項方法

Promise.prototype.then(onFulfilled, onRejected)

該方法為promsie新增完成或拒絕處理器,將返回一個新的promise,該新promise接受傳入的處理器呼叫後的返回值進行決議;若promise未被處理,如傳入的處理器不是函式,則新promise維持原來promise的狀態。

我們通過兩個例子介紹then方法,首先看第一個例項:


    var promise = new Promise((resolve, reject) => {
        setTimeout(function() {
            resolve('完成');
        }, 10);
    });
    promise.then((msg) => {
        console.log('first messaeg: ' + msg);
    }).then((msg) => {
        console.log('second messaeg: ' + msg);
    });複製程式碼

輸出如下:

JavaScript 非同步與 Promise 實現
then例項

輸出兩行資訊:我們發現第二個then方法接收到的最終值是undefined,為什麼呢?看看第一個then方法呼叫後返回的promise狀態如下:

JavaScript 非同步與 Promise 實現
then方法返回的promise

如上圖,發現呼叫第一個then方法後,返回promise最終值為undefined,傳遞給第二個then的回撥,如果把上面的例子稍加改動:


    var promise = new Promise((resolve, reject) => {
        setTimeout(function() {
            resolve('完成');
        }, 10);
    });
    promise.then((msg) => {
        console.log('first messaeg: ' + msg);
        return msg + '第二次';
    }).then((msg) => {
        console.log('second messaeg: ' + msg);
    });複製程式碼

輸出如下:

JavaScript 非同步與 Promise 實現
promise狀態

這次兩個then方法的回撥都接收到了最終值,正如我們前文所說,'then'方法返回一個新promise,並且該新promise根據其傳入的回撥執行的返回值,進行決議,而函式未明確return返回值時,預設返回的是undefined,這也是上面例項第二個then方法的回撥接收undefined引數的原因。

這裡使用了鏈式呼叫,我們需要明確:共產生三個promise,初始promise,兩個then方法分別返回一個promise;而第一個then方法返回的新promise是第二個then方法的主體,而不是初始promise。

Promise.prototype.catch(onRejected)

該方法為promise新增拒絕回撥函式,將返回一個新promise,該新promise根據回撥函式執行的返回值進行決議;若promise決議為完成狀態,則新promise根據其最終值進行決議。


    var promise = new Promise((resolve, reject) => {
        setTimeout(() => {
            reject('failed');
        }, 0);
    });

    var promise2 = promise.catch((reason) => {
        console.log(reason);
        return 'successed';
    });
    var promise3 = promise.catch((reason) => {
        console.log(reason);
    });
    var promise4 = promise.catch((reason) => {
        console.log(reason);
        throw 'failed 2';
    });複製程式碼

輸出如下圖:

JavaScript 非同步與 Promise 實現
Promise catch例項

如圖中所輸出內容,我們需要明白以下幾點:

  1. catch會為promise註冊拒絕回撥函式,一旦非同步操作結束,呼叫了reject回撥函式,則依次執行註冊的拒絕回撥;
  2. 另外有一點和then方法相似,catch方法返回的新promise將使用其回撥函式執行的返回值進行決議,如promise2,promise3狀態均為完成(resolved),但是promise3最終值為undefined,而promise2最終值為successed,這是因為在呼叫promise.catch方法時,傳入的回撥沒有顯式的設定返回值;
  3. 對於promise4,由於呼叫catch方法時,回撥中throw丟擲異常,所以promise4狀態為拒絕(rejected),拒絕原因為丟擲的異常;
  4. 特別需要注意的是這裡一共有四個promise,一旦決議,它們之間都是獨立的,我們需要明白無論是then方法,還是catch方法,都會返回一個新promise,此新promise與初始promise相互獨立。

catch方法和then方法的第二個引數一樣,都是為promise註冊拒絕回撥。

鏈式呼叫

和jQuery的鏈式呼叫一樣,Promise設計也支援鏈式呼叫,上一步的返回值作為下一步方法呼叫的主體:


    new Promise((resolve, reject) => {
        setTimeout(()=>{
            resolve('success');
        },0);
    }).then((msg) => {
        return 'second success';
    }).then((msg) => {
        console.log(msg);
    });複製程式碼

最後輸出:second success,初始化promise作為主體呼叫第一個then方法,返回完成狀態的新promise其最終值為second success,然後該新promise作為主體呼叫第二個then方法,該方法返回第三個promise,而且該promise最終值為undefined,若不清楚為什麼,請回到關於Promise.prototype.thenPromise.prototype.catch的介紹。

錯誤處理

我們前文提到了JavaScript非同步回撥中的異常是難以處理的,而Promise對非同步異常和錯誤的處理是比較方便的:


    var promise = new Promise((resolve, reject) => {
        test(); // 丟擲異常
        resolve('success'); // 被忽略
    });
    console.log(promise);
    promise.catch((reason) => {
        console.log(reason);
    });複製程式碼

輸出如圖,執行test丟擲異常,導致promise被拒絕,拒絕原因即丟擲的異常,然後執行catch方法註冊的拒絕回撥:

JavaScript 非同步與 Promise 實現
promise錯誤處理

決議,完成與拒絕

目前為止,關於Promise是什麼,我們應該有了一定的認識,這裡,需要再次說明的是Promise的三個重要概念及其關係:決議(resolve),完成(fulfill),拒絕(reject)。

  1. 完成與拒絕是Promise可能處於的兩種狀態;
  2. 決議是一個過程,是Promise由等待狀態變更為完成或拒絕狀態的一個過程;
  3. 靜態方法Promise.resolve描述的就是一個決議過程,而Promise建構函式,傳入的回撥函式的兩個引數:resolve和reject,一個是完成函式,一個是拒絕函式,這裡令人疑惑的是為什麼這裡依然使用resolve而不是fulfill,我們通過一個例子解釋這個問題:

    var promise = new Promise((resolve, reject) => {
        resolve(Promise.reject('failed'));
    });
    promise.then((msg) => {
        console.log('完成:' + msg);
    }, (reason) => {
        console.log('拒絕:' + reason);
    });複製程式碼

輸出如圖:

JavaScript 非同步與 Promise 實現
Promise resolve reject

上例中,在建立一個Promise時,給resolve函式傳遞的是一個拒絕Promise,此時我們發現promise狀態是rejected,所以這裡第一個引數函式執行,完成的是一個更接近決議的過程(可以參考前文講述的決議過程),所以命名為resolve是更合理的;而第二個引數函式,則只是拒絕該promise:


    var promise = new Promise((resolve, reject) => {
        reject(Promise.resolve('success'));
    });
    promise.then((msg) => {
        console.log('完成:' + msg);
    }, (reason) => {
        console.log('拒絕:' + reason);
    });複製程式碼

reject函式並不會處理引數,而只是直接將其當做拒絕原因拒絕promise。

Promise實現

Promise是什麼,怎麼樣使用就介紹到此,另外一個問題是面試過程中經常也會被提及的:如何實現一個Promise,當然,限於篇幅,我們這裡只講思路,不會長篇大論。

建構函式

首先建立一個建構函式,供例項化建立promise,該建構函式接受一個函式引數,例項化時,會立即呼叫該函式,然後返回一個Promise物件:


    var MyPromise = (() => {
        var value = undefined; // 當前Promise
        var tasks = []; // 完成回撥佇列
        var rejectTasks = []; // 拒絕回撥佇列
        var state = 'pending'; // Promise初始為等待態

        // 輔助函式,使非同步回撥下一輪事件迴圈執行
        var nextTick = (callback) => {
            setTimeout(callback, 0);
        };

        // 輔助函式,傳遞Promsie的狀態值
        var ref = (value) => {
            if (value && typeof value.then === 'function') {
                // 若狀態值為thenable物件或Promise,直接返回
                return value;
            }
            // 否則,將最終值傳遞給下一個then方法註冊的回撥函式
            return {
                then: function(callback) {
                    return ref(callback(value));
                }
            }
        };
        var resolve = (val) => {};
        var reject = (reason) => {};

        function MyPromise(func) {
            func(resolve.bind(this), reject.bind(this));
        }

        return MyPromise;
    });複製程式碼

靜態方法

在例項化建立Promise時,我們會將建構函式的兩個靜態方法:resolvereject傳入初始函式,接下來需要實現這兩個函式:


    var resolve = (val) => {
        if (tasks) {
            value = ref(val);
            state = 'resolved'; // 將狀態標記為已完成
            // 依次執行任務回撥
            tasks.forEach((task) => {
                value = nextTick((val) => {task[0](self.value);});
            });
            tasks = undefined; // 決議後狀態不可變

            return this;
        }
    };
    var reject = (reason) => {
        if (tasks) {
            value = ref(reason);
            state = 'rejected'; // 將狀態標記為已完成

            // 依次執行任務回撥
            tasks.forEach((task) => {
                nextTick((reason) => {task[1](value);});
            });
            tasks = undefined; // 決議後狀態不可變

            return this;
        }
    };複製程式碼

還有另外兩個靜態方法,原理還是一樣,就不細說了。

例項方法

目前建構函式,和靜態方法完成和拒絕Promise都已經實現,接下來需要考慮的是Promise的例項方法和鏈式呼叫:


    MyPromise.prototype.then = (onFulfilled, onRejected) => {
        onFulfilled = onFulfilled || function(value) {
            // 預設的完成回撥
            return value;
        };
        onRejected = onRejected || function(reason) {
            // 預設的拒絕回撥
            return reject(reason);
        };

        if (tasks) {
            // 未決議時加入佇列
             tasks.push(onFulfilled);
             rejectTasks.push(onRejected);
        } else {
            // 已決議,直接加入事件迴圈執行
             nextTick(() => {
                 if (state === 'resolved') {
                     value.then(onFulfilled);
                 } else if (state === 'rejected') {
                     value.then(onRejected);
                 }
             });
        }

        return this;
    };複製程式碼

例項

以上可以簡單實現Promise部分非同步管理功能:


    var promise = new MyPromise((resolve, reject) => {
        setTimeout(() => {
            resolve('完成');
        }, 0);
    });
    promise.then((msg) => {console.log(msg);});複製程式碼

本篇由回撥函式起,介紹了回撥處理非同步任務的常見問題,然後介紹Promises/A+規範及Promise使用,最後就Promise實現做了簡單闡述(之後有機會會詳細實現一個Promise),花費一週終於把基本知識點介紹完,下一篇將介紹JavaScript非同步與生成器實現。

參考

  1. Promises/A+ specification
  2. JavaScript Promise

歡迎訪問我的個人部落格

相關文章