JavaScript里通常不建議阻塞主程序,尤其是一些代價比較昂貴的操作,如查找數據庫,下載文件等操作,應該用異步API,如Ajax模式。在執行操作的的同時,主程序能繼續處理后序代碼,等操作結束后調用回調函數。
例如文件下載,同步方式的話,萬一網絡情緒不穩定,下載文件耗時很長,頁面就卡住了,所以一定會用異步API:
downloadAsync("http://example.com/file.txt", function(text) {
console.log(text);
});
但通常異步操作并非只有單一步驟,就以文件下載為例,如果下載3個有關聯的文件,你要如何才能保證依次下載呢?你可能會用回調嵌套:
downloadAsync("a.txt", function(a) {
downloadAsync("b.txt", function(b) {
downloadAsync("c.txt", function(c) {
console.log("Contents: " + a + b + c);
}, function(error) {
console.log("Error: " + error);
});
}, function(error) {
console.log("Error: " + error);
});
}, function(error) {
console.log("Error: " + error);
});
回調嵌套本身沒有什么錯。它被詬病之處在于,這才3層,基本框架就很難看了。業務邏輯稍微多一點,即便你努力重構出子函數,也難以避免讓主體框架陷入“回調地獄”,代碼會難以閱讀和維護。
這就是Promise誕生的原因,它并不是全新的數據類型,而是一種組織代碼的方式,內部改進了回調函數,對外暴露出then方法。讓開發者避免陷入“回調地獄”,寫出更加清晰的,類似函數鏈的代碼。ES6將Promise納入了標準,本篇就介紹一下Promise。
- Promise對象
- then方法
- catch方法
- 靜態方法(resolve,reject,all,race)
- 例子
Promise對象
Promise既然是一種代碼組織方式,它就需要為用戶提供構造函數來創建一個Promise對象。構造函數原型:new Promise(function(resolve, reject) { … } );。參照MDN
構造函數用一個函數作為參數,該函數有兩個參數,兩個參數均是回調函數,由JS引擎提供,你不用自己部署了。第一個參數resolve,當異步操作成功時會調用,它有一個參數用于傳遞異步操作成功的結果。第二個參數reject,當異步操作失敗時會調用,它有一個參數用于傳遞異步操作失敗的信息。例如:
var myPromise = new Promise(function(resolve, reject) {
... //異步操作
if( success ) {
resolve(value);
} else {
reject(error);
}
});
上面生成了一個Promise對象,它代表著一個異步操作。有3種狀態Pending,Resolved(MDN里又稱Fulfilled),Rejected??疵志椭婪謩e表示異步操作中,操作成功,操作失敗。
一旦調用構造函數開始生成Promise對象(如上面myPromise),就會立即執行異步操作。異步操作成功的話,myPromise狀態會從Pending切換成Resolved。失敗的話,狀態從Pending切換成Rejected。狀態改變后就固定了,永遠不會再次改變,這就是Promise的語義,表示“承諾”。一旦承諾,海枯石爛都不會變。實例對象生成后,就能調用then方法了。
then方法
Promise.prototype.then()方法顯然就是Promise的精華。函數聲明:p.then(resolve, reject);
。參照MDN
注意它不是靜態方法,需要經由Promise實例對象來調用。
then方法有兩個參數,第一個參數是Promise實例對象為Resolved狀態時的回調函數,它的參數就是上面Promise構造函數里resolve傳遞過來的異步操作成功的結果。
第二個參數可選,是Promise實例對象為Rejected狀態時的回調函數,它的參數就是上面Promise構造函數里reject傳遞過來的異步操作失敗的信息。
例如:
var myPromise = new Promise(function(resolve, reject) {
console.log("執行異步操作");
resolve("成功");
//reject ("失敗啦");
});
console.log("后續操作");
myPromise.then(function(value) {
console.log(value);
}, function(error) {
console.log(error);
});
//執行異步操作
//后續操作
//成功
上面結果可以看出,一旦調用構造函數開始生成Promise對象,就會立即執行異步操作,打印出第一行log。由于是異步,會繼續執行生成Promise對象外的代碼,打印出第二行log。異步操作成功后,打印出then里resolve回調函數里的log。
由于通常異常會用下面介紹的catch方法捕捉,因此then方法的第二個參數通常會省略:
myPromise.then(function(value) {
... //成功
}).catch(function(error) {
... //失敗
});
then方法最強大之處在于,它內部可以使用return或throw來實現鏈式調用。使用return或throw后的返回值是一個新的Promise實例對象(注意,不是原來那個Promise實例對象):
var myPromise = new Promise(function(resolve, reject) {
resolve(1);
});
myPromise.then(function(value) {
console.log("第" + value + "一次異步操作成功"); //第1次異步操作成功
return value+1;
}).then(function(value) {
console.log("第" + value + "一次異步操作成功"); //第2次異步操作成功
});
myPromise.then(function(value) {
console.log("第" + value + "一次異步操作成功"); //第1次異步操作成功
});
上面代碼中,myPromise對象第一次調用then時,value為1,打印出log后return出一個匿名Promise對象。
你可能會疑惑,看代碼return value+1;
(相當于return 2;
)只是返回了一個數字,并沒有返回Promise對象呀。其實它會隱式調用下面介紹的Promise.resolve靜態方法將轉為一個Promise對象。具體怎么轉的,請參照下面介紹的Promise.resolve靜態方法。
如果then方法內部不是return,而是throw,會隱式調用下面介紹的Promise.reject靜態方法返回一個Rejected狀態的Promise對象。
由于then里用return或throw返回的是另一個Promise對象,這就實現了鏈式調用。
代碼中返回得到的匿名Promise對象,由于狀態是Resolved,立即調用鏈式中第二個then方法,value為2。(如果匿名Pormise對象里有異步API,那仍舊會等操作成功或失敗后,再觸發調用鏈中的then方法)
最后一次then方法由myPromise對象調用,因為Promise對象的狀態改變后就固定了,永遠不會再次改變,所以value值仍舊為1。
一個常見的錯誤是,忘記寫return或throw,但卻對then方法使用鏈式調用。如上例中將return語句刪掉:
myPromise.then(function(value) {
console.log("第" + value + "一次異步操作成功"); //第1次異步操作成功
}).then(function(value) {
console.log("第" + value + "一次異步操作成功"); //第undefined次異步操作成功
});
結果發現鏈式調用后,下一個then方法得到的是undefined。因為如果不顯式寫return語句的話,JS里的函數會自動 return undefined。這樣就相當于調用下面介紹的Promise.resolve(undefined)。雖然瀏覽器覺得這段代碼合法,不會報錯,但通常來說這不是你期待的結果。因此推薦:
確保處于調用鏈中間的then方法內部永遠顯式的調用return或者throw。
catch方法
Promise.prototype.catch()同樣是實例方法,需要經由Promise實例對象來調用,用于Promise實例對象狀態為Rejected的后續處理,即異常處理。函數聲明:p.catch(reject);
。參照MDN
catch方法本質上等價于then(null, reject),因此參數reject在上面介紹過了,是一個回調函數,它的參數就是Promise對象狀態變為Rejected后,傳遞來的錯誤信息。
例如:
var myPromise = new Promise(function(resolve, reject) {
throw "異步操作失敗";
resolve("成功");
});
myPromise.then(function(value) {
console.log(value);
}).catch(function(e) {
console.log(e);
});
//異步操作失敗
異步操作時throw了異常,導致myPromise狀態變成Rejected。由于then里未定義第二個可選的reject回調函數,所以跳過then方法,進入catch,打印出log。
有個細節要注意,上面異步操作里throw和resolve語句別寫反了,寫反了是不會捕捉異常的:
var myPromise = new Promise(function(resolve, reject) {
resolve("成功"); //throw和resovle的順序寫反了
throw "異步操作失敗";
});
myPromise.then(function(value) {
console.log(value);
}).catch(function(e) {
console.log(e);
});
//成功
上面代碼未能捕獲異常,并不是throw語句未被執行,throw語句確實被執行了。但由于已經將狀態改為Resolved了,Promise對象的狀態一旦改變將永不再變。因此不會進入catch語句里。即throw異常的話,要保證Promise對象的狀態為Rejected,否則即使throw了異常,也沒有回調函數能捕捉該異常。
那如果catch之前的then里也定義了第二個可選的reject回調函數參數呢?究竟是then的reject捕捉還是catch捕捉?其實明白了catch是then(null, reject)的別名,就能推導出,應該被then的reject捕捉:
myPromise.then(function(value) {
console.log(value);
},function(e) {
console.log(e + ",由then的reject回調函數捕捉");
}).catch(function(e) {
console.log(e);
});
//異步操作失敗,由then的reject回調函數捕捉
上面代碼等價于:
myPromise.then(function(value) {
console.log(value);
},function(e) {
console.log(e + ",由then的reject回調函數捕捉");
}).then(null, function(e) {
console.log(e);
});
//異步操作失敗,由then的reject回調函數捕捉
從結果可以看出,catch等價于then(null, reject),相當于調用鏈上全是then,自然是被第一個定義了reject回調的then捕捉。
當然使用catch看起來代碼更清晰,所以通常會省略then方法的第二個參數reject回調,用catch來捕捉異常。
then的返回值是一個全新的Promise對象,catch也不例外,同樣適用于鏈式調用。雖然通常catch都放在鏈式調用的最后,但沒人強制規定必須這么做,catch后面完全可以繼續接then。同樣的,如果catch未放在調用鏈最后,而是在調用鏈中間的話,別忘了顯示地寫上return或throw語句。
另外Promise對象的異常同樣具有冒泡特性,會一直向后傳遞,直到被捕捉到為止。因此then方法中拋出的異常,也能被catch捕捉。因為調用鏈里then拋出異常相當于將匿名Promise對象的狀態改成Rejected,這樣異常在調用鏈上傳遞,直到異常被捕捉到:
var myPromise = new Promise(function(resolve, reject) {
resolve("成功");
});
myPromise.then(function(value) {
console.log(value);
throw "then的resolve里拋出異常";
}).catch(function(e) {
console.log(e);
});
//成功
//then的resolve里拋出異常
上面代碼中,異常操作成功,打印出log。但在then的resolve回調函數里又throw出了異常,該異常會被then返回的匿名Promise對象繼續傳遞給catch。如果catch之前有多個then,無論哪里拋出異常,最終都會被catch捕捉到。
那如果異常未被捕捉到會怎么樣呢?例如myPromise.then(…).catch(…).then(…);。最后的then里拋出異常,或者catch里拋出異常(沒錯,catch等價于then(null, reject),自然在catch方法之中,也能再拋出異常),會怎么樣呢?
這就是Promise異常的冒泡和傳統的try/catch代碼塊的異常冒泡的區別:如果Promise異常最終未被捕捉到,不會繼續傳遞到外層代碼,即不會有任何反應:
var myPromise = new Promise(function(resolve, reject) {
resolve(x); //error,x未定義
});
myPromise.then(function(e) {
console.log('成功');}
);
上面代碼沒有catch語句,因此異常未被捕捉到,外層代碼也沒有任何反應,仿佛什么都沒發生(注意,試下來Chrome會拋出x未定義的錯誤)。忘記在最后加上catch,會導致珍貴的異常信息丟失,這對調試來說是異常痛苦的經歷。要記住,通常程序員對自己寫的代碼會很自信,覺得不會拋出一個 error,但現實不同環境下,相同代碼的執行結果會大相徑庭。所以為了將來調試方便,建議:
在promise調用鏈最后添加catch。
myPromise().then(function () {
…
}).then(function () {
…
}).catch(console.log.bind(console));
靜態方法(resolve,reject,all,race)
這里介紹的4個都是靜態方法,非實例方法,用Promise對象是無法調用的。
Promise.resolve將對象轉為Promise對象。函數聲明:Promise.resolve(value);
。參照MDN
參數是一個對象,轉換成Promise對象返回出去。你可以用該方法將jQuery的Deferred對象轉換成一個Promise對象:
var myPromise = Promise.resolve($.ajax('/xx.json'));
參數分4種情況:
1.如果參數對象本身就是Promise對象,那就將該對象原封不動返回。
2.如果參數對象本身是thenable對象(即對象有then方法),轉為Promise對象并返回后,立即執行thenable對象的then方法。例如:
var myPromise = Promise.resolve({
then: function(resolve) {
resolve("成功");
}
});
console.log(myPromise instanceof Promise);
myPromise.then(function(v) {
console.log(v);
});
//true
//成功
上面代碼中,Promise.resolve的參數里有then方法,因此myPromise對象生成后,立即執行then方法,根據異步操作的結果,調用then里resolve/reject回調函數
3.如果參數對象本身不是thenable對象(即對象沒有then方法),例如一個數字數組等,那會返回一個狀態固定為Resolved的全新的Promise對象:
var myPromise = Promise.resolve([1,2,3]);
myPromise.then(function(v) {
console.log(v);
});
//[1,2,3]
由于數組不是具有then方法的對象,返回Promise實例的狀態固定為Resolved,觸發調用then方法。原先的參數對象,會被傳給then方法的resolve回調函數做為參數。
為什么呢?因為Promise.resolve([1,2,3]);等價于:
new Promise(function(resolve, reject) {
resolve([1,2,3]);
});
4.如果沒有參數,那會返回一個狀態固定為Resolved的全新的空的Promise對象。雖然瀏覽器不會報錯,但也沒什么卵用。
Promise.reject將對象轉換成一個狀態為Rejected的全新的Promise對象。其他同Promise.resolve,不贅述。函數聲明:Promise.reject(error);
。參照MDN
該方法常用于調試,顯示棧信息:
Promise.reject(new Error("fail")).then(function(error) {
// not called
}, function(error) {
console.log(error); // Stacktrace
});
Promise.all方法用于將多個Promise實例對象,包裝成一個新的Promise實例對象。函數聲明:Promise.all(iterable);
。參照MDN
參數是一個Promise對象數組。例如:
var p = Promise.all([p1, p2, p3]);
p1,p2,p3都是Promise對象,如果不是,就會先調用Promise.resolve方法,將參數轉為Promise對象。(Promise.all方法的參數可以不是數組,但必須具有Iterator接口)
返回值是一個全新的Promise對象。它的狀態由參數的狀態決定:
當p1,p2,p3的狀態都變成Resolved,p的狀態才會變成Resolved。然后p1,p2,p3的返回值組成一個數組,傳遞給p的回調函數。
只要p1,p2,p3中有一個變成Rejected,p的狀態就變成Rejected,此時第一個Rejected的實例的返回值,會傳遞給p的回調函數。
var p1 = Promise.resolve(3);
var p2 = 1337;
var p3 = new Promise((resolve, reject) => {
setTimeout(resolve, 1000, "foo");
});
Promise.all([p1, p2, p3]).then(values => {
console.log(values); //1秒后顯示[3, 1337, "foo"]
});
all方法用于哪里呢?一個非常常見場景是:程序員想用forEach,for,while等循環來處理他們的異步結果。例如刪除所有臨時文件:
db.allDocs({include_docs: true}).then(function (result) {
result.rows.forEach(function (row) { //用forEach刪除所有臨時文件
db.remove(row.doc);
});
}).then(function () {
//我認為執行到這里時,所有臨時文件都已經被刪了。但是我錯了…
});
上面代碼,第一個then的回調里,期望用循環刪除所有臨時文件后,再進入第二個then。但事實上,由于第一個then里沒有return語句(原因是刪除文件是個異步動作,如果寫了return語句,刪文件執行之前就會return),所以返回的是undefined。因此第二個then并不是在所有文件均被刪除后才執行的,實際上,第二個then的執行不會有任何延遲,會被立即執行。
如果第二個then里并不需要對臨時文件進行任何操作,那這個bug可能會隱藏的很深。但如果在第二個then里刷新UI頁面,那時不時UI頁面上會出現一些還來不及刪掉的文件,這取決于刪文件的速度。程序員都知道,最討厭的不是出bug,而是出了看運氣才能再現的bug。
所以應該用Promise.all來寫這種需要循環處理異步的操作,可以理解為異步的循環。
db.allDocs({include_docs: true}).then(function (result) {
return Promise.all(result.rows.map(function (row) {
return db.remove(row.doc);
}));
}).then(function (arrayObject) {
//我承諾執行到這里時,所有臨時文件都已經被刪掉了。
})
Promise.race方法和all方法類似,函數聲明:Promise.race(iterable);
,參照MDN
var p = Promise.race([p1,p2,p3]);
參數p1,p2,p3都是Promise對象,如果不是,就會先調用Promise.resolve方法,將參數轉為Promise對象,這點和all相同,不贅述。
和Promise.all的區別是,race表示競爭。只要任意一個參數對象的狀態發生改變,就會立即返回一個相同狀態的Promise對象。
簡單地說:all里全為Resolved才返回Resolved,有一個為Rejected就返回Rejected。race里有一個為Resolved/Rejected就返回Resolved/Rejected
race常用于競爭異步執行的結果場景,例如:指定時間內沒有獲得結果就Rejected:
var p = Promise.race([
fetch('/resource'),
new Promise(function (resolve, reject) {
setTimeout(() => reject(new Error('request timeout')), 5000)
})
]);
p.then(response => console.log(response))
.catch(error => console.log(error));
上面代碼中,如果5秒之內無法fetch到數據,p的狀態就會變為Rejected,從而觸發catch方法指定的回調函數。
例子
例如我們要做兩次異步操作(用最簡單的異步操作setTimeout為例),第一次異步操作成功后執行第二次異步操作:
function delay(time, callback){
setTimeout(function(){
callback("sleep "+time);
},time);
}
delay(1000,function(msg){
console.log(msg);
delay(2000,function(msg){
console.log(msg);
});
});
//1秒后打印出:sleep 1000
//再過2秒打印出:sleep 2000
上面用回調嵌套很容易實現,但也發現才兩層(還沒加上異常處理),代碼就比較難看了。改成Promise試試:
function delay(time, callback){
setTimeout(function(){
callback("sleep "+time);
},time);
}
var p = new Promise(function (resolve, reject) {
delay(1000, resolve);
});
p.then(function(value) {
console.log(value);
return new Promise(function (resolve, reject) {
delay(2000, resolve);
});
}).then(function(value) {
console.log(value);
}).catch(console.log.bind(console));
因為才兩個異步操作,所以代碼行數上看,優勢不明顯。優勢在于Promise讓代碼的邏輯流程變得更清晰。先執行第一次異步操作,成功后進入then,打印出結果,并執行第二次異步操作,成功后進入then,打印出結果。
總結
ES6 Promise的實現嚴格遵循了Promise/A+規范,用Promise可以寫出更可讀的異步編程代碼,避免了回調地獄。需要注意的是,由于歷史原因,有的庫,例如jQuery的Deferred就不是Promise/ A+的規范,它是個工廠類,返回的是內部構建的deferred對象。本篇篇幅不夠去介紹Deferred和Promise的區別,但ES6的Promise完全可以取代Deferred。