[書籍翻譯] 《JavaScript併發編程》第三章 使用Promises實現同步

本文是我翻譯《JavaScript Concurrency》書籍的第三章 使用Promises實現同步,該書主要以Promises、Generator、Web workers等技術來講解JavaScript併發編程方面的實踐。

完整書籍翻譯地址:https://github.com/yzsunlei/javascript_concurrency_translation 。由於能力有限,肯定存在翻譯不清楚甚至翻譯錯誤的地方,歡迎朋友們提issue指出,感謝。

Promises幾年前就在JavaScript類庫中實現了。這一切都始於Promises/A+規範。這些類庫的實現都有它們自己的形式,直到最近(確切地說是ES6),Promises規範才被JavaScript語言納入。如標題那樣 - 它幫助我們實現同步原則。

在本章中,我們將首先簡單介紹Promises中各種術語,以便更容易理解本章的後面部分內容。然後,通過各種方式,我們將使用Promises來解決目前的一些問題,並讓併發處理更容易。準備好了嗎?

Promise相關術語

在我們深入研究代碼之前,讓我們花一點時間確保我們牢牢掌握Promises有關的術語。有Promise實例,但是還有各種狀態和方法。如果我們能夠弄清楚Promise這些術語,那麼後面的章節會更易理解。這些解釋簡短易懂,所以如果您已經使用過Promises,您可以快速看下這些術語,就當複習下。

Promise

顧名思義,Promise是一種承諾。將Promise視爲尚不存在的值的代理。Promise讓我們更好的編寫併發代碼,因爲我們知道值會在將來某個時刻存在,並且我們不必編寫大量的狀態檢查樣板代碼。

狀態(State)

Promises總是處於以下三種狀態之一:

• 等待:這是Promise創建後的第一個狀態。它一直處於等待狀態,直到它完成或被拒絕。

• 完成:該Promise值已經處理完成,並能爲它提供then()回調函數。

• 拒絕:處理Promise的值出了問題。現在沒有數據。

Promise狀態的一個有趣特性是它們只轉換一次。它們要麼從等待狀態到完成,要麼從等待狀態到被拒絕。一旦它們進行了這種狀態轉換,後面就會鎖定在這種狀態。

執行器(Executor)

執行器函數負責以某種方式解析值並將處於等待狀態。創建Promise後立即調用此函數。它需要兩個參數:resolver函數和rejector函數。

解析器(Resolver)

解析器是一個作爲參數傳遞給執行器函數的函數。實際上,這非常方便,因爲我們可以將解析器函數傳遞給另一個函數,依此類推。調用解析器函數的位置並不重要,但是當它被調用時,Promise會進入一個完成狀態。狀態的這種改變將觸發then()回調 - 這些我們將在後面看到。

拒絕器(Rejector)

拒絕器與解析器相似。它是傳遞給執行器函數的第二個參數,可以從任何地方調用。當它被調用時,Promise從等待狀態改變到拒絕狀態。這種狀態的改變將調用錯誤回調函數,如果有的話,會傳遞給then()或catch()。

Thenable

如果對象具有接受完成回調和拒絕回調作爲參數的then()方法,則該對象就是Thenable。換句話說,Promise是Thenable。但是在某些情況下,我們可能希望實現特定的解析語義。

完成和拒絕Promises

如果上一節剛剛介紹的幾個術語聽起來讓你困惑,那別擔心。從本節開始,我們將看到所有這些Promises術語的應用實踐。在這裏,我們將展示一些簡單的Promise解決和拒絕的示例。

完成Promises

解析器是一個函數,顧名思義,它完成了我們的Promise。這不是完成Promise的唯一方法 - 我們將在後面探索更高級的方式。但到目前爲止,這種方法是最常見的。它作爲第一個參數傳遞給執行器函數。這意味着執行器可以通過簡單地調用解析器直接完成Promise。但這並不怎麼實用,不是嗎?

更常見的情況是Promise執行器函數設置即將發生的異步操作 - 例如撥打網絡電話。然後,在這些異步操作的回調函數中,我們可以完成這個Promise。在我們的代碼中傳遞一個解析函數,剛開始可能感覺有點違反直覺,但是一旦我們開始使用它們就會發現很有意義。

解析器函數是一個相對Promise來說比較難懂的函數。它只能完成一次Promise。我們可以調用解析器很多次,但只在第一次調用會改變Promise的狀態。下面是一個圖描述了Promise的可能狀態;它還顯示了狀態之間是如何變化的:

image060.gif

現在,我們來看一些Promise代碼。在這裏,我們將完成一個promise,它會調用then()完成回調函數:

//我們的Promise使用的執行器函數。
//第一個參數是解析器函數,在1秒後調用完成Promise。
function executor(resolve) {
    setTimeout(resolve, 1000);
}

//我們Promise的完成回調函數。
//這個簡單地在我們的執行程序函數運行後,停止那個定時器。
function fulfilled() {
    console.timeEnd('fulfillment');
}

//創建promise,並立即運行,
//然後啓動一個定時器來查看調用完成函數需要多長時間。
var promise = new Promise(executor);
promise.then(fulfilled);
console.time('fulfillment');

我們可以看到,解析器函數被調用時fulfilled()函數會被調用。執行器實際上並不調用解析器。相反,它將解析器函數傳遞給另一個異步函數 - setTimeout()。執行器並不是我們試圖去弄清楚的異步代碼。可以將執行器視爲一種協調程序,它編排異步操作並確定何時執行Promise。

前面的示例未解析任何值。當某個操作的調用者需要確認它成功或失敗時,這是一個有效的用例。相反,讓我們這次嘗試解析一個值,如下所示:

//我們的Promise使用的執行函數。
//創建Promise後,設置延時一秒鐘調用"resolve()",
//並解析返回一個字符串值 - "done!"。
function executor(resolve) {
    setTimeout(() => {
        resolve('done!');
    }, 1000);
}

//我們Promise的完成回調接受一個值參數。
//這個值將傳遞到解析器。
function fulfilled(value) {
    console.log('resolved', value);
}

//創建我們的Promise,提供執行程序和完成回調函數。
var promise = new Promise(executor);
promise.then(fulfilled);

我們可以看到這段代碼與前面的例子非常相似。區別在於我們的解析器函數實際上是在傳遞給setTimeout()的回調函數的閉包內調用的。這是因爲我們正在解析一個字符串值。還有一個將被解析的參數值傳遞給我們的fulfilled()函數。

拒絕promises

Promise執行器函數並不總是按期望進行,當出現問題時,我們需要拒絕promise。這是從等待狀態轉換到另一個可能的狀態。這不是進入一個完成狀態而是進入一個被拒絕的狀態。這會導致執行不同的回調,與完成回調函數是分開的。值得慶幸的是,拒絕Promise的機制與完成Promise非常相似。我們來看看這是如何實現的:

//此執行器在延時一秒後拒絕Promise。
//它使用拒絕回調函數來改變狀態,
//並傳遞拒絕的參數值到回調函數。
function executor(resolve, reject) {
    setTimeout(() => {
        reject('Failed');
    }, 1000);
}

//用作拒絕回調的函數。
//它接收提供拒絕的參數值。
function rejected(reason) {
    console.error(reason);
}

//創建promise,並運行執行器。
//使用“catch()”方法來接收拒絕回調函數。
var promise = new Promise(executor);
promise.catch(rejected);

這段代碼看起來和在上一節中看到的代碼非常相似。我們設置了超時,並且我們拒絕了它而不是完成它。這是使用rejector函數完成的,並作爲第二個參數傳遞給執行器。

我們使用catch()方法而不是then()方法來設置拒絕回調函數。我們將在本章後面看到then()方法如何用於同時處理完成和拒絕回調函數。此示例中的拒絕回調函數僅將失敗原因打印出來。通常情況下提供此返回值很重要。當我們完成promise時,返回值也是常見的,儘管不是必需的。另一方面,對於拒絕函數,一般也很少有情況僅僅通過回調函數輸出拒絕原因。

讓我們看下另一個例子,它捕獲執行器中拋出的異常,併爲拒絕回調函數提供更有意義的報錯原因:

//此promise執行程序拋出錯誤,
//並調用拒絕回調函數輸出錯誤信息。
new Promise(() => {
    throw new Error('Problem executing promise');
}).catch((reason) => {
    console.error(reason);
});

//此promise執行程序捕獲錯誤,
//並調用拒絕回調函數輸出更有意義的錯誤信息。
new Promise((resolve, reject) => {
    try {
        var size = this.name.length;
    } catch (error) {
        reject(error instanceof TypeError ? 'Missing "name" property' : error);
    }
}).catch((reason) => {
    console.error(reason);
});

前一個例子中第一個Promise的有趣之處在於它確實改變了狀態,即使我們沒有使用resolve()或reject()明確地改變promise的狀態。然而,最終改變promise的狀態是很重要的; 我們將在下一節中探討這個話題。

空Promises

儘管事實上執行器函數傳遞了一個完成回調函數和拒絕回調函數,但並不保證promise將改變狀態。有些情況下,promise只是掛起,並沒有觸發完成回調也沒有觸發拒絕回調。這可能並沒有什麼問題,事實上,簡單的promises,就很容易發現和修復沒有響應的promises。然而,隨着我們進入更復雜的場景後,一個promise的完成回調可以作爲其他幾個promise的回調結果。如果一個promises不能完成或拒絕,然後整個流程將崩潰。這種情況調試起來是非常麻煩的;下面的圖可以很清楚的看到這個情況:

image061.gif

在圖中,我們可以看到哪個promise導致依賴的promise掛起,但通過調試代碼來解決這個問題並不容易。現在讓我們看看導致promise掛起的執行函數:

//這個promise能夠正常運行執行器函數。
//但“then()”回調函數永遠不會被執行。
new Promise(() => {
    console.log('executing promise');
}).then(() => {
    console.log('never called');
});

//此時,我們並不知道promise出了什麼問題
console.log('finished executing, promise hangs');

但是,是否有一種更安全的方式來處理這種不確定性呢?在我們的代碼中,我們不需要掛起無需完成或拒絕的執行函數。讓我們來實現一個執行器包裝函數,像一個安全網那樣讓過長時間還沒完成的promises執行拒絕回調函數。這將揭開解決不好處理的promise場景的神祕面紗:

//promise執行器函數的包裝器,
//在給定的超時時間後拋出錯誤。
function executorWrapper(func, timeout) {
    //這是實際調用的函數。
    //它需要解析器函數和拒絕器函數作爲參數。
    return function executor(resolve, reject) {
        //設置我們的計時器。
        //當時間到達時,我們可以使用超時消息拒絕promise。
        var timer = setTimeout(() => {
            reject('Promise timed out after $​​ {timeout} MS');
        }, timeout);
        
        //調用我們原來的執行器包裝函數。
        //我們實際上也包裝了完成回調函數
        //和拒絕回調函數,所以當
        //執行者調用它們時,會清除定時器。
        func((value) => {
            clearTimeout(timer);
            resolve(value);
        }, (value) => {
            clearTimeout(timer);
            reject(value);
        });
    };
}

//這個promise執行後超時,
//超時錯誤消息傳遞給拒絕回調。
new Promise(executorWrapper((resolve, reject) => {
    setTimeout(() => {
        resolve('done');
    }, 2000);
}, 1000)).catch((reason) => {
    console.error(reason);
});

//這個promise執行後按預期運行,
//在定時結束之前調用“resolve()”。
new Promise(executorWrapper((resolve, reject) => {
    setTimeout(() => {
        resolve(true);
    }, 500);
}, 1000)).then((value) => {
    console.log('resolved', value);
});

對promises作出改進

既然我們已經很好地理解了promises的執行機制,本節將詳細介紹如何使用promises來解決特定問題。通常,這意味着當promises完成或被拒絕時,我們會達到我們某些目的。

我們將首先查看JavaScript解釋器中的任務隊列,以及這些對我們的解析回調函數的意義。然後,我們將考慮使用promise的結果數據,處理錯誤,創建更好的抽象來響應promises,以及thenables。讓我們開始吧。

處理任務隊列

JavaScript任務隊列的概念在“第2章,JavaScript運行模型”中提到過。它的主要職責是初始化新的執行上下文堆棧。這是常見的任務隊列。然而,還有另一種隊列,這是專用於執行promises回調的。這意味着,如果他們都存在時,算法會從這些隊列中選擇一個任務執行。

Promises具有內置的併發語義,而且有充分的理由。如果一個promise被用來確保某個值最終被解析,那麼爲對其作出響應的代碼賦予高優先級是有意義的。否則,當值到達時,處理它的代碼可能還要在其他任務後面等待很長的時間才能執行。讓我們編寫一些代碼來演示下這些併發語義:

//創建5個promise,記錄它們的執行時間,
//以及當他們對返回值做出響應的時間。
for (let i = 0; i < 5; i++) {
    new Promise((resolve) => {
        console.log('execting promise');
        resolve(i);
    }).then((value) => {
        console.log('resolved', i);
    });
}

//在任何promise完成回調之前,這裏會先被調用,
//因爲堆棧任務需要在解釋器進入promise解析回調隊列之前完成,
//當前5個“then()”回調將被置後。
console.log('done executing');

//→
//execting promise
//execting promise
// ...
//done executing
//resolved 1
//resolved 2
// ...
拒絕回調也遵循同樣的語義。

使用promise的返回數據

到目前爲止,我們已經在本章中看到了一些示例,其中解析器函數完成promise後並返回值。傳遞給此函數的值是最終傳遞給完成回調函數的值。通過讓執行程序設置任何異步操作的方法,例如setTimeout(),延時傳遞該值調用解析程序。但在這些例子中,調用者實際上並沒有等待任何值;我們只使用setTimeout()作爲示例異步操作。讓我們看一下我們實際上沒有值的情況,異步網絡請求需要獲取到它:

//用於從服務器獲取資源的通用函數,
//返回一個promise。
function get(path) {
    return new Promise((resolve, reject) => {
        var request = new XMLHttpRequest();
        
        //promise解析數據加載後的JSON數據。
        request.addEventListener('load', (e) => {
            resolve(JSON.parse(e.target.responseText));
        });

        //當請求出錯時,promise執行拒絕回調函數。
        request.addEventListener('error', (e) => {
            reject(e.target.statusText || '未知錯誤');
        });


        //如果請求被中止時,我們調用完成回調函數
        request.addEventListener('abort', resolve);
        
        request.open('get', path);
        request.send();
    });
}

//我們可以直接附加我們的“then()”處理程序
//到“get()”,因爲它返回一個promise。
//在解析之前,這裏使用的值是一個真正的異步操作,
//因爲必須發請求遠程獲取值。
get('api.json').then((value) => {
    console.log('hello', value.hello);
});

使用像get()這樣的函數,它們不僅始終返回像promise一樣的原生類型,而且還封裝了一些讓人討厭的異步細節。在我們的代碼中處理XMLHttpRequest對象並不令人愉快。我們已經簡化了可以返回的各種情況。而不是總是必須爲load,error和abort事件創建處理程序,我們只需要關心一個接口 - promise。這就是同步併發原則的全部內容。

錯誤回調

有兩種方法可以對被拒絕的promise做出處理。換句話說,提供錯誤回調。第一種方法是使用catch()方法,該方法使用單一回調函數。另一種方法是將被拒絕的回調函數作爲then()的第二個參數傳遞。

將then()方法用來處理拒絕回調函數在某些情況下表現的更好,它應該被用來替代catch()函數。第一個場景是編寫promises和thenable對象可以互換的代碼。catch()方法不是thenable必要的一部分。第二個場景是當我們建立回調鏈時,我們將在本章後面探討。

讓我們看一些代碼,它們比較了兩種爲promises提供拒絕回調函數的方法:

//這個promise執行器將隨機執行完成回調或拒絕回調
function executor(resolve, reject) {
    cnt++;
    Math.round(Math.random()) ? 
        resolve(`fulfilled promise ${cnt}`) :
        reject(`rejected promise ${cnt}`);
}

//讓“log()”和“error()”函數作爲簡單回調函數
var log = console.log.bind(console),
    error = console.error.bind(console),
    cnt = 0;

//創建一個promise,然後通過“catch()”方法傳入拒絕回調。
new Promise(executor).then(log).catch(error);

//創建一個promise,然後通過“then()”方法傳入拒絕回調。
new Promise(executor).then(log, error);

我們可以看到這兩種方法實際上非常相似。在代碼美觀上,也沒有哪個有真正的優勢。然而,當涉及到使用thenables時,then()方法有一個優勢,我們後面會看到。但是,由於我們實際上並沒有以任何方式使用promise實例,除了添加回調之外,實際上沒有必要擔心catch()和then()用於註冊拒絕回調。

始終響應

Promises最終總是結束於完成狀態或拒絕狀態。我們通常爲每個狀態傳入不同的回調函數。但是,我們很可能希望爲這兩個狀態執行一些相同的操作。例如,如果使用promise的組件在promise等待時更改狀態,我們要確保在完成或拒絕promise後清除狀態。

我們可以用這樣的方式編寫代碼:完成和拒絕狀態的每個回調都去執行這些操作,或者他們每個都可以調用執行一些公用的清理函數。下面這種方式的示圖:

image065.gif

將清理任務分配給promise是否有意義,而不是將其分配給其它個別結果?這樣,在解析promise時運行的回調函數專注於它需要對值執行的操作,而拒絕回調則專注於處理錯誤。讓我們看看是否可以使用always()方法編寫一些擴展promises的代碼:

//在promise原型上擴展使用“always()”方法。
//不管promise是完成還是拒絕,始終會調用給定的函數。
Promise.prototype.always = function(func) {
    return this.then(func, func);
};

//創建promise隨機完成或被拒絕。
var promise = new Promise((resolve, reject) => {
    Math.round(Math.random()) ? 
    resolve('fullfilled') : reject('rejected');
});

//傳遞promise完成和拒絕回調。
promise.then((value) => {
    console.log(value);
}, (reason) => {
    console.error(reason);
});

//這個回調函數總是會在上面的回調執行之後調用。
promise.always((value) => {
    console.log('cleaning up...');
});
請注意,在這裏順序很重要。如果我們在then()之前調用always(),那麼函數仍然會運行,但它會在
回調提供給then()之前運行。我們實際上可以在then()之前和之後都調用always(),以便在完成或拒絕回調
之前以及之後運行代碼。

處理其他promises

到目前爲止,我們在本章中看到的大多數promise都是由執行程序函數直接完成的,或者是當值準備完成時從異步操作中調用解析器的結果。像這樣傳遞迴調函數實際上非常靈活。例如,執行程序甚至不必執行任何任務,除了將解析器函數存儲在某處以便稍後調用它來解析promise。

當我們發現自己處於需要多個值的更復雜的同步場景時,這可能特別有用,這些值已經被傳遞給調用者。如果我們有處理回調函數,我們就可以處理promise。讓我們看看,在存儲代碼的解析函數的多個promises,使每一個promise都可以在後面處理:

//存儲一系列解析器函數的列表。
var resolvers = [];

//在執行器中創建5個新的promise,
//解析器被推到了“resolvers”數組。
//我們可以給每一個promise執行回調。
for(let i = 0; i < 5; i++) {
    new Promise(() => {
        resolvers.push(resolve);
    }).then((value) => {
        console.log(`resolved ${i + 1}`, value);
    });
}

//設置一個2s之後延時運行函數,
//當它運行時,我們遍歷“解析器”數組中的每一個解析器函數,
//並且傳入一個返回值來調用它。
setTimeout(() => {
    for(resolver of resolvers) {
        resolver(true);
    }
}, 2000);

正如這個例子所表明的那樣,我們不必在executor函數內處理它們。事實上,我們甚至不需要在創建和設置執行程序和完成函數之後顯式引用promise實例。解析器函數已存儲在某處,它包含對promise的引用。

類Promise對象

Promise類是一種原生的JavaScript類型。但是,我們並不總是需要創建新的promise實例來實現相同的同步操作。我們可以使用靜態Promise.resolve()方法來解析這些對象。讓我們看看如何使用此方法:

//“Promise.resolve()”方法可以處理thenable對象。
//這是一個帶有“then()”方法的類似於執行器的對象。
//這個執行器將隨機完成或拒絕promise。
Promise.resolve({then: (resolve, reject) => {
    Math.round(Math.random()) ? resolve('fulfilled') : reject('rejected');

    //這個方法返回一個promise,所以我們能夠
    //設置已完成和被拒絕的回調函數。
}}).then((value) => {
    console.log('resolved', value);
}, (reason) => {
    console.error('reason', reason);
});

我們將在本章的最後一節中再次討論Promise.resolve()方法,以瞭解更多用例。

建立回調鏈

我們在本章前面介紹的每種promise方法都會返回promise。這允許我們在返回值上再次調用這些方法,從而產生then().then()調用的鏈,依此類推。鏈式promise具有挑戰性的一個方面是promise方法返回的是新實例。也就是說,我們將在本節中探討promise在一定程度上的不變性。

隨着我們的應用程序變得越來越大,併發性挑戰隨之增加。這意味着我們需要考慮更好的方法來利用原生同步語義,例如promises。正如JavaScript中的任何其他原始值一樣,我們可以將它們從函數傳遞給函數。我們必須以同樣的方式處理promises - 傳遞它們,並建立在回調函數鏈上。

Promises只改變狀態一次

Promise初始時是等待狀態,並且它們結束於已完成或被拒絕的狀態。一旦promise轉變爲其中一種狀態,它們就會鎖定在這種狀態。這有兩個有趣的副作用。

首先,多次嘗試完成或拒絕promise將被忽略。換句話說,解析器和拒絕器是冪等的 - 只有第一次調用對promise有影響。讓我們看看這代碼如何執行:

//此執行器函數嘗試解析promise兩次,
//但完成的回調只調用一次。
new Promise((resolve, reject) => {
    resolve('fulfilled');
    resolve('fulfilled');
}).then((value) => {
    console.log('then', value);
});

//這個執行器函數嘗試拒絕promise兩次,
//但拒絕的回調只調用一次。
new Promise((resolve, reject) => {
    reject('rejected');
    reject('rejected');
}).catch((reason) => {
    console.error('reason');
});

promises僅改變狀態一次的另一個含義是promise可以在添加完成或拒絕回調之前處理。競爭條件,例如這個,是併發編程的殘酷現實。通常,回調函數會在創建時添加到promise中。由於JavaScript是運行到完成的,因此在添加回調之前,不會處理promise解析回調的任務隊列。但是,如果promise立即在執行中解析怎麼辦?如果將回調添加到另一個JavaScript執行上下文的promise中會怎樣?讓我們看看是否可以用一些代碼來更好地說明這些情況:

//此執行器函數立即解析promise。添加“then()”回調時,
//promise已經解析了。但回調函數仍然會使用已解析的值進行調用。
new Promise((resolve, reject) => {
    resolve('done');
    console.log('executor', 'resolved');
}).then((value) => {
    console.log('then', value);
});

//創建一個立即解析的新promise執行器函數。
var promise = new Promise((resolve, reject) => {
    resolve('done');
    console.log('executor', 'resolved');
});

//這個回調是promise解析後就立即執行了。
promise.then((value) => {
    console.log('then 1', value);
});

//此回調在promise解析後未添加到另一個的promise中,
//它仍然被立即調用並獲得已解析的值。
setTimeout(() => {
    promise.then((value) => {
        console.log('then 2', value);
    });
}, 1000);

此代碼說明了promises的一個非常重要的特性。無論何時將執行回調添加到promise中,無論是處於暫時掛起狀態還是解析狀態,使用promise的代碼都不會更改。從表面上看,這似乎不是什麼大不了的事。但是這種競爭條件檢查的類型需要更多的併發代碼來保護自己。相反,Promise原生語法爲我們處理這個問題,我們可以開始將異步值視爲原始類型。

不可改變的promises

promises並非真正不可改變。它們改變狀態,then()方法將回調函數添加到promise。但是,有一些不可改變的promises特徵值得在這裏討論,因爲它們會在某些情況下影響我們的promise代碼。

從技術上講,then()方法實際上並沒有改變promise對象。它創建了所謂的promise能力,它是一個引用promise的內部JavaScript記錄,以及我們添加的函數。因此,它不是JavaScript語言中的真正語法。

這是一張圖,說明當我們鏈接兩個或更多then()一起調用時會發生什麼:

image069.gif

我們可以看到,then()方法不會返回與上下文一起調用的相同實例。相反,then()創建一個新的promise實例並返回它。讓我們看一些代碼,來進一步的說明當我們使用then()將promises鏈接在一起時會發生的事情:

//創建一個立即解析的promise,
//並且存儲在“promise1”中。
var promise1 = new Promise((resolve, reject) => {
    resolve('fulfilled');
});

//使用“promise1”的“then()”方法創建一個
//新的promise實例,存儲在“promise2”中。
var promise2 = promise1.then((value) => {
    console.log('then 1', value);
    //→then 1 fulfilled
});

//爲“promise2”創建一個“then()”回調。這實際上
//創建第三個promise實例,但我們不用它做任何事情。
promise2.then((value) => {
    console.log('then 2', value);
    //→then 2 undefined
});

//確信“promise1”和“promise2”實際上是不同的對象
console.log('equal', promise1 === promise2);
//→equal false

我們可以清楚地看到這兩個創建promise的實例在這個例子中是獨立的promise對象。值得指出的是第二個promise執行前時,一定是它執行了第一個promise。但是,我們可以看到的是該值不會傳遞到第二個promise。我們將在下一節中解決此問題。

有多少個then()回調,就有多少個promise對象

正如我們在上一節中看到的那樣,使用then()創建的promise將綁定到它們的創建者。也就是說,當第一個promise完成時,綁定它的promise也會完成,依此類推。但是,我們也發現了一個小問題。已解析的值不會使其傳遞到第一個回調函數。這樣做的原因是爲響應promise解析而運行的每個回調都是第一個回調的返回值被送入第二個回調,依此類推。我們的第一個回調將值作爲參數的原因是因爲這在promise機制中顯然會發生的。

我們來看看另一個promise鏈示例。這一次,我們將顯式返回回調函數中的值:

//創建一個新promise隨機調用解析回調或拒絕回調。
new Promise((resolve, reject) => {
    Math.round(Math.random()) ?
    resolve('fulfilled') : reject('rejected');
}).then((value) => {
    //在完成原始promise時調用返回值,
    //以防另一個promise鏈接到這一個。
    console.log('then 1', value); 
    return value;
}).catch((reason) => {
    //鏈接到第二個promise,
    //當拒絕回調時執行。
    console.error('catch 1', reason);
}).then((value) => {
    //鏈接到第三個promise,
    //按預期得到值,並返回值給任何下個promise回調使用。
    console.log('then 2', value);
    return value;
}).catch((reason) => {
    //這裏永不會被調用,
    //拒絕回調不會通過promise鏈傳遞。
    console.error('catch 2', reason);
});

這看起來不錯。我們可以看到已解析的值通過promise鏈傳遞。有一個異常 - 拒絕回調不會向後傳遞。相反,只有鏈中的第一個promise拒絕回調會執行。其餘的promise回調只是完成,而不是拒絕。這意味着最後一個catch()回調永遠不會運行。

當我們以這種方式將promise鏈接在一起時,我們的執行回調函數需要能夠處理錯誤條件。例如,已解析的值可能具有error屬性,可以檢查其具體問題。

promises傳遞

在本節中,我們講講promise作爲原始值的用法。我們經常用原始值做的事情是將它們作爲參數傳遞給函數,並從函數中返回它們。promise和其他原生語法之間的關鍵區別在於我們如何使用它們。其他值是始終都存在,而promise的值到未來某個時間點才存在。因此,我們需要通過回調函數定義一些操作過程,當值獲得時去執行。

promises的好處是用於提供這些回調函數的接口小巧且一致。當我們將值與將作用於它的代碼耦合時,我們不需要再去自主創造同步機制。這些單元可以像任何其他值一樣在我們的應用程序中運用,並且併發語義是常見的。這是幾個promise函數相互傳遞的示圖:

image071.gif

在這個函數堆棧調用結束時,我們得到一個完成幾個promise的解析的promise對象。整個promise鏈是從第一個promise完成而開始的。比如何遍歷promise鏈的機制​​更重要的是所有這些函數都可以自由使用這個promise傳遞的值而不影響其他函數。

在這裏有兩個併發原則。首先,我們通過執行異步操作僅只能處理該值一次; 每個回調函數都可以自由使用此解析值。其次,我們在抽象同步機制方面做得很好。換句話說,代碼並沒有帶有很多重複代碼。讓我們看看傳遞promise的代碼實際的樣子:

//簡單實用的工具函數,
//將多個較小的函數組合成一個函數。
function compose(...funcs) {
    return function(value) {
        var result = value;
        
        for(let func of funcs) {
            result = func(value);
        }
        return result;
    };
}

//接受一個promise或一個完成值。
//如果這是一個promise,它添加了一個“then()”回調並返回一個新的promise。
//否則,它會執行“update”並返回值。
function updateFirstName(value) {
    if (value instanceof Promise) {
        return value.then(updateFirstName);
    }

    console.log('first name', value.first); 
    return value;
}

//與上面的函數類似,
//只是它執行不同的UI“update”。
function updateLastName(value) {
    if (value instanceof Promise) {
        return value.then(updateLastName);
    } 

    console.log('last name', value.last); 
    return value;
}

//與上面的函數類似,除了它
//只是它執行不同的UI“update”。
function updateAge(value) {
    if (value instanceof Promise) {
        return value.then(updateAge);
    }

    console.log('age', value.age);
    return value;
}

//一個promise對象,
//它在延時一秒鐘之後,
//攜帶一個數據對象完成promise。
var promise = new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve({
            first: 'John',
            last: 'Smith',
            age: 37
        });
    });
}, 1000);

//我們組裝一個“update()”函數來更新各種UI組件。
var update = compose(
    updateFirstName,
    updateLastName,
    updateAge
);

//使用promise調用我們的更新函數。
update(promise);

這裏的關鍵函數是我們的更新函數 - updateFirstName(),updateLastName()和updateAge()。他們非常靈活,接受一個promise或promise返回值。如果這些函數中的任何一個將promise作爲參數,它們會通過添加then()回調函數來返回新的promise。請注意,它添加了相同的函數。updateFirstName()將添加updateFirstName()作爲回調。當回調觸發時,它將與此次用於更新UI的普通對象一起使用。因此,promise如果失敗,我們可以繼續更新UI。

promise檢查每個函數都需要三行,這並不是非常突兀的。最終結果是易讀且靈活的代碼。順序無關緊要; 我們可以用不同的順序包裝我們的update()函數,並且UI組件都將以相同的方式更新。我們可以將普通對象直接傳遞給update(),一切都會同樣執行。看起來不像併發代碼的併發代碼是我們在這裏取得的重大成功。

同步多個promises

在本章前面,我們已經探究了單個promise實例,它解析一個值,觸發回調,並可能傳遞給其他promises處理。在本節中,我們將介紹幾種靜態Promise方法,它們可以幫助我們處理需要同步多個promise值的情況。

首先,我們將處理我們開發的組件需要同步訪問多個異步資源的情況。然後,我們將看一下不常見的情況,如異步操作在處理之前由於UI中發生的事件而變得沒有意義。

等待promises

在我們等待處理多個promise的情況下,也許是將多個數據源轉換後提供給一個UI組件使用,我們可以使用Promise.all()方法。它將promise實例的集合作爲輸入,並返回一個新的promise實例。僅當完成了所有輸入的promise時,纔會返回一個新實例。

then()函數是我們爲Promise提供的創建新promise的回調。給出一組解析值作爲輸入。這些值對應於索引輸入promise的位置。這是一個非常強大的同步機制,它可以幫助我們實現同步併發原則,因爲它隱藏了所有的處理記錄。

我們不需要幾個回調,讓每個回調都協調它們所綁定的promise狀態,我們只需一個回調,它具有我們需要的所有解析數據。這個示例展示如何同步多個promise:

//用於發送“GET”HTTP請求的工具函數,
//並返回帶有已解析的數據的promise。
function get(path) {
    return new Promise((resolve, reject) => {
        var request = new XMLHttpRequest();
        
        //當數據加載時,完成解析了JSON數據的promise
        request.addEventListener('load', (e) => {
            resolve(JSON.parse(e.target.responseText));
        });

        //當請求出錯時,
        //promise被適當的原因拒絕。
        request.addEventListener('error', (e) => {
            reject(e.target.statusText || 'unknown error');
        });

        //如果請求被中止,我們繼續完成處理請求 
        request.addEventListener('abort', resolve);
        
        request.open('get', path);
        request.send();
    });
}


//保存我們的請求promises。
var requests = [];

//發出5個API請求,並將相應的5個
//promise放在“requests”數組中。
for (let i = 0; i < 5; i++) {
    requests.push(get('api.json'));
}

//使用“Promise.all()”讓我們傳入一個數組promises,
//當所有promise完成時,返回一個已經完成的新promise。
//我們的回調得到一個數組對應於promises的已解析值。
Promise.all(requests).then((values) => {
    console.log('first', values.map(x => x[0])); 
    console.log('second', values.map(x => x[1]));
});

取消promises

到目前爲止,我們在本書中已看到的XHR請求具有中止請求的處理程序。這是因爲我們可以手動中止請求並阻止任何load回調函數運行。需要此功能的典型場景是用戶單擊取消按鈕,或導航到應用程序的其他部分,從而使請求變得毫無意義。

如果我們是要在抽象promise上更上一層樓,在同樣的原則也適用。而一些可能發生的併發操作的執行讓promise變得毫無意義。promises和XHR請求的過程中之間的區別,是前者沒有abort()方法。最後我們要做的一件事是在我們的promise回調中開始引入可能並不必要的取消邏輯。

Promise.race()方法在這裏可以幫助我們。顧名思義,該方法返回一個新的promise,它由第一個要解析的輸入promise決定。這可能你聽的不多,但實現Promise.race()的邏輯並不容易。它實際上是同步原則,隱藏了應用程序代碼中的併發複雜性。我們來看看這個方法是怎麼可以幫助我們處理因用戶交互而取消的promise:

//用於取消數據請求的解析器​​函數。
var cancelResolver;

//一個簡單的“常量”值,用於處理取消promise
var CANCELED = {};

//我們的UI組件
var buttonLoad = document.querySelector('button.load'),
    buttonCancel = document.querySelector('button.cancel');

//請求數據,返回一個promise。
function getDataPromise() {
    //創建取消promise。
    //執行器傳入“resolve”函數爲“cancelResolver”,
    //所以它稍後可以被調用。
    var cancelPromise = new Promise((resolve) => {
        cancelResolver = resolve;
    });

    //我們實際想要的數據
    //這通常是一個HTTP請求,
    //但我們在這裏使用setTimeout()簡單模擬一下。
    var dataPromise = new Promise((resolve) => {
        setTimeout(() => {
            resolve({hello: 'world'});
        }, 3000);
    });

    //“Promise.race()”方法返回一個新的promise,
    //並且無論輸入promise是什麼,它都可以完成處理
    return Promise.race([cancelPromise, dataPromise]);
}

//單擊取消按鈕時,我們使用
//“cancelResolver()”函數來處理取消promise
buttonCancel.addEventListener('click', () => {
    cancelResolver(CANCELLED);
});

//單擊加載按鈕時,我們使用
//“getDataPromise()”發出請求獲取數據。
buttonLoad.addEventListener('click', () => {
    buttonLoad.disabled = true;
    getDataPromise().then((value) => {
        buttonLoad.disabled = false;
        //promise得到了執行,但那是因爲
        //用戶取消了請求。所以我們這裏
        //通過返回CANCELED “constant”退出。
        //否則,我們有數據可以使用。
        if (Object.is(value, CANCELED)) {
            return value;
        }
        
        console.log('loaded data', value);
    });
});
作爲練習,嘗試想象一個更復雜的場景,其中dataPromise是由Promise.all()創建的promise。我們的
cancelResolver()函數可以一次取消許多複雜的異步操作。

沒有執行器的promises

在最後一節中,我們將介紹Promise.resolve()和Promise.reject()方法。我們已經在本章前面看到Promise.resolve()如何處理thenable對象。它還可以直接處理值或其他promises。當我們實現一個可能同步也可能異步的函數時,這些方法會派上用場。這不是我們想要使用具有模糊併發語義函數的情況。

例如,這是一個可能同步也可能異步的函數,讓人感到迷惑,幾乎肯定會在以後出現錯誤:

//一個示例函數,它可能從緩存中返回“value”,
//也可能通過“fetchs”異步獲取值。
function getData(value) {
    //如果它存在於緩存中,我們直接返回這個值
    var index = getData.cache.indexOf(value);
    if(index > -1) {
        return getData.cache[index];
    }

    //否則,我們必須通過“fetch”異步獲取它。
    //這個“resolve()”調用通常是會在網絡發起請求的回調函數
    return new Promise((resolve) => {
        getData.cache.push(value);
        resolve(value);
    });
}

//創建緩存。
getData.cache = [];

console.log('getting foo', getData('foo'));
//→getting foo Promise
console.log('getting bar', getData('bar'));
//→getting bar Promise
console.log('getting foo', getData('foo'));
//→getting foo foo

我們可以看到最後一次調用返回的是緩存值,而不是一個promise。這很直觀,因爲我們不需要通過promise獲取最終的值,我們已經擁有這個值!問題是我們讓使用getData()函數的任何代碼表現出不一致性。也就是說,調用getData()的代碼需要處理併發語義。此代碼不是併發的。讓我們通過引入Promise.resolve()來改變它:

//一個示例函數,它可能從緩存中返回“value”,
//也可能通過“fetchs”異步獲取值。
function getData(value) {
    var cache = getData.cache;
    //如果這個函數沒有緩存,
    //那就拒絕promise。
    if(!Array.isArray(cache)) {
        return Promise.reject('missing cache');
    }

    //如果它存在於緩存中,
    //我們直接使用緩存的值返回完成的promise
    var index = getData.cache.indexOf(value);
    
    if (index > -1) {
        return Promise.resolve(getData.cache[index]);
    }

    //否則,我們必須通過“fetch”異步獲取它。
    //這個“resolve()”調用通常是會在網絡發起請求的回調函數
    return new Promise((resolve) => {
        getData.cache.push(value);
        resolve(value);
    });
}

//創建緩存。
getData.cache = [];

//每次調用“getData()”返回都是一致的。
//甚至當使用同步值時,
//它們仍然返回得到解析完成的promise。
getData('foo').then((value) => {
    console.log('getting foo', `“${value}”`);
}, (reason) => {
    console.error(reason);
});

getData('bar').then((value) => {
    console.log('getting bar', `“${value}”`);
}, (reason) => {
    console.error(reason);
});

getData('foo').then((value) => {
    console.log('getting foo', `“${value}”`);
}, (reason) => {
    console.error(reason);
});

這樣更好。使用Promise.resolve()和Promise.reject(),任何使用getData()的代碼默認都是併發的,即使數據獲取操作是同步的。

小結

本章介紹了ES6中引入的Promise對象的大量細節內容,以幫助JavaScript程序員處理困擾該語言多年的同步問題。大量的使用異步回調,這會產生回調地獄,因而我們要儘量避免它。

Promise通過實現一個足以解決任何值的通用接口來幫助我們處理同步問題。promise總是處於三種狀態之一 - 等待,完成或拒絕,並且它們只會改變一次狀態。當這些狀態發生改變時,將觸發回調。promise有一個執行器函數,其作用是設置使用promise的異步操作resolver函數或rejector函數來改變promise的狀態。

promise帶來的大部分價值在於它們如何幫助我們簡化複雜的場景。因爲,如果我們只需處理一個運行帶有解析值回調的異步操作,那麼使用promises就不值得。這是不常見的情況。常見的情況是幾個異步操作,每個操作都需要解析返回值;並且這些值需要同步處理和轉換。Promises有方法幫助我們這樣做,因此,我們能夠更好地將同步併發原則應用於我們的代碼。

在下一章中,我們將介紹另一個新引入的語法 - Generator。與promises類似,生成器是幫助我們應用另一個併發原則的機制 - 保護。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章