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

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

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

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

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

Promise相關術語

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

Promise

顧名思義,Promise是一種承諾。將Promise視爲尚不存在的值的代理。Promise讓咱們更好的編寫併發代碼,由於咱們知道值會在未來某個時刻存在,而且咱們沒必要編寫大量的狀態檢查樣板代碼。程序員

狀態(State)

Promises老是處於如下三種狀態之一:github

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

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

• 拒絕:處理Promise的值出了問題。如今沒有數據。json

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

執行器(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相似,生成器是幫助咱們應用另外一個併發原則的機制 - 保護。

最後補充下書籍章節目錄

另外還有講解兩章nodeJs後端併發方面的,和一章項目實戰方面的,這裏就再也不貼了,有興趣可轉向https://github.com/yzsunlei/javascript_concurrency_translation查看。

相關文章
相關標籤/搜索