本文是我翻譯《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的可能狀態;它還顯示了狀態之間是如何變化的:
現在,我們來看一些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不能完成或拒絕,然后整個流程將崩潰,這種情況除錯起來是非常麻煩的;下面的圖可以很清楚的看到這個情況:
在圖中,我們可以看到哪個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后清除狀態,
我們可以用這樣的方式撰寫代碼:完成和拒絕狀態的每個回呼都去執行這些操作,或者他們每個都可以呼叫執行一些公用的清理函式,下面這種方式的示圖:
將清理任務分配給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()一起呼叫時會發生什么:
我們可以看到,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函式相互傳遞的示圖:
在這個函式堆疊呼叫結束時,我們得到一個完成幾個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類似,生成器是幫助我們應用另一個并發原則的機制 - 保護,
最后補充下書籍章節目錄
- 《JavaScript并發編程》第一章 JavaScript并發簡介
- 《JavaScript并發編程》第二章 JavaScript運行模型
- 《JavaScript并發編程》第三章 使用Promises實作同步
- 《JavaScript并發編程》第四章 使用Generators實作惰性計算
- 《JavaScript并發編程》第五章 使用Web Workers
- 《JavaScript并發編程》第六章 實用的并發
- 《JavaScript并發編程》第七章 抽取并發邏輯
另外還有講解兩章nodeJs后端并發方面的,和一章專案實戰方面的,這里就不再貼了,有興趣可轉向https://github.com/yzsunlei/javascript_concurrency_translation查看,
轉載請註明出處,本文鏈接:https://www.uj5u.com/qianduan/34042.html
標籤:HTML5
上一篇:git使用
