本文作者就是我,簡書的microkof。如果您覺得本文對您的工作有意義,產生了不可估量的價值,那么請您不吝打賞我,謝謝!
名詞約定
Promises的概念是由CommonJS小組的成員在Promises/A規范中提出來的。一般來講,有以下的名詞約定:
promise(首字母小寫)對象指的是Promise實例對象
Promise首字母大寫且單數形式,表示Promise構造函數
Promises首字母大寫且復數形式,用于指代Promises規范
Promises/A規范和ES6 Promises規范
Promise規范有幾次升級,目前來講,Promise/A是最新的民間規范,ES6 Promises是最新的官方規范,只需知道ES6 Promises規范是你應該遵守的標準就行了。
為什么有Promises這個東西
- 解決回調金字塔的問題,回調金字塔也叫回調圣誕樹,好聽吧?還有個別名叫回調地獄,不好聽了吧?到底可怕不可怕?非常的可怕。
- 可以同時管理成功回調和失敗回調。
名詞解釋:同步任務和異步任務
- 同步任務:只需JS引擎自身就可以完成的任務,叫同步任務。
- 異步任務:JS引擎自身無法完成,需要外力協助的任務,叫異步任務;還有一種是由JS引擎能夠獨立完成,但是JS引擎把任務分成了兩段,第二段當做回調,也是異步任務。
簡單理解JS引擎的執行機制的話,異步任務分為三個執行段,對JS引擎來講是執行第一和第三個階段:
- 異步任務本體執行:由JS引擎同步執行
- 異步任務外力執行:由外力執行
- 異步任務回調執行:由JS引擎異步執行
所謂“承諾”
Promise這個單詞的意思是“承諾”,就是我們日常說的“我肯定幫你買早飯”、“你如果交了錢我肯定給你一杯咖啡”。
在程序世界,舉例可以說:“我承諾給你完成這些代碼執行”。new一個Promise實例,就是JS引擎對你做了一個承諾。
既然是承諾,就肯定有成功的時候有失敗的時候,比如我幫你買早餐,結果今天煎餅果子沒出攤,或者是我走到半路上,煎餅果子的塑料袋破裂,煎餅果子滑落到了地上,這就是失敗。就連“我們承諾絕不首先動用核武器”都有堅持不下去的時候,所以只要是承諾就有成功和失敗,只不過是概率問題。
到程序世界,一個承諾也會有三種狀態,就是“未決的”、“成功的”、“失敗的”三種狀態。也就是pending/resolved/rejected三種狀態。
Promise構造函數的超能力
Promises寫法的本質就是把異步寫法擼成同步寫法。要做這么酷炫這么變態的事情,當然需要Promise構造函數有超能力,它的超能力就是傳入Promise構造函數的函數參數會第一優先執行,無論這個函數多么的繁復,有多少層回調,有多少秒的計數器,統統都會最優先執行,也就是說,我們只要new了一個Promise(),那么Promise構造函數的函數參數其實是同步代碼,但是.then比較特殊,.then會等到promise對象實例有了結果(resolved或者rejected),.then()里面代碼才會執行。鏈條上的每一個.then都會等前面的promise有了結果才會執行,Promise構造函數的這個超能力是Promises系統的威力之源。(當然,這里說的執行優先級,是在理想環境下,所謂理想環境也就是全部執行代碼只由new Promise()和它的一系列.then()方法組成。如果方法鏈之外還有其他代碼,那么整體代碼執行的先后順序就復雜化了,涉及到ES最底層的Event Loop,下文有介紹。然而,Promise加它的then方法鏈已經提供了梳理代碼執行順序的整套方案,如果在方法鏈之外還寫異步代碼的話屬于不鼓勵的寫法,應該盡量避免這么做。)
實現了Promise/A規范的瀏覽器
簡單說,IE全不支持,Edge支持,Chrome和Firefox在十幾個版本之前就已經接近支持,目前的最新版已經全面支持。
所以,IE8-10可以考慮Promise/A的Polyfill庫:
jakearchibald/es6-promise
一個兼容 ES6 Promises 的Polyfill類庫。 它基于 RSVP.js 這個兼容 Promises/A+ 的類庫, 它只是 RSVP.js 的一個子集,只實現了Promises 規定的 API。
yahoo/ypromise
這是一個獨立版本的 YUI 的 Promise Polyfill,具有和 ES6 Promises 的兼容性。 本書的示例代碼也都是基于這個 ypromise 的 Polyfill 來在線運行的。
getify/native-promise-only
以作為ES6 Promises的polyfill為目的的類庫 它嚴格按照ES6 Promises的規范設計,沒有添加在規范中沒有定義的功能。 如果運行環境有原生的Promise支持的話,則優先使用原生的Promise支持。
其他還有很多Polyfill類庫,不多說,可以github一下。
基本用法
案例1:現在開始,延遲3秒,執行console.log('第一個回調')
,然后定義一個變量a,值是3,然后再延遲2秒,執行console.log('第二個回調')
,然后執行console.log(a * 2)
。回調圣誕樹型寫法是:
setTimeout(function() {
console.log('第一個回調');
var a = 3;
setTimeout(function() {
console.log('第二個回調');
console.log(a * 2);
}, 2000);
}, 3000);
執行上面代碼,結果是:先延遲3秒,然后瀏覽器打印了一個第一個回調
字符串,然后又延遲2秒,然后瀏覽器打印了一個第二個回調
字符串,以及打印了一個6
數字。
然后遵循Promises的寫法是:
var promise = new Promise(function(resolve, reject) {
setTimeout(function() {
console.log('第一個回調');
var a = 3;
if ( true ){
resolve(a);
} else {
reject('bu ok');
}
}, 3000);
});
promise.then(function(value) {
setTimeout(function() {
console.log('第二個回調');
console.log(value * 2);
}, 2000);
}, function(error) {
console.log(error);
});
執行結果跟圣誕樹寫法的結果完全一致。
Promise是一個構造函數,用來生成promise實例。Promise構造函數接受一個函數作為參數,該函數的兩個參數分別是resolve和reject。它們是兩個函數,由JavaScript引擎提供,不用自己部署。
promise對象代表一個異步操作,有三種狀態:Pending(進行中)、Resolved
(已完成)和Rejected(已失敗)。
- resolve函數的作用是,將promise對象的狀態從“未完成”變為“成功”(即從Pending變為Resolved),在異步操作成功時調用,并將異步操作的結果,作為參數傳遞出去。
- reject函數的作用是,將promise對象的狀態從“未完成”變為“失敗”(即從Pending變為Rejected),在異步操作失敗時調用,并將異步操作報出的錯誤,作為參數傳遞出去。
promise對象生成以后,可以用then方法分別指定Resolved狀態和Reject狀態的回調函數。
then方法可以接受兩個回調函數作為參數。第一個回調函數是promise對象的狀態變為Resolved時調用,第二個回調函數是promise對象的狀態變為Reject時調用。其中,第二個函數是可選的,不一定要提供。這兩個函數都接受promise對象傳出的值作為參數。
然后這里你可能會問,if ( true ){}
是什么鬼?因為console.log('第一個對象');var a = 3;
是不可能失敗的,這都能失敗的話,等于js引擎掛了,也等于頁面掛了。所以我只能模擬成功和失敗狀態,現在你把true
改成false
試試,那么,延遲2秒之后是不是打印了bu ok
?
比較兩種寫法的區別,首先就可以看出Promises寫法的代碼多了很多。如果你確定promise對象根本不可能有失敗的狀態,可以省掉reject函數以及錯誤回調。那么可以簡寫成這樣:
var promise = new Promise(function(resolve) {
setTimeout(function() {
console.log('第一個回調');
resolve(3);
}, 3000);
});
promise.then(function(value) {
setTimeout(function() {
console.log('第二個回調');
console.log(value * 2);
}, 2000);
});
上面代碼就是只考慮promise對象“成功”的可能性,不考慮失敗的可能性。
去掉promise對象失敗的可能性之后,你可能繼續會說,“Promises寫法的代碼還是多!”沒錯,確實多,但不要只看劣勢不看優勢,沒有優勢的東西,是沒人會用的。假設回調多起來了,比如至少5個,而且每一步回調都有成功和失敗狀態,那么Promises的優勢才能顯現出來。
案例2:在案例1的基礎上,再延遲1秒,執行console.log('第三個回調'); console.log(value * 2);
,也就是說,我想在第二步的輸出值的基礎上再乘以2,也就是想得到12。代碼如下:
var promise = new Promise(function(resolve, reject) {
setTimeout(function() {
console.log('第一個回調');
resolve(3);
}, 3000);
});
promise.then(function(value) {
return new Promise(function(resolve, reject) {
setTimeout(function() {
console.log('第二個回調');
console.log(value * 2);
resolve(value * 2);
}, 2000);
});
}).then(function(value) {
setTimeout(function() {
console.log('第三個回調');
console.log(value * 2);
}, 1000);
});
結果沒問題:
這里用到了then
的鏈式調用。你會發現第一個then
返回了一個promise對象。這就跟案例1不一樣了,案例一的then
里沒有再返回promise對象。必須返回么?看案例3。
案例3:跟案例2相似,只是執行console.log('第二個回調')
這步不用延遲。代碼如下:
var promise = new Promise(function(resolve, reject) {
setTimeout(function() {
console.log('第一個回調');
resolve(3);
}, 3000);
});
promise.then(function(value) {
console.log('第二個回調');
console.log(value * 2);
return value * 2;
}).then(function(value) {
setTimeout(function() {
console.log('第三個回調');
console.log(value * 2);
}, 1000);
});
跟案例2的代碼的區別是什么?第一個then
方法中,沒了return promise對象
,取而代之的是return value * 2
,為什么?
因為案例2中,三個then
的回調函數是異步-異步-異步,案例3中,是異步-同步-異步,這區別很大。簡單情況下,then
方法中的回調執行代碼是同步代碼,這樣只需要簡單return一下參數,就可以把參數傳遞下去。復雜情況下,是異步-異步-異步這種情況,如果依然簡單的在setTimeout
的回調里return一下參數,你會發現,參數根本沒有及時傳遞。代碼如下:
var promise = new Promise(function(resolve, reject) {
setTimeout(function() {
console.log('第一個回調');
resolve(3);
}, 3000);
});
promise.then(function(value) {
setTimeout(function() {
console.log('第二個回調');
console.log(value * 2);
return value * 2;
}, 2000);
}).then(function(value) {
setTimeout(function() {
console.log('第三個回調');
console.log(value * 2);
}, 1000);
});
結果就神奇了:
什么原因?怎么理解?
簡單說原因是:
then
的鏈式執行,理想情況下是基于每個then的前一個then能夠返回promise對象。不理想情況下是沒有返回promise對象,這種情況下,雖然then的鏈式執行依然可以執行,但是,每個then
只可能等前一個then
的同步代碼完成,不會等前一個then
的異步代碼完成。第一個then
的同步代碼是一個計時器,開始計時就算完成了,然后第二個then
什么也沒得到,其實是得到了一個undefined
,undefined * 2
得到NaN
,所以打印NaN
。
為啥第一個then
的回調用return promise對象,第二個then
就可以等那2秒的延遲呢?用上段文字就很好理解了,而且你還可以回憶一下本文最上方說的promise對象的超能力,當你給then
返回一個非promise對象,then
只接收同步的返回值,反之,當你給then
返回一個promise對象,那么then
就等待promise對象生成,然后等resolve
和reject
傳遞參數,等多久都能等。
這里要注意一下,我上段文字所說的“等待”,其實并不是等待,而是new一個promise對象的過程被js引擎視為同步任務執行,因此new出了promise對象,并return的過程,其實是同步代碼,then其實并不是在等待,而是非常自然的鏈式執行順序。
為什么第三個回調比第二個回調先執行了?因為第二個then
得到了undefined
之后,第三個then就開始了,第三個回調延遲的時間短嘛,就1秒,所以比第二個回調先執行了。
為啥“第二個回調”下面輸出是6?因為3 * 2得6。
總之,如果想異步-異步-異步-異步......這樣一直搞下去,就只能是每一步都給下一步返回一個promise對象。
解答幾個問題:
如果
resolve
或reject
語句后面還寫了語句,會執行嗎?
會。resolve
或reject
負責傳參,但不是說傳了參就中止執行了。
如果第一個
then
的回調用了promise對象,但是promise對象沒寫resolve
或reject
方法,第二個then
的回調還會執行么?
答案是不執行,等于第二個then
白寫了,因為promise對象永遠處于Pending狀態。如果后面有第三個then
,依然不會執行。等于鏈條從第一個promise對象就斷了。
如果有
resolve
或reject
方法,但是不設參數,也就是resolve()
或reject()
,那么then
會執行嗎?
會。resolve
或reject
傳的參數是undefined
。
如果第一個
then
的第一個回調函數沒執行,第二個then
的第一個回調函數會執行么?
會。第二個then
的第一個回調,并不是前一個then
的第一個回調的繼承。
每個then
的2個回調,只可能有其中一個回調執行。下一個then
的第一個回調,只會看前一個then
的任意回調是否返回成功的promise;下一個then
的第二個回調,只會看前一個then
的任意回調是否返回失敗的promise。
如果promise只執行了
reject
方法,但第一個then
沒有寫對應的error處理回調,第二個then
寫了,還能處理么?
能。Promises規定,參數可以無限制的順著鏈條傳遞下去直到被處理掉。
如果流程是異步-同步-同步-同步.....這么走下去,那么搞一串
then
還有意義嗎?所有同步合在一起豈不是更容易編寫?也容易理解?
答案:如果真的是一串的同步,當然可以合并了。Promises的用武之地在于全異步或者異步-同步互相夾雜的情況。
then
的回調的優先級有多高?
測試一下:
console.log('sync1');
setTimeout(function() {console.log('setTimeout1')}, 0);
var promise = new Promise(function(resolve, reject) {
setTimeout(function() {console.log('setTimeoutPromise')}, 0);
console.log('promise');
resolve();
});
promise.then(function() {
setTimeout(function() {console.log('setTimeoutThen-1')}, 0);
console.log('then-1');
}).then(function() {
setTimeout(function() {console.log('setTimeoutThen-2')}, 0);
console.log('then-2');
});
setTimeout(function() {console.log('setTimeout2')}, 0);
console.log('sync2');
會得到
sync1
promise
sync2
then-1
then-2
setTimeout1
setTimeoutPromise
setTimeout2
setTimeoutThen-1
setTimeoutThen-2
這里就要科普一下ES的Event Loop,Event Loop簡單說就是ES為了高效解決異步任務而制定的一套規則,它的基本含義這里不講,可以自行網上搜索,也可以參考https://segmentfault.com/a/1190000016278115這篇文章,這里只摘抄結論:
ES的引擎里有2個隊列:
一個叫宏隊列,macrotask,也叫tasks。 一些異步任務的回調會依次進入macro task queue,等待后續被調用,這些異步任務包括:
- setTimeout
- setInterval
- setImmediate (Node獨有)
- requestAnimationFrame (瀏覽器獨有)
- I/O
- UI rendering (瀏覽器獨有)
另一個叫微隊列,microtask,也叫jobs。 另一些異步任務的回調會依次進入micro task queue,等待后續被調用,這些異步任務包括:
- process.nextTick (Node獨有)
- Promise
- Object.observe
- MutationObserver
那么,ES整個的任務隊列的執行機制就是:
- 執行全局Script同步代碼,這些同步代碼有一些是同步語句,有一些是異步語句(比如setTimeout等);
- 全局Script代碼執行完畢后,調用棧Stack會清空;
- 從微隊列microtask queue中取出位于隊首的回調任務,放入調用棧Stack中執行,執行完后microtask queue長度減1;
- 繼續取出位于隊首的任務,放入調用棧Stack中執行,以此類推,直到直到把microtask queue中的所有任務都執行完畢。注意,如果在執行microtask的過程中,又產生了microtask,那么會加入到隊列的末尾,也會在這個周期被調用執行;
- microtask queue中的所有任務都執行完畢,此時microtask queue為空隊列,調用棧Stack也為空;
- 取出宏隊列macrotask queue中位于隊首的任務,放入Stack中執行;
- 執行完畢后,調用棧Stack為空;
- 重復第3-7個步驟;
- 重復第3-7個步驟;
......
看到了吧,結論是什么?結論是:微隊列全部執行完,才執行宏隊列的第一個任務,執行完宏隊列的第一個任務之后,又去查看微隊列是否有任務,如果有,則全部執行,然后再看宏隊列的第一個任務。(宏隊列說:臥槽,歧視我?)
結合到現在的例子,new Promise()的內容是同步代碼,.then()是異步的,而且是微隊列的,優先級高,setTimeout屬于宏隊列的,優先級低。.then方法的回調里面的同步任務,優先級肯定比new Promise()外面的setTimeout任務的優先級高;而.then方法的回調里面的setTimeout任務,在宏隊列里面沒有排第一,所以優先級比new Promise()外面的setTimeout任務的優先級低,因為外面的setTimeout任務在宏隊列里排第一。
所以,從p對象賦值語句開始,JS引擎的執行順序是:
new Promise()內的同步任務
-> 外部下方的同步任務
-> .then鏈里的所有回調里的同步任務
-> 外部的宏隊列任務的回調,加上new Promise()內的宏隊列任務的回調,按回調時間依次執行,如果時間一致,按照書寫順序定
-> .then鏈里的所有回調里的回調任務
由此可以看出,如果在then鏈條之外還寫代碼的話,優先級會比較混亂,就像本文開頭說的,在then鏈條外面寫代碼不是一個好主意,應該由new Promise()和then鏈條統一管理本次需要執行的所有代碼,否則優先級不容易把控。
Promise.prototype.catch()
.catch()
是什么?它是.then()
的一個子集,也就是說專用于接收promise對象的reject()
傳過來的error參數的。其他沒什么特別的。也就是說,.then()
可以有兩個回調函數,.catch()
只有一個回調函數。永遠盡量在能用.catch()
的場合全用.catch()
。
.catch()
可以鏈式調用么?
可以。
可以跟
.then()
混合鏈式么?
可以。
如果上一層的
.then()
沒有reject,.catch()
會執行嗎?
不會,會被JS引擎跳過。
.catch()
下一層如果是.then()
,會執行嗎?
會。
參數怎么傳遞?
這個then接收的參數是上一個then傳遞的值,跟上一層的catch無關。也就是說,引擎跳過不執行的代碼,該怎么傳遞就怎么傳遞。
連寫
.catch()
和.then(//只寫一個回調)
是并列關系么?
絕對不是,是執行的前后關系。從來沒有什么并列關系,只有連續的或者跳躍的前后關系。連寫.then(//只寫一個回調)
和.catch()
也不是并列關系。
Promise.all()
Promise.all方法用于將多個promise實例,包裝成一個新的promise實例。
Promise.all()方法接受一個數組作為參數,p1、p2、p3都是Promise對象的實例,如果不是,就會先調用下面講到的Promise.resolve方法,將參數轉為Promise實例,再進一步處理。(Promise.all()方法的參數可以不是數組,但必須具有Iterator接口,且返回的每個成員都是Promise實例。)
p的狀態由p1、p2、p3決定,分成兩種情況。
- 只有p1、p2、p3的狀態都變成fulfilled,p的狀態才會變成fulfilled,此時p1、p2、p3的返回值組成一個數組,傳遞給p的回調函數。
注意,如果p1的最后一個then的回調沒有return命令,那么p1的返回值就是undefined,即使倒數第二個then的回調有返回值也沒有用,只看最后一個then的回調的返回值。
再注意,組成的數組的順序是按照.all()參數的書寫順序而定,跟誰先返回值無關。 - 只要p1、p2、p3之中有一個被rejected,p的狀態就變成rejected,此時第一個被reject的實例的返回值,會傳遞給p的回調函數。
下面是一個具體的例子。
var p1 = new Promise(function(resolve, reject) {
setTimeout(function() {
console.log('第一個回調');
reject(3);
}, 3000);
});
var p2 = new Promise(function(resolve, reject) {
setTimeout(function() {
console.log('第二個回調');
reject(2);
}, 2000);
});
Promise.all([p1, p2]).catch(function(value) {
return new Promise(function(resolve, reject) {
setTimeout(function() {
console.log('第三個回調');
resolve(value * 2);
console.log(value * 2);
}, 2000);
});
}).then(function(value) {
setTimeout(function() {
console.log('第四個回調');
console.log(value * 2);
}, 1000);
});
結果是下圖。由于p1和p2都rejected,所以catch捕獲的參數是p2傳過來的,因為p2的延遲比p1短。
如果p1的回調是同步任務,p2是異步任務,毫無疑問,catch捕獲的參數會是p1傳過來的,但是,通常肯定不這么用,這么寫太蠢了。如果p1和p2都是同步任務?更蠢,那么你干嘛不把p1和p2寫到一起呢?而且,根本不需要Promises寫法。
如果p1和p2都fulfilled,那么value
是一個數組。不代碼舉例了。
總結:Promise.all()方法的適用場合,是多個異步任務并發執行,在最后一個任務成功完成之后,給出一個回調。比如,并發10個xhr線程,傳10個文件,你并不知道哪個文件會先傳完,Promise.all()方法能確保在10個文件都傳完的那一刻給出完成提示。
如果不用Promise.all()方法,通常做法是設一個計數器初始值為0,每上傳成功一個文件就+1,然后判斷一次,看看計數器是否等于10,如果等于的話,就給出完成提示。最終相當于判斷10次。
Promise.race()
Promise.race方法同樣是將多個Promise實例,包裝成一個新的Promise實例。
var p = Promise.race([p1,p2,p3]);
上面代碼中,只要p1、p2、p3之中有一個實例率先改變狀態,p的狀態就跟著改變。那個率先改變的Promise實例的返回值,就傳遞給p的回調函數。
注意,所謂率先改變狀態,可能會改成fulfilled,也可能會改成rejected,都可以。
Promise.race方法的參數與Promise.all方法一樣,如果不是Promise實例,就會先調用下面講到的Promise.resolve方法,將參數轉為Promise實例,再進一步處理。
Promise.race的使用場合不算常見,比如一款小游戲,三輛賽車比賽,任何一個車先到達終點,比賽就結束,那么可以適用于Promise.race。或者是一個躲開障礙的游戲,任何一個障礙物撞到你,游戲就結束,那么可以適用于Promise.race。
還一個場合是超時判定。比如一個ajax請求,30秒下載不下來就算失敗。這樣,p1是ajax請求,p2是30秒計數器,誰先完成,p的狀態就隨誰。那個率先改變的Promise實例的返回值,就傳遞給p的回調函數。
Promise.resolve()
有時需要將現有對象轉為Promise對象,Promise.resolve方法就起到這個作用。
var jsPromise = Promise.resolve($.ajax('/whatever.json'));
上面代碼將jQuery生成的deferred對象,轉為一個新的Promise對象。
Promise.resolve方法的參數分成四種情況。
(1)參數是一個Promise實例
如果參數是Promise實例,那么Promise.resolve將不做任何修改、原封不動地返回這個實例。
(2)參數是一個thenable對象
thenable對象指的是具有then
方法的對象,比如下面這個對象。
var thenable = {
then: function(resolve, reject) {
resolve(42);
}
};
var jsPromise = Promise.resolve(thenable);
jsPromise.then(function(value) {
console.log(value); // 42
});
(3)參數不是具有then方法的對象,或根本就不是對象
如果參數是一個原始值,或者是一個不具有then方法的對象,則Promise.resolve方法返回一個新的Promise對象,狀態為Resolved,實例p向then
的回調函數傳的參數就是那個原始值。
var p = Promise.resolve('Hello');
p.then(function (s){
console.log(s) // Hello
});
上面代碼生成一個新的Promise對象的實例p。由于字符串Hello不屬于異步操作(判斷方法是它不是具有then方法的對象),返回Promise實例的狀態從一生成就是Resolved,所以回調函數會立即執行。Promise.resolve()方法的參數,會同時傳給回調函數。
(4)不帶有任何參數
Promise.resolve()方法允許調用時不帶參數,直接返回一個Resolved狀態的Promise對象。實例p向then
的回調函數傳的參數是undefined
。
所以,如果希望得到一個Promise對象,比較方便的方法就是直接調用Promise.resolve()方法。
var p = Promise.resolve();
p.then(function (s){
console.log(1)
});
console.log(2);
上面代碼的變量p就是一個Promise對象。
Promise.reject()
Promise.reject()方法也會返回一個新的promise實例,該實例的狀態為rejected
。它的參數用法與Promise.resolve()方法完全一致。
最佳實踐
現在Promises規范全部介紹完了。然后就是最佳實踐。
參考文檔:
本文作者就是我,簡書的microkof。如果您覺得本文對您的工作有意義,產生了不可估量的價值,那么請您不吝打賞我,謝謝!