JavaScript Promise 詳解

lanyu發表於2021-09-09

原文連結:

讀完這篇文章,預計會消耗你 40 分鐘的時間。

Ajax 出現的時候,刮來了一陣非同步之風,現在 Nodejs 火爆,又一陣非同步狂風颳了過來。需求是越來越苛刻,使用者對效能的要求也是越來越高,隨之而來的是頁面非同步操作指數般增長,如果不能恰當的控制程式碼邏輯,我們就會陷入無窮的回撥地獄中。

ECMAScript 6 已經將非同步操作納入了規範,現代瀏覽器也內建了 Promise 物件供我們進行非同步程式設計,那麼此刻,還在等啥?趕緊學習學習 Promise 的內部原理吧!

由於 javascript 的單執行緒性質,我們必須等待上一個事件執行完成才能處理下一步,如下:

// DOM ready之後執行$(document).ready(function(){    // 獲取模板
    $.get(url, function(tpl){        // 獲取資料
        $.get(url2, function(data){            // 構建 DOMString
            makeHtml(tpl, data, function(str){                // 插入到 DOM 中
                $(obj).html(str);
            });
        });
    });
});

為了減少首屏資料的載入,我們將一些模板和所有資料都放在伺服器端,當使用者操作某個按鈕時,需要將模板和資料拼接起來插入到 DOM 中,這個過程還必須在 DOMReady 之後才能執行。這種情況是十分常見的,如果非同步操作再多一些,整個程式碼的縮排讓人看著很不舒服,為了優雅地處理這個問題,ECMAScript 6 引入了 Promise 的概念,目前一些現代瀏覽器已經支援這些新東西了!

為了讓程式碼流程更加清晰,我們假想著能夠按照下面的流程來跑程式:

new Promise(ready).then(getTpl).then(getData).then(makeHtml).resolve();

先將要事務按照執行順序依次 push 到事務佇列中,push 完了之後再透過 resolve 函式啟動整個流程。

整個流程的操作模型如下:

promise(ok).then(ok_1).then(ok_2).then(ok_3).reslove(value)------+         |         |          |          |                       |
         |         |          |          |        +=======+      |
         |         |          |          |        |       |      |
         |         |          |          |        |       |      |
         +---------|----------|----------|--------  ok() ------+
                   |          |          |        |      |
                   |          |          |        |      |
                   +----------|----------|-------- ok_1()|
                              |          |        |      |
                              |          |        |      |
                              +----------|-------- ok_2()|
                                         |        |      |
                                         |        |      |
                                         +-------- ok_3()-----+                                                  |       |    |       
                                                  |       |    
@ Created By Barret Lee                           +=======+   exit

在 resolve 之前,promise 的每一個 then 都會將回撥函式壓入佇列,resolve 後,將 resolve 的值送給佇列的第一個函式,第一個函式執行完畢後,將執行結果再送入下一個函式,依次執行完佇列。一連串下來,一氣呵成,沒有絲毫間斷。

如果瞭解 Promise,可以移步下方,看看對 Promise 的封裝:

Github: 
DEMO: 

如果還不是很瞭解,可以往下閱讀全文,瞭解一二。

那麼,什麼是 Promise ?

Promise 可以簡單理解為一個事務,這個事務存在三種狀態:

  1. 已經完成了 resolved

  2. 因為某種原因被中斷了 rejected

  3. 還在等待上一個事務結束 pending

上文中我們舉了一個栗子,獲取模板和資料之後再將拼合的資料插入到 DOM 中,這裡我們將整個程式分解成多個事務:

事務一:     獲取模板
               
事務二:     獲取資料
               
事務三: 拼合之後插入到 DOM

在事務一結束之前,也就是模板程式碼從伺服器拉取過來之前,事務二和事務三都處於 pending 狀態,他們必須等待上一個事務結束。而事務一結束之後會將自身狀態標記為 resolved,並把該事務中處理的結果移交給事務二繼續處理(當然,這裡如果沒有資料返回,事務二就不會獲得上一個事務的資料),依次類推,直到最後一個事務操作結束。

在事務操作的過程中,若遇到錯誤,比如事務一獲取資料存在跨域問題,那事務就會操作失敗,此時它會將自身的狀態標記為 rejected,由於後續事務都是承接前一事務的,前一事務已經宣告工程已經玩不成了,那麼後續的所有事務都會將自己標記為 rejected,其標記理由(reason)就是出錯事務的報錯資訊(這個報錯資訊可以使用 try…catch 來捕獲,也可以透過程式自身來捕獲,如 ajax 的 onerror 事件、ajax 返回的狀態碼為 404 等)。

小結:Promise 就是一個事務的管理器。他的作用就是將各種內嵌回撥的事務用流水形式表達,其目的是為了簡化程式設計,讓程式碼邏輯更加清晰。

由於整個程式的實現比較難理解,對於 Promise,我們將分為兩部分闡述:

  • 無錯誤傳遞的 Promise,也就是事務不會因為任何原因中斷,事務佇列中的事項都會被依次處理,此過程中 Promise 只有 pending 和 resolved 兩種狀態,沒有 rejected 狀態。

  • 包含錯誤的 Promise,每個事務的處理都必須使用容錯機制來獲取結果,一旦出錯,就會將錯誤資訊傳遞給下一個事務,如果錯誤資訊會影響下一個事務,則下一個事務也會 rejected,如果不會,下一個事務可以正常執行,依次類推。

首先,我們需要用一個變數(status)來標記事務的狀態,然後將事務(affair)也儲存到 Promise 物件中。

var Promise = function(affair){    this.state = “pending”;    this.affair = affair || function(o) { return o; };    this.allAffairs = [];
};

Promise 有兩個重要的方法,一個是 then,另一個是 resolve:

  • then,將事務新增到事務佇列(allAffairs)中

  • resolve,開啟流程,讓整個操作從第一個事務開始執行

在操作事務之前,我們會先把各種事務依次放入事務佇列中,這裡會用到 then 方法:

Promise.prototype.then = function (nextAffair){    var promise = new Promise();    if (this.state == ‘resloved’){        // 如果當前狀態是已完成,則這個事務將會被立即執行
        return this._fire(promise, nextAffair);
    }else{        // 否則將會被加入佇列中
        return this._push(promise, nextAffair);
    }
};

如果整個操作已經完成了,那 then 方法送進的事務會被立即執行,

Promise.prototype._fire = function (nextPromise, nextAffair){    var nextResult = nextAffair(this.result);    if (nextResult instanceof Promise){
        nextResult.then(function(obj){
            nextPromise.resolve(obj);
        });
    }else{
        nextPromise.resolve(nextResult);
    }    return nextPromise;
};
被立即執行之後會返回一個結果,這個結果會被傳遞到下一個事務中作為原料,但是這裡需要考慮兩種情況:
  1. 非同步,如果這個結果也是一個 Promise,則需要等待這個 Promise 執行完畢再將最終的結果傳到下一個事務中。

  2. 同步,如果這個結果不是 Promise,則直接將結果傳遞給下一個事務。

第一種情況還是比較常見的,比如我們在一個事務中有一個子事務佇列需要處理,此時必須等待子事務完成才能回到主事務佇列中。

Promise.prototype.resolve = function (obj){    if (this.state != ‘pending’) {        throw ‘流程已完成,不能再次開啟流程!’;
    }    this.state = ‘resloved’;    // 執行該事務,並將執行結果寄存到 Promise 管理器上
    this.result = this.affair(obj);    for (var i = 0, len = this.allAffairs.length; i 

resolve 接受一個引數,這個資料是交給第一個事務來處理的,因為第一個事務的啟動可能需要點原料,這個資料就是原料,它也可以是空。該事物處理完畢之後,將操作結果(result)寄存在 Promise 物件上,方便引用,然後將結果(result)作為原料送入下一個事務。依次類推。

我們看到 then 方法中還呼叫了一個 _push ,這個方法的作用是將事務推進事務管理器(Promise)。

Promise.prototype._push = function (nextPromise, nextAffair){    this.allAffairs.push({
        promise: nextPromise,
        affair: nextAffair
    });    return nextPromise;
};

以上操作,我們就實現了一個簡單的事務管理器,可以測試下下面的程式碼:

// 初始化事務管理器var promise = new Promise(function(data){    console.log(data);    return 1;
});// 新增事務promise.then(function(data){    console.log(data);    return 2;
}).then(function(data){    console.log(data);    return 3;
}).then(function(data){    console.log(data);    console.log(“end”);
});// 啟動事務promise.resolve(“start”);

可以看到依次輸出的結果為:

> start> 1> 2> 3> end

由於上述實現十分簡陋,鏈式呼叫沒做太好的處理,請讀者自行完善。

下面是一個非同步操作演示:

var promise = new Promise(function(data){    console.log(data);    return “end”;
});
promise.then(function(data){    // 這裡需要返回一個 Promise,讓主事務切換到子事務處理
    return (function(data){        // 建立一個子事務
        var promise = new Promise();
        setTimeout(function(){            console.log(data);            // 一秒之後才啟動子事務,模擬非同步延時
            promise.resolve();
        }, 1000);        return promise;
    })(data);
});
promise.resolve(“start”);
可以看到依次輸出的結果為:
> start> end (1s之後輸出)

將函式寫的稍微好看點:

function delay(data){    // 建立一個子事務
    var promise = new Promise();
    setTimeout(function(){        console.log(data);        // 一秒之後才啟動子事務,模擬非同步延時
        promise.resolve();
    }, 1000);    return promise;
}// 主事務var promise = new Promise(function(data){    console.log(data);    return “end”;
});
promise.then(delay);
promise.resolve(“start”);

三、包含錯誤傳遞的 Promise

真的很羨慕你能看到這麼詳細的文章,當然,後面會更加精彩!

沒有錯誤處理的 Promise 只能算是一個半成品,雖說可以透過在最外層加一個 try..catch 來捕獲錯誤,但沒法具體定位是哪個事務發生的錯誤。並且這裡的錯誤不僅僅包含 JavaScript Error,還有諸如 ajax 返回的 data code 不是 200 的情況等。

先看一個瀏覽器內建 Promise 的例項(該程式碼可在現代瀏覽器下執行):

new Promise(function(resolve, reject){
    resolve(“start”);
}).then(function(data){    console.log(data);    throw “error”;
}).catch(function(err){    console.log(err);    return “end”;
}).then(function(data){    console.log(data)
});
Promise 的回撥和 then 方法都是接受兩個引數:
new Promise(function(resolve, reject){    // …});

promise.then(    function(value){/* code here */}, 
    function(reason){/* code here */}
);

事務處理過程中,如果有值返回,則作為 value,傳入到 resolve 函式中,若有錯誤產生,則作為 reason 傳入到 reject 函式中處理。

在初始化 Promise 物件時,若傳入的回撥中沒有執行 resolve 或者 reject,這需要我們主動去啟動事務佇列。

promise.resolve();promise.reject();

上面兩種都是可以啟動一個佇列的。這裡跟第二章第二節的 resolve 函式用法類似。Promise 物件還提供了 catch 函式,起用法等價於下面所示:

promise.catch();// 等價於promise.then(null, function(reason){});

還有兩個 API:

promise.all();promise.race();

後續再講。先看看這個有錯誤處理的 Promise 是如何實現的。

function Promise(resolver){    this.status = “pending”;    this.value = null;    this.handlers = [];    this._doPromise.call(this, resolver);
}

_doPromise 方法在例項化 Promise 函式時就執行。如果送入的回撥函式 resolver 中已經 resolve 或者 reject 了,程式就已經啟動了,所以在例項化的時候就開始判斷。

_doPromise: function(resolver){    var called = false, self = this;    try{
        resolver(function(value){            // 如果沒有 call 則繼續,並標記 called 為 true
            !called && (called = !0, self.resolve(value));
        }, function(reason){            // 同上
            !called && (called = !0, self.reject(reason));
        });
    } catch(e) {        // 同上,捕獲錯誤,傳遞錯誤到下一個 then 事務
        !called && (called = !0, self.reject(e));
    }
},

只要 resolve 或者 reject 就會標記程式 called 為 true,表示程式已經啟動了。

resolve: function(value) {    try{        if(this === value){            throw new TypeError(‘流程已完成,不能再次開啟流程!’);
        } else {            // 如果還有子事務佇列,繼續執行
            value && value.then && this._doPromise(value.then);
        }        // 執行完了之後標記為完成
        this.status = “fulfilled”;        this.value = value;        this._dequeue();
    } catch(e) {        this.reject(e);
    }
},
reject: function(reason) {    // 標記狀態為出錯
    this.status = “rejected”;    this.value = reason;    this._dequeue();
},

可以看到,每次 resolve 的時候都會用一個 try..catch 包裹來捕獲未知錯誤。

_dequeue: function(){    var handler;    // 執行事務,直到佇列為空
    while (this.handlers.length) {
        handler = this.handlers.shift();        this._handle(handler.thenPromise, handler.onFulfilled, handler.onRejected);
    }
},

無論是 resolve 還是 reject 都會讓程式往後奔流,直到結束所有事務,所以這兩個方法中都有 _dequeue 函式。

_handle: function(thenPromise, onFulfilled, onRejected){    var self = this;

    setTimeout(function() {        // 判斷下次操作採用哪個函式,reject 還是 resolve
        var callback = self.status == “fulfilled” 
                       ? onFulfilled 
                       : onRejected;        // 只有是函式才會繼續回撥
        if (typeof callback === ‘function’) {            try {                self.resolve.call(thenPromise, callback(self.value));
            } catch(e) {                self.reject.call(thenPromise, e);
            }            return;
        }        // 否則就將 value 傳遞給下一個事務了
        self.status == “fulfilled”
                        ? self.resolve.call(thenPromise, self.value) 
                        : self.reject.call(thenPromise, self.value);
    }, 1);
},

這個函式跟上一節提到的 _fire 類似,如果 callback 是 function,就會進入子事務佇列,處理完了之後退回到主事務佇列。最後一個 then 方法,將事務推進佇列。

then: function(onFulfilled, onRejected){    var thenPromise = new Promise(function() {});    if (this.status == “pending”) {        this.handlers.push({
            thenPromise: thenPromise,
            onFulfilled: onFulfilled,
            onRejected: onRejected
        });
    } else {        this._handle(thenPromise, onFulfilled, onRejected);
    }    return thenPromise;
}

如果第二節沒有理解清楚,這一節也會讓人頭疼,這一部分講的比較粗糙。

或許你在面試的時候,有面試官問你:

$.ajax() 執行後返回的結果是什麼?

在 jQuery1.5 版本就已經引入了 Defferred 物件,當時為了引入這個東西,整個 jQuery 都被重構了。Defferred 跟 Promise 類似,它表示一個還未完成任務的物件,而 Promise 確切的說,是一個代表未知值的物件。

$.ajax({
    url: url
}).done(function(data, status, xhr){    //…}).fail(function(){    //…});

回憶下第二章第一節中的 Promise,是不是如出一轍,只是 jQuery 還提供了更多的語法糖:

$.ajax({
    url: url,
    success: function(data){        //…
    },
    error: funtion(){        //…
    }    
});

他允許將 done 和 fail 兩個函式的回撥放在 ajax 初始化的引數 success 和 fail 上,其原理還是一樣的,同樣,還有這樣的東西:

$.when(taskOne, taskTwo).done(function () {    console.log(“都執行完畢後才會輸出我!”);
}).fail(function(){    console.log(“只要有一個失敗,就會輸出我!”)
});
當 taskOne 和 taskTwo 都完成之後才執行 done 回撥,這個瀏覽器內建的 Promise 也有對應的函式:
Promise.all([true, Promise.resolve(1), …]).then(function(value){    //....});

瀏覽器內建的 Promise 還提供了一個 API:

Promise.race([true, Promise.resolve(1), …]).then(function(value){    //....}, function(reason){    //…});

只要 race 引數中有一個 resolve 或者 reject,then 回撥就會出發。

 寫的  就是基於事件響應的非同步模型,按理說,這個實現的邏輯是最清晰的,不過程式碼量稍微多一點。

function taskA(){
    setTimeout(function(){        var result = “A”;
        E.emit(“taskA”, result);
    }, 1000);
}function taskB(){
    setTimeout(function(){        var result = “B”;
        E.emit(“taskB”, result);
    }, 1000);
}

E.all([“taskA”, “taskB”], function(A, B){    return A + B;
});

我沒有看他的原始碼,但是想想,應該是這個邏輯。只需要在訊息中心管理各個 emit 以及訊息註冊。這裡的錯誤處理值得思考下。

在半年前,也寫過一篇關於非同步程式設計的文章:,感興趣的可以去讀一讀。

文章比較長,閱讀了好幾天別人寫的東西,自己提筆還是比較輕鬆的,本文大概花費了 6 個小時撰寫。

本文主要解說了 Promise 的應用場景和實現原理,如果你能夠順暢的讀完全文並且之處文中的一些錯誤,說明你已經悟到了:)

Promise 使用起來不難,但是理解其原理還是有點偏頭痛的,所以下面列舉的幾篇相關閱讀也建議讀者點進去看看。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/2035/viewspace-2801903/,如需轉載,請註明出處,否則將追究法律責任。

相關文章