參考
深入理解 Promise 五部曲 -- 1.異步問題
深入理解 Promise 五部曲 -- 2.控制權(quán)轉(zhuǎn)換問題
深入理解 Promise 五部曲 -- 3.可靠性問題
深入理解 Promise 五部曲 -- 4.擴展問題
深入理解 Promise 五部曲 -- 5.LEGO
【javascript】異步編年史,從“純回調(diào)”到Promise
阮一峰 ES6入門 Promise
廖雪峰 JavaScript教程 Promise
理清 Promise 的狀態(tài)及使用
一、異步回調(diào)的問題
1.調(diào)用函數(shù)過早
調(diào)用函數(shù)過早的最值得讓人注意的問題, 是你不小心定義了一個函數(shù),使得作為函數(shù)參數(shù)的回調(diào)可能延時調(diào)用,也可能立即調(diào)用。 也即你使用了一個可能同步調(diào)用, 也可能異步調(diào)用的回調(diào)。 這樣一種難以預(yù)測的回調(diào)。
在英語世界里, 這種可能同步也可能異步調(diào)用的回調(diào)以及包裹它的函數(shù), 被稱作是 “Zalgo” (一種都市傳說中的魔鬼), 而編寫這種函數(shù)的行為, 被稱作是"release Zalgo" (將Zalgo釋放了出來)
var a =1
zalgoFunction () {
// 這里還有很多其他代碼,使得a = 2可能被異步調(diào)用也可能被同步調(diào)用
[ a = 2 ]
}
console.log(a)
結(jié)果會輸出什么呢? 如果zalgoFunction是同步的, 那么a 顯然等于2, 但如果 zalgoFunction是異步的,那么 a顯然等于1。于是, 我們陷入了無法判斷調(diào)用影響的窘境。
這只是一個極為簡單的場景, 如果場景變得相當(dāng)復(fù)雜, 結(jié)果又會如何呢?你可能想說: 我自己寫的函數(shù)我怎么會不知道呢?很多時候這個不確定的函數(shù)來源于它人之手,甚至來源于完全無法核實的第三方代碼。我們把這種不確定的情況稍微變得夸張一些: 這個函數(shù)中傳入的回調(diào), 有99%的幾率被異步調(diào)用, 有1%的幾率被同步調(diào)用。
2.調(diào)用次數(shù)過多
這里取《你不知道的javascript(中卷)》的例子給大家看一看:
作為一個公司的員工,你需要開發(fā)一個網(wǎng)上商城, payWithYourMoney是你在確認購買后執(zhí)行的扣費的函數(shù), 由于公司需要對購買的數(shù)據(jù)做追蹤分析, 這里需要用到一個做數(shù)據(jù)分析的第三方公司提供的analytics對象中的purchase函數(shù)。 代碼看起來像這樣
analytics.purchase( purchaseData, function () {
payWithYourMoney ()
} );
在這情況下,可能我們會忽略的一個事實是: 我們已經(jīng)把payWithYourMoney 的控制權(quán)完全交給了analytics.purchase函數(shù)了,這讓我們的回調(diào)“任人宰割”,這種控制權(quán)的轉(zhuǎn)移, 被叫做“控制反轉(zhuǎn)”。
然后上線后的一天, 數(shù)據(jù)分析公司的一個隱蔽的bug終于顯露出來, 讓其中一個原本只執(zhí)行一次的payWithYourMoney執(zhí)行了5次, 這讓那個網(wǎng)上商城的客戶極為惱怒, 并投訴了你們公司??赡銈児疽埠軣o奈, 這個時候驚奇的發(fā)現(xiàn): payWithYourMoney的控制完全不在自己的手里 ?。。。?!后來, 為了保證只支付一次, 代碼改成了這樣:
// 判斷是否已經(jīng)分析(支付)過一次了
var analysisFlag = true
analytics.purchase( purchaseData, function(){
if (!analysisFlag) {
payWithYourMoney ()
analysisFlag = false
}
} );
但是, 這種方式雖然巧妙, 但卻仍不夠簡潔優(yōu)雅(后文提到的Promise將改變這一點)。而且, 在回調(diào)函數(shù)的無數(shù)“痛點”中, 它只能規(guī)避掉一個, 如果你嘗試規(guī)避掉所有的“痛點”,代碼將比上面更加復(fù)雜而混亂。
3.太晚調(diào)用或根本沒有調(diào)用
因為你失去了對回調(diào)的控制權(quán), 你的回調(diào)可能會出現(xiàn)預(yù)期之外的過晚調(diào)用或者不調(diào)用的情況(為了處理這個“痛點”你又將混入一些復(fù)雜的代碼邏輯)
4.吞掉報錯
回調(diào)內(nèi)的報錯是可能被包裹回調(diào)的外部函數(shù)捕捉而不報錯,(為了處理這個“痛點”你又又又將混入一些復(fù)雜的代碼邏輯)
5.復(fù)雜情況下可讀性差
請問這段代碼的調(diào)用順序 ?
doA( function(){
doB();
doC( function(){
doD();
} )
doE();
} );
doF();
讓人一臉蒙逼的回調(diào)函數(shù)地獄
setTimeout(function (name) {
var catList = name + ','
setTimeout(function (name) {
catList += name + ',';
setTimeout(function (name) {
catList += name + ',';
setTimeout(function (name) {
catList += name + ',';
setTimeout(function (name) {
catList += name;
console.log(catList);
}, 1, 'Lion');
}, 1, 'Snow Leopard');
}, 1, 'Lynx');
}, 1, 'Jaguar');}, 1, 'Panther');
6.門
什么叫“門”?, 你可以大概理解成: 現(xiàn)在有一群人準(zhǔn)備進屋,但只有他們所有人都到齊了,才能“進門” ,也就是: 只有所有的異步操作都完成了, 我們才認為它整體完成了,才能進行下一步操作
下面這個例子里, 我們試圖通過兩個異步請求操作,希望當(dāng)a和b的取值都到達的時候才輸出?。?/p>
var a, b;
function foo(x) {
a = x * 2;
if (a && b) {
baz();
}
}
function bar(y) {
b = y * 2;
if (a && b) {
baz();
}
}
function baz() {
console.log( a + b );
}
// ajax(..)是某個庫中的某個Ajax函數(shù)
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );
這段代碼比前面那段“鏈?zhǔn)健崩锏幕卣{(diào)地獄好懂多了,但是卻依然存在這一些問題:我們使用了兩個 if (a && b) { } 去分別保證baz是在a和b都到達后才執(zhí)行的,試著思考一下:兩個 if (a && b) { } 的判斷條件是否可以合并到一起呢,因為這兩個判斷條件都試圖表達同一種語意: a 和 b都到達, 能合并成一條語句的話豈不是更加簡潔優(yōu)雅 ? (一切都在為Promise做鋪墊哦~~~~啦啦啦)
7.競態(tài)
一組異步操作,其中一個完成了, 這組異步操作便算是整體完成了。在下面,我們希望通過異步請求的方式,取得x的值,然后執(zhí)行foo或者bar,但希望只把foo或者bar其中一個函數(shù)執(zhí)行一次
var flag = true;
function foo(x) {
if (flag) {
x = x + 1
baz(x);
flag = false
}
}
function bar(x) {
if (flag) {
x = x*2
baz(x);
flag = false
}
}
function baz( x ) {
console.log( x );
}
// ajax(..)是某個庫中的某個Ajax函數(shù)
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );
在這里,我們設(shè)置了一個flag, 設(shè)它的初始值為true, 這時候foo或者bar在第一次執(zhí)行的時候, 是可以進入if內(nèi)部的代碼塊并且執(zhí)行baz函數(shù)的, 但在if內(nèi)部的代碼塊結(jié)束的時候, 我們把flag的值置為false,這個時候下一個函數(shù)就無法進入代碼塊執(zhí)行了, 這就是回調(diào)對于競態(tài)的處理。
二、Promise 的含義
Promise 是異步編程的一種解決方案,比傳統(tǒng)的解決方案——回調(diào)函數(shù)和事件——更合理和更強大。它由社區(qū)最早提出和實現(xiàn),ES6 將其寫進了語言標(biāo)準(zhǔn),統(tǒng)一了用法,原生提供了Promise對象。
所謂Promise,簡單說就是一個容器,里面保存著某個未來才會結(jié)束的事件(通常是一個異步操作)的結(jié)果。從語法上說,Promise 是一個對象,從它可以獲取異步操作的消息。Promise 提供統(tǒng)一的 API,各種異步操作都可以用同樣的方法進行處理。
Promise對象有以下兩個特點。
(1)對象的狀態(tài)不受外界影響。Promise對象代表一個異步操作,有三種狀態(tài):pending(進行中)、fulfilled(已成功)和rejected(已失?。?。只有異步操作的結(jié)果,可以決定當(dāng)前是哪一種狀態(tài),任何其他操作都無法改變這個狀態(tài)。這也是Promise這個名字的由來,它的英語意思就是“承諾”,表示其他手段無法改變。
(2)一旦狀態(tài)改變,就不會再變,任何時候都可以得到這個結(jié)果。Promise對象的狀態(tài)改變,只有兩種可能:從pending變?yōu)閒ulfilled和從pending變?yōu)閞ejected。只要這兩種情況發(fā)生,狀態(tài)就凝固了,不會再變了,會一直保持這個結(jié)果,這時就稱為 resolved(已定型)。如果改變已經(jīng)發(fā)生了,你再對Promise對象添加回調(diào)函數(shù),也會立即得到這個結(jié)果。這與事件(Event)完全不同,事件的特點是,如果你錯過了它,再去監(jiān)聽,是得不到結(jié)果的。
注意,為了行文方便,本章后面的resolved統(tǒng)一只指fulfilled狀態(tài),不包含rejected狀態(tài)。
有了Promise對象,就可以將異步操作以同步操作的流程表達出來,避免了層層嵌套的回調(diào)函數(shù)。此外,Promise對象提供統(tǒng)一的接口,使得控制異步操作更加容易。
Promise也有一些缺點。首先,無法取消Promise,一旦新建它就會立即執(zhí)行,無法中途取消。其次,如果不設(shè)置回調(diào)函數(shù),Promise內(nèi)部拋出的錯誤,不會反應(yīng)到外部。第三,當(dāng)處于pending狀態(tài)時,無法得知目前進展到哪一個階段(剛剛開始還是即將完成)。
如果某些事件不斷地反復(fù)發(fā)生,一般來說,使用 Stream 模式是比部署Promise更好的選擇。
三、Promise是怎么解決問題的
1.回調(diào)過早調(diào)用
讓我們回到那個回調(diào)的痛點:我們有可能會寫出一個既可能同步執(zhí)行, 又可能異步執(zhí)行的“zalgo”函數(shù)。但Promise可以自動幫我們避免這個問題:如果對一個 Promise 調(diào)用 then(..) 的時候,即使這個 Promise是立即resolve的函數(shù)(即Promise內(nèi)部沒有ajax等異步操作,只有同步操作), 提供給then(..) 的回調(diào)也是會被異步調(diào)用的,這幫助我們省了不少心
回調(diào)調(diào)用次數(shù)過多
Promise 的內(nèi)部機制決定了調(diào)用單個Promise的then方法, 回調(diào)只會被執(zhí)行一次,因為Promise的狀態(tài)變化是單向不可逆的,當(dāng)這個Promise第一次調(diào)用resolve方法, 使得它的狀態(tài)從pending(正在進行)變成fullfilled(已成功)或者rejected(被拒絕)后, 它的狀態(tài)就再也不能變化了。所以你完全不必擔(dān)心Promise.then( function ) 中的function會被調(diào)用多次的情況回調(diào)中的報錯被吞掉
要說明一點的是Promise中的then方法中的error回調(diào)被調(diào)用的時機有兩種情況:
- a. Promise中主動調(diào)用了reject (有意識地使得Promise的狀態(tài)被拒絕), 這時error回調(diào)能夠接收到reject方法傳來的參數(shù)(reject(error))
- b. 在定義的Promise中, 運行時候報錯(未預(yù)料到的錯誤), 也會使得Promise的狀態(tài)被拒絕,從而使得error回調(diào)能夠接收到捕捉到的錯誤
例如:
var p = new Promise( function(resolve,reject){
foo.bar(); // foo未定義,所以會出錯!
resolve( 42 ); // 永遠不會到達這里 :(
} );
p.then(
function fulfilled(){
// 永遠不會到達這里 :(
},
function rejected(err){
// err將會是一個TypeError異常對象來自foo.bar()這一行
}
);
- 還有一種情況是回調(diào)根本就沒有被調(diào)用,這是可以用Promise的race方法解決(下文將介紹)
// 用于超時一個Promise的工具
function timeoutPromise(delay) {
return new Promise( function(resolve,reject){
setTimeout( function(){
reject( "Timeout!" );
}, delay );
} );
}
// 設(shè)置foo()超時
Promise.race( [
foo(), // 試著開始foo()
timeoutPromise( 3000 ) // 給它3秒鐘
] ).then(
function(){
// foo(..)及時完成!
},
function(err){
// 或者foo()被拒絕,或者只是沒能按時完成
// 查看err來了解是哪種情況
}
);
5.鏈?zhǔn)?br> 我們上面說了, 純回調(diào)的一大痛點就是“金字塔回調(diào)地獄”, 這種“嵌套風(fēng)格”的代碼丑陋難懂,但Promise就可以把這種“嵌套”風(fēng)格的代碼改裝成我們喜聞樂見的“鏈?zhǔn)健憋L(fēng)格。因為then函數(shù)是可以鏈?zhǔn)秸{(diào)用的, 你的代碼可以變成這樣
Promise.then(
// 第一個異步操作
).then(
// 第二個異步操作
).then(
// 第三個異步操作
)
6.門Promise.all,競態(tài)Promise.race見后文
四、基本用法
ES6 規(guī)定,Promise對象是一個構(gòu)造函數(shù),用來生成Promise實例。下面代碼創(chuàng)造了一個Promise實例。
const promise = new Promise(function(resolve, reject) {
// ... some code
if (/* 異步操作成功 */){
resolve(value);
} else {
reject(error);
}
});
Promise構(gòu)造函數(shù)接受一個函數(shù)作為參數(shù),該函數(shù)的兩個參數(shù)分別是resolve和reject。它們是兩個函數(shù),由 JavaScript 引擎提供,不用自己部署。
resolve函數(shù)的作用是,將Promise對象的狀態(tài)從“未完成”變?yōu)椤俺晒Α保磸?pending 變?yōu)?resolved),在異步操作成功時調(diào)用,并將異步操作的結(jié)果,作為參數(shù)傳遞出去;reject函數(shù)的作用是,將Promise對象的狀態(tài)從“未完成”變?yōu)椤笆 保磸?pending 變?yōu)?rejected),在異步操作失敗時調(diào)用,并將異步操作報出的錯誤,作為參數(shù)傳遞出去。
Promise實例生成以后,可以用then方法分別指定resolved狀態(tài)和rejected狀態(tài)的回調(diào)函數(shù)。
promise.then(function(value) {
// success
}, function(error) {
// failure
});
then方法可以接受兩個回調(diào)函數(shù)作為參數(shù)。第一個回調(diào)函數(shù)是Promise對象的狀態(tài)變?yōu)閞esolved時調(diào)用,第二個回調(diào)函數(shù)是Promise對象的狀態(tài)變?yōu)閞ejected時調(diào)用。其中,第二個函數(shù)是可選的,不一定要提供。這兩個函數(shù)都接受Promise對象傳出的值作為參數(shù)。
下面是一個Promise對象的簡單例子。
function timeout(ms) {
return new Promise((resolve, reject) => {
setTimeout(resolve, ms, 'done');
});
}
timeout(100).then((value) => {
console.log(value);
});
上面代碼中,timeout方法返回一個Promise實例,表示一段時間以后才會發(fā)生的結(jié)果。過了指定的時間(ms參數(shù))以后,Promise實例的狀態(tài)變?yōu)閞esolved,就會觸發(fā)then方法綁定的回調(diào)函數(shù)。
下面是一個用Promise對象實現(xiàn)的 Ajax 操作的例子。
const getJSON = function(url) {
const promise = new Promise(function(resolve, reject){
const handler = function() {
if (this.readyState !== 4) {
return;
}
if (this.status === 200) {
resolve(this.response);
} else {
reject(new Error(this.statusText));
}
};
const client = new XMLHttpRequest();
client.open("GET", url);
client.onreadystatechange = handler;
client.responseType = "json";
client.setRequestHeader("Accept", "application/json");
client.send();
});
return promise;
};
getJSON("/posts.json").then(function(json) {
console.log('Contents: ' + json);
}, function(error) {
console.error('出錯了', error);
});
上面代碼中,getJSON是對 XMLHttpRequest 對象的封裝,用于發(fā)出一個針對 JSON 數(shù)據(jù)的 HTTP 請求,并且返回一個Promise對象。需要注意的是,在getJSON內(nèi)部,resolve函數(shù)和reject函數(shù)調(diào)用時,都帶有參數(shù)。
五、Promise.prototype.then()
前面說過,then方法的第一個參數(shù)是resolved狀態(tài)的回調(diào)函數(shù),第二個參數(shù)(可選)是rejected狀態(tài)的回調(diào)函數(shù)。
then方法返回的是一個新的Promise實例(注意,不是原來那個Promise實例)。因此可以采用鏈?zhǔn)綄懛ǎ磘hen方法后面再調(diào)用另一個then方法。
getJSON("/posts.json").then(function(json) {
return json.post;
}).then(function(post) {
// ...
});
上面的代碼使用then方法,依次指定了兩個回調(diào)函數(shù)。第一個回調(diào)函數(shù)完成以后,會將返回結(jié)果作為參數(shù),傳入第二個回調(diào)函數(shù)。
采用鏈?zhǔn)降膖hen,可以指定一組按照次序調(diào)用的回調(diào)函數(shù)。這時,前一個回調(diào)函數(shù),有可能返回的還是一個Promise對象(即有異步操作),這時后一個回調(diào)函數(shù),就會等待該Promise對象的狀態(tài)發(fā)生變化,才會被調(diào)用。
getJSON("/post/1.json").then(function(post) {
return getJSON(post.commentURL);
}).then(function funcA(comments) {
console.log("resolved: ", comments);
}, function funcB(err){
console.log("rejected: ", err);
});
上面代碼中,第一個then方法指定的回調(diào)函數(shù),返回的是另一個Promise對象。這時,第二個then方法指定的回調(diào)函數(shù),就會等待這個新的Promise對象狀態(tài)發(fā)生變化。如果變?yōu)閞esolved,就調(diào)用funcA,如果狀態(tài)變?yōu)閞ejected,就調(diào)用funcB。
如果采用箭頭函數(shù),上面的代碼可以寫得更簡潔。
getJSON("/post/1.json").then(
post => getJSON(post.commentURL)
).then(
comments => console.log("resolved: ", comments),
err => console.log("rejected: ", err)
);
六、Promise.prototype.catch()
Promise.prototype.catch方法是.then(null, rejection)的別名,用于指定發(fā)生錯誤時的回調(diào)函數(shù)。
p.then((val) => console.log('fulfilled:', val))
.catch((err) => console.log('rejected', err));
// 等同于
p.then((val) => console.log('fulfilled:', val))
.then(null, (err) => console.log("rejected:", err));
下面代碼中,getJSON方法返回一個 Promise 對象,如果該對象狀態(tài)變?yōu)閞esolved,則會調(diào)用then方法指定的回調(diào)函數(shù);如果異步操作拋出錯誤,狀態(tài)就會變?yōu)閞ejected,就會調(diào)用catch方法指定的回調(diào)函數(shù),處理這個錯誤。另外,then方法指定的回調(diào)函數(shù),如果運行中拋出錯誤,也會被catch方法捕獲。
getJSON('/posts.json').then(function(posts) {
// ...
}).catch(function(error) {
// 處理 getJSON 和 前一個回調(diào)函數(shù)運行時發(fā)生的錯誤
console.log('發(fā)生錯誤!', error);
});
用 then 的第二個參數(shù)去處理錯誤不是最好的選擇。因為大多數(shù)情況下我們會用到鏈?zhǔn)秸{(diào)用。類似:promise.then().then().then()。所以在每個 then 方法去處理錯誤顯得代碼很多余,而且也真的沒必要。
catch 方法就是用來做錯誤統(tǒng)一處理。這樣鏈?zhǔn)秸{(diào)用中我們只需要用 then 來處理 fulfilled 狀態(tài),在鏈的末尾加上 catch 來統(tǒng)一處理錯誤。
new Promise((resolve, reject) => {
setTimeout(() => {
resolve(100);
}, 2000)
}).then(result => {
return result * num // 這里模擬一個錯誤,num 未定義
}).then(result => {
return result / 2;
}).catch(err => {
console.log(err); // num is not defined
})
這里舉得例子比較簡單,在 then 方法里面沒有去 return 新的 promise??梢钥吹降谝粋€ then 發(fā)生了錯誤,最后的 catch 會捕捉這個錯誤。catch 實際上是.then(null, rejection)的別名。
七、Promise.prototype.finally()
finally方法用于指定不管 Promise 對象最后狀態(tài)如何,都會執(zhí)行的操作。該方法是 ES2018 引入標(biāo)準(zhǔn)的。
promise
.then(result => {···})
.catch(error => {···})
.finally(() => {···});
上面代碼中,不管promise最后的狀態(tài),在執(zhí)行完then或catch指定的回調(diào)函數(shù)以后,都會執(zhí)行finally方法指定的回調(diào)函數(shù)。
下面是一個例子,服務(wù)器使用 Promise 處理請求,然后使用finally方法關(guān)掉服務(wù)器。
server.listen(port)
.then(function () {
// ...
})
.finally(server.stop);
finally方法的回調(diào)函數(shù)不接受任何參數(shù),這意味著沒有辦法知道,前面的 Promise 狀態(tài)到底是fulfilled還是rejected。這表明,finally方法里面的操作,應(yīng)該是與狀態(tài)無關(guān)的,不依賴于 Promise 的執(zhí)行結(jié)果。
八、Promise.all()
試想一個頁面聊天系統(tǒng),我們需要從兩個不同的URL分別獲得用戶的個人信息和好友列表,這兩個任務(wù)是可以并行執(zhí)行的,用Promise.all()實現(xiàn)如下:
var p1 = new Promise(function (resolve, reject) {
setTimeout(resolve, 500, 'P1');
});
var p2 = new Promise(function (resolve, reject) {
setTimeout(resolve, 600, 'P2');
});
// 同時執(zhí)行p1和p2,并在它們都完成后執(zhí)行then:
Promise.all([p1, p2]).then(function (results) {
console.log(results); // 獲得一個Array: ['P1', 'P2']
});
九、Promise.race()
有些時候,多個異步任務(wù)是為了容錯。比如,同時向兩個URL讀取用戶的個人信息,只需要獲得先返回的結(jié)果即可。這種情況下,用Promise.race()實現(xiàn):
var p1 = new Promise(function (resolve, reject) {
setTimeout(resolve, 500, 'P1');
});
var p2 = new Promise(function (resolve, reject) {
setTimeout(resolve, 600, 'P2');
});
Promise.race([p1, p2]).then(function (result) {
console.log(result); // 'P1'
});
由于p1執(zhí)行較快,Promise的then()將獲得結(jié)果'P1'。p2仍在繼續(xù)執(zhí)行,但執(zhí)行結(jié)果將被丟棄。