感謝社區中各位的大力支持,譯者再次奉上一點點福利:阿里云產品券,享受所有官網優惠,并抽取幸運大獎:點擊這里領取
錯誤處理
我們已經看過幾個例子,Promise拒絕——既可以通過有意調用reject(..)
,也可以通過意外的JS異常——是如何在異步編程中允許清晰的錯誤處理的。讓我們兜個圈子回去,將我們一帶而過的一些細節弄清楚。
對大多數開發者來說,最自然的錯誤處理形式是同步的try..catch
結構。不幸的是,它僅能用于同步狀態,所以在異步代碼模式中它幫不上什么忙:
function foo() {
setTimeout( function(){
baz.bar();
}, 100 );
}
try {
foo();
// 稍后會從`baz.bar()`拋出全局錯誤
}
catch (err) {
// 永遠不會到這里
}
能有try..catch
當然很好,但除非有某些附加的環境支持,它無法與異步操作一起工作。我們將會在第四章中討論generator時回到這個話題。
在回調中,對于錯誤處理的模式已經有了一些新興的模式,最有名的就是“錯誤優先回調”風格:
function foo(cb) {
setTimeout( function(){
try {
var x = baz.bar();
cb( null, x ); // 成功!
}
catch (err) {
cb( err );
}
}, 100 );
}
foo( function(err,val){
if (err) {
console.error( err ); // 倒霉 :(
}
else {
console.log( val );
}
} );
注意: 這里的try..catch
僅在baz.bar()
調用立即地,同步地成功或失敗時才能工作。如果baz.bar()
本身是一個異步完成的函數,它內部的任何異步錯誤都不能被捕獲。
我們傳遞給foo(..)
的回調期望通過預留的err
參數收到一個表示錯誤的信號。如果存在,就假定出錯。如果不存在,就假定成功。
這類錯誤處理在技術上是 異步兼容的,但它根本組織的不好。用無處不在的if
語句檢查將多層錯誤優先回調編織在一起,將不可避免地將你置于回調地獄的危險之中(見第二章)。
那么我們回到Promise的錯誤處理,使用傳遞給then(..)
的拒絕處理器。Promise不使用流行的“錯誤優先回調”設計風格,反而使用“分割回調”的風格;一個回調給完成,一個回調給拒絕:
var p = Promise.reject( "Oops" );
p.then(
function fulfilled(){
// 永遠不會到這里
},
function rejected(err){
console.log( err ); // "Oops"
}
);
雖然這種模式表面上看起來十分有道理,但是Promise錯誤處理的微妙之處經常使它有點兒相當難以全面把握。
考慮下面的代碼:
var p = Promise.resolve( 42 );
p.then(
function fulfilled(msg){
// 數字沒有字符串方法,
// 所以這里拋出一個錯誤
console.log( msg.toLowerCase() );
},
function rejected(err){
// 永遠不會到這里
}
);
如果msg.toLowerCase()
合法地拋出一個錯誤(它會的!),為什么我們的錯誤處理器沒有得到通知?正如我們早先解釋的,這是因為 這個 錯誤處理器是為p
promise準備的,也就是已經被值42
完成的那個promise。p
promise是不可變的,所以唯一可以得到錯誤通知的promise是由p.then(..)
返回的那個,而在這里我們沒有捕獲它。
這應當解釋了:為什么Promise的錯誤處理是易錯的。錯誤太容易被吞掉了,而這很少是你有意這么做的。
警告: 如果你以一種不合法的方式使用Promise API,而且有錯誤阻止正常的Promise構建,其結果將是一個立即被拋出的異常,而不是一個拒絕Promise。這是一些導致Promise構建失敗的錯誤用法:new Promise(null)
,Promise.all()
,Promise.race(42)
等等。如果你沒有足夠合法地使用Promise API來首先實際構建一個Promise,你就不能得到一個拒絕Promise!
絕望的深淵
幾年前Jeff Atwood曾經寫到:編程語言總是默認地以這樣的方式建立,開發者們會掉入“絕望的深淵”(http://blog.codinghorror.com/falling-into-the-pit-of-success/ )——在這里意外會被懲罰——而你不得不更努力地使它正確。他懇求我們相反地創建“成功的深淵”,就是你會默認地掉入期望的(成功的)行為,而如此你不得不更努力地去失敗。
毫無疑問,Promise的錯誤處理是一種“絕望的深淵”的設計。默認情況下,它假定你想讓所有的錯誤都被Promise的狀態吞掉,而且如果你忘記監聽這個狀態,錯誤就會默默地凋零/死去——通常是絕望的。
為了回避把一個被遺忘/拋棄的Promise的錯誤無聲地丟失,一些開發者宣稱Promise鏈的“最佳實踐”是,總是將你的鏈條以catch(..)
終結,就像這樣:
var p = Promise.resolve( 42 );
p.then(
function fulfilled(msg){
// 數字沒有字符串方法,
// 所以這里拋出一個錯誤
console.log( msg.toLowerCase() );
}
)
.catch( handleErrors );
因為我們沒有給then(..)
傳遞拒絕處理器,默認的處理器會頂替上來,它僅僅簡單地將錯誤傳播到鏈條的下一個promise中。如此,在p
中發生的錯誤,與在p
之后的解析中(比如msg.toLowerCase()
)發生的錯誤都將會過濾到最后的handleErrors(..)
中。
問題解決了,對吧?沒那么容易!
要是handleErrors(..)
本身也有錯誤呢?誰來捕獲它?這里還有一個沒人注意的promise:catch(..)
返回的promise,我們沒有對它進行捕獲,也沒注冊拒絕處理器。
你不能僅僅將另一個catch(..)
貼在鏈條末尾,因為它也可能失敗。Promise鏈的最后一步,無論它是什么,總有可能,即便這種可能性逐漸減少,懸掛著一個困在未被監聽的Promise中的,未被捕獲的錯誤。
聽起來像一個不可解的迷吧?
處理未被捕獲的錯誤
這不是一個很容易就能完全解決的問題。但是有些接近于解決的方法,或者說 更好的方法。
一些Promise庫有一些附加的方法,可以注冊某些類似于“全局的未處理拒絕”的處理器,全局上不會拋出錯誤,而是調用它。但是他們識別一個錯誤是“未被捕獲的錯誤”的方案是,使用一個任意長的計時器,比如說3秒,從拒絕的那一刻開始計時。如果一個Promise被拒絕但沒有錯誤處理在計時器被觸發前注冊,那么它就假定你不會注冊監聽器了,所以它是“未被捕獲的”。
實踐中,這個方法在許多庫中工作的很好,因為大多數用法不會在Promise拒絕和監聽這個拒絕之間有很明顯的延遲。但是這個模式有點兒麻煩,因為3秒實在太隨意了(即便它是實證過的),還因為確實有些情況你想讓一個Promise在一段不確定的時間內持有它的拒絕狀態,而且你不希望你的“未捕獲錯誤”處理器因為這些誤報(還沒處理的“未捕獲錯誤”)而被調用。
另一種常見的建議是,Promise應當增加一個done(..)
方法,它實質上標志著Promise鏈的“終結”。done(..)
不會創建并返回一個Promise,所以傳遞給done(..)
的回調很明顯地不會鏈接上一個不存在的Promise鏈,并向它報告問題。
那么接下來會發什么?正如你通常在未處理錯誤狀態下希望的那樣,在done(..)
的拒絕處理器內部的任何異常都作為全局的未捕獲錯誤拋出(基本上扔到開發者控制臺):
var p = Promise.resolve( 42 );
p.then(
function fulfilled(msg){
// 數字沒有字符串方法,
// 所以這里拋出一個錯誤
console.log( msg.toLowerCase() );
}
)
.done( null, handleErrors );
// 如果`handleErrors(..)`自身發生異常,它會在這里被拋出到全局
這聽起來要比永不終結的鏈條或隨意的超時要吸引人。但最大的問題是,它不是ES6標準,所以不管聽起來多么好,它成為一個可靠而普遍的解決方案還有很長的距離。
那我們就卡在這里了?不完全是。
瀏覽器有一個我們的代碼沒有的能力:它們可以追蹤并確定一個對象什么時候被廢棄并可以作為垃圾回收。所以,瀏覽器可以追蹤Promise對象,當它們被當做垃圾回收時,如果在它們內部存在一個拒絕狀態,瀏覽器就可以確信這是一個合法的“未捕獲錯誤”,它可以信心十足地知道應當在開發者控制臺上報告這一情況。
注意: 在寫作本書的時候,Chrome和Firefox都早已試圖實現這種“未捕獲拒絕”的能力,雖然至多也就是支持的不完整。
然而,如果一個Promise不被垃圾回收——通過許多不同的代碼模式,這極其容易不經意地發生——瀏覽器的垃圾回收檢測不會幫你知道或診斷你有一個拒絕的Promise靜靜地躺在附近。
還有其他選項嗎?有。
成功的深淵
以下講的僅僅是理論上,Promise 可能 在某一天變成什么樣的行為。我相信那會比我們現在擁有的優越許多。而且我想這種改變可能會發生在后ES6時代,因為我不認為它會破壞Web的兼容性。另外,如果你小心行事,它是可以被填補(polyfilled)/預填補(prollyfilled)的。讓我們來看一下:
- Promise可以默認為是報告(向開發者控制臺)一切拒絕的,就在下一個Job或事件輪詢tick,如果就在這時Promise上沒有注冊任何錯誤處理器。
- 如果你希望拒絕的Promise在被監聽前,將其拒絕狀態保持一段不確定的時間。你可以調用
defer()
,它會壓制這個Promise自動報告錯誤。
如果一個Promise被拒絕,默認地它會吵吵鬧鬧地向開發者控制臺報告這個情況(而不是默認不出聲)。你既可以選擇隱式地處理這個報告(通過在拒絕之前注冊錯誤處理器),也可以選擇明確地處理這個報告(使用defer()
)。無論哪種情況,你 都控制著這種誤報。
考慮下面的代碼:
var p = Promise.reject( "Oops" ).defer();
// `foo(..)`返回Promise
foo( 42 )
.then(
function fulfilled(){
return p;
},
function rejected(err){
// 處理`foo(..)`的錯誤
}
);
...
我們創建了p
,我們知道我們會為了使用/監聽它的拒絕而等待一會兒,所以我們調用defer()
——如此就不會有全局的報告。defer()
單純地返回同一個promise,為了鏈接的目的。
從foo(..)
返回的promise 當即 就添附了一個錯誤處理器,所以這隱含地跳出了默認行為,而且不會有全局的關于錯誤的報告。
但是從then(..)
調用返回的promise沒有defer()
或添附錯誤處理器,所以如果它被拒絕(從它內部的任意一個解析處理器中),那么它就會向開發者控制臺報告一個未捕獲錯誤。
這種設計稱為成功的深淵。默認情況下,所有的錯誤不是被處理就是被報告——這幾乎是所有開發者在幾乎所有情況下所期望的。你要么不得不注冊一個監聽器,要么不得不有意什么都不做,并指示你要將錯誤處理推遲到 稍后;你僅為這種特定情況選擇承擔額外的責任。
這種方式唯一真正的危險是,你defer()
了一個Promise但是實際上沒有監聽/處理它的拒絕。
但你不得不有意地調用defer()
來選擇進入絕望深淵——默認是成功深淵——所以對于從你自己的錯誤中拯救你這件事來說,我們能做的不多。
我覺得對于Promise的錯誤處理還有希望(在后ES6時代)。我希望上層人物將會重新思考這種情況并考慮選用這種方式。同時,你可以自己實現這種方式(給讀者們的挑戰練習!),或使用一個 聰明 的Promise庫來為你這么做。
注意: 這種錯誤處理/報告的確切的模型已經在我的 asynquence Promise抽象庫中實現,我們會在本書的附錄A中討論它。
Promise模式
我們已經隱含地看到了使用Promise鏈的順序模式(這個-然后-這個-然后-那個的流程控制),但是我們還可以在Promise的基礎上抽象出許多其他種類的異步模式。這些模式用于簡化異步流程控制的的表達——它可以使我們的代碼更易于推理并且更易于維護——即便是我們程序中最復雜的部分。
有兩個這樣的模式被直接編碼在ES6原生的Promise
實現中,所以我們免費的得到了它們,來作為我們其他模式的構建塊兒。
Promise.all([ .. ])
在一個異步序列(Promise鏈)中,在任何給定的時刻都只有一個異步任務在被協調——第2步嚴格地接著第1步,而第3步嚴格地接著第2步。但要是并發(也叫“并行地”)地去做兩個或以上的步驟呢?
用經典的編程術語,一個“門(gate)”是一種等待兩個或更多并行/并發任務都執行完再繼續的機制。它們完成的順序無關緊要,只是它們不得不都完成才能讓門打開,繼而讓流程控制通過。
在Promise API中,我們稱這種模式為all([ .. ])
。
比方說你想同時發起兩個Ajax請求,在發起第三個Ajax請求發起之前,等待它們都完成,而不管它們的順序。考慮這段代碼:
// `request(..)`是一個兼容Promise的Ajax工具
// 就像我們在本章早前定義的
var p1 = request( "http://some.url.1/" );
var p2 = request( "http://some.url.2/" );
Promise.all( [p1,p2] )
.then( function(msgs){
// `p1`和`p2`都已完成,這里將它們的消息傳入
return request(
"http://some.url.3/?v=" + msgs.join(",")
);
} )
.then( function(msg){
console.log( msg );
} );
Promise.all([ .. ])
期待一個單獨的參數,一個array
,一般由Promise的實例組成。從Promise.all([ .. ])
返回的promise將會收到完成的消息(在這段代碼中是msgs
),它是一個由所有被傳入的promise的完成消息按照被傳入的順序構成的array
(與完成的順序無關)。
注意: 技術上講,被傳入Promise.all([ .. ])
的array
的值可以包括Promise,thenable,甚至是立即值。這個列表中的每一個值都實質上通過Promise.resolve(..)
來確保它是一個可以被等待的純粹的Promise,所以一個立即值將被范化為這個值的一個Promise。如果這個array
是空的,主Promise將會立即完成。
從Promise.resolve(..)
返回的主Promise將會在所有組成它的promise完成之后才會被完成。如果其中任意一個promise被拒絕,Promise.all([ .. ])
的主Promise將立即被拒絕,并放棄所有其他promise的結果。
要記得總是給每個promise添加拒絕/錯誤處理器,即使和特別是那個從Promise.all([ .. ])
返回的promise。
Promise.race([ .. ])
雖然Promise.all([ .. ])
并發地協調多個Promise并假定它們都需要被完成,但是有時候你只想應答“沖過終點的第一個Promise”,而讓其他的Promise被丟棄。
這種模式經典地被稱為“閂”,但在Promise中它被稱為一個“競合(race)”。
警告: 雖然“只有第一個沖過終點的算贏”是一個非常合適被比喻,但不幸的是“競合(race)”是一個被占用的詞,因為“競合狀態(race conditions)”通常被認為是程序中的Bug(見第一章)。不要把Promise.race([ .. ])
與“競合狀態(race conditions)”搞混了。
“競合狀態(race conditions)”也期待一個單獨的array
參數,含有一個或多個Promise,thenable,或立即值。與立即值進行競合并沒有多大實際意義,因為很明顯列表中的第一個會勝出——就像賽跑時有一個選手在終點線上起跑!
和Promise.all([ .. ])
相似,Promise.race([ .. ])
將會在任意一個Promise解析為完成時完成,而且它會在任意一個Promise解析為拒絕時拒絕。
注意: 一個“競合(race)”需要至少一個“選手”,所以如果你傳入一個空的array
,race([..])
的主Promise將不會立即解析,反而是永遠不會被解析。這是砸自己的腳!ES6應當將它規范為要么完成,要么拒絕,或者要么拋出某種同步錯誤。不幸的是,因為在ES6的Promise
之前的Promise庫的優先權高,他們不得不把這個坑留在這兒,所以要小心絕不要傳入一個空array
。
讓我們重溫剛才的并發Ajax的例子,但是在p1
和p2
競合的環境下:
// `request(..)`是一個兼容Promise的Ajax工具
// 就像我們在本章早前定義的
var p1 = request( "http://some.url.1/" );
var p2 = request( "http://some.url.2/" );
Promise.race( [p1,p2] )
.then( function(msg){
// `p1`或`p2`會贏得競合
return request(
"http://some.url.3/?v=" + msg
);
} )
.then( function(msg){
console.log( msg );
} );
因為只有一個Promise會勝出,所以完成的值是一個單獨的消息,而不是一個像Promise.all([ .. ])
中那樣的array
。
超時競合
我們早先看過這個例子,描述Promise.race([ .. ])
如何能夠用于表達“promise超時”模式:
// `foo()`是一個兼容Promise
// `timeoutPromise(..)`在早前定義過,
// 返回一個在指定延遲之后會被拒絕的Promise
// 為`foo()`設置一個超時
Promise.race( [
foo(), // 嘗試`foo()`
timeoutPromise( 3000 ) // 給它3秒鐘
] )
.then(
function(){
// `foo(..)`及時地完成了!
},
function(err){
// `foo()`要么是被拒絕了,要么就是沒有及時完成
// 可以考察`err`來知道是哪一個原因
}
);
這種超時模式在絕大多數情況下工作的很好。但這里有一些微妙的細節要考慮,而且坦率的說它們對于Promise.race([ .. ])
和Promise.all([ .. ])
都同樣需要考慮。
"Finally"
要問的關鍵問題是,“那些被丟棄/忽略的promise發生了什么?”我們不是從性能的角度在問這個問題——它們通常最終會變成垃圾回收的合法對象——而是從行為的角度(副作用等等)。Promise不能被取消——而且不應當被取消,因為那會摧毀本章稍后的“Promise不可取消”一節中要討論的外部不可變性——所以它們只能被無聲地忽略。
但如果前面例子中的foo()
占用了某些資源,但超時首先觸發而且導致這個promise被忽略了呢?這種模式中存在某種東西可以在超時后主動釋放被占用的資源,或者取消任何它可能帶來的副作用嗎?要是你想做的全部只是記錄下foo()
超時的事實呢?
一些開發者提議,Promise需要一個finally(..)
回調注冊機制,它總是在Promise解析時被調用,而且允許你制定任何可能的清理操作。在當前的語言規范中它還不存在,但它可能會在ES7+中加入。我們不得不邊走邊看了。
它看起來可能是這樣:
var p = Promise.resolve( 42 );
p.then( something )
.finally( cleanup )
.then( another )
.finally( cleanup );
注意: 在各種Promise庫中,finally(..)
依然會創建并返回一個新的Promise(為了使鏈條延續下去)。如果cleanup(..)
函數返回一個Promise,它將會鏈入鏈條,這意味著你可能還有我們剛才討論的未處理拒絕的問題。
同時,我們可以制造一個靜態的幫助工具來讓我們觀察(但不干涉)Promise的解析:
// 填補的安全檢查
if (!Promise.observe) {
Promise.observe = function(pr,cb) {
// 從側面觀察`pr`的解析
pr.then(
function fulfilled(msg){
// 異步安排回調(作為Job)
Promise.resolve( msg ).then( cb );
},
function rejected(err){
// 異步安排回調(作為Job)
Promise.resolve( err ).then( cb );
}
);
// 返回原本的promise
return pr;
};
}
這是我們在前面的超時例子中如何使用它:
Promise.race( [
Promise.observe(
foo(), // 嘗試`foo()`
function cleanup(msg){
// 在`foo()`之后進行清理,即便它沒有及時完成
}
),
timeoutPromise( 3000 ) // 給它3秒鐘
] )
這個Promise.observe(..)
幫助工具只是描述你如何在不干擾Promise的情況下觀測它的完成。其他的Promise庫有他們自己的解決方案。不論你怎么做,你都將很可能有個地方想用來確認你的Promise沒有意外地被無聲地忽略掉。
all([ .. ]) 與 race([ .. ]) 的變種
原生的ES6Promise帶有內建的Promise.all([ .. ])
和Promise.race([ .. ])
,這里還有幾個關于這些語義的其他常用的變種模式:
-
none([ .. ])
很像all([ .. ])
,但是完成和拒絕被轉置了。所有的Promise都需要被拒絕——拒絕變成了完成值,反之亦然。 -
any([ .. ])
很像all([ .. ])
,但它忽略任何拒絕,所以只有一個需要完成即可,而不是它們所有的。 -
first([ .. ])
像是一個帶有any([ .. ])
的競合,它忽略任何拒絕,而且一旦有一個Promise完成時,它就立即完成。 -
last([ .. ])
很像first([ .. ])
,但是只有最后一個完成勝出。
某些Promise抽象工具庫提供這些方法,但你也可以用Promise機制的race([ .. ])
和all([ .. ])
,自己定義他們。
比如,這是我們如何定義first([..])
:
// 填補的安全檢查
if (!Promise.first) {
Promise.first = function(prs) {
return new Promise( function(resolve,reject){
// 迭代所有的promise
prs.forEach( function(pr){
// 泛化它的值
Promise.resolve( pr )
// 無論哪一個首先成功完成,都由它來解析主promise
.then( resolve );
} );
} );
};
}
注意: 這個first(..)
的實現不會在它所有的promise都被拒絕時拒絕;它會簡單地掛起,很像Promise.race([])
。如果需要,你可以添加一些附加邏輯來追蹤每個promise的拒絕,而且如果所有的都被拒絕,就在主promise上調用reject()
。我們將此作為練習留給讀者。
并發迭代
有時候你想迭代一個Promise的列表,并對它們所有都實施一些任務,就像你可以對同步的array
做的那樣(比如,forEach(..)
,map(..)
,some(..)
,和every(..)
)。如果對每個Promise實施的操作根本上是同步的,它們工作的很好,正如我們在前面的代碼段中用過的forEach(..)
。
但如果任務在根本上是異步的,或者可以/應當并發地實施,你可以使用許多庫提供的異步版本的這些工具方法。
比如,讓我們考慮一個異步的map(..)
工具,它接收一個array
值(可以是Promise或任何東西),外加一個對數組中每一個值實施的函數(任務)。map(..)
本身返回一個promise,它的完成值是一個持有每個任務的異步完成值的array
(以與映射(mapping)相同的順序):
if (!Promise.map) {
Promise.map = function(vals,cb) {
// 一個等待所有被映射的promise的新promise
return Promise.all(
// 注意:普通的數組`map(..)`,
// 將值的數組變為promise的數組
vals.map( function(val){
// 將`val`替換為一個在`val`
// 異步映射完成后才解析的新promise
return new Promise( function(resolve){
cb( val, resolve );
} );
} )
);
};
}
注意: 在這種map(..)
的實現中,你無法表示異步拒絕,但如果一個在映射的回調內部發生一個同步的異常/錯誤,那么Promise.map(..)
返回的主Promise就會拒絕。
讓我們描繪一下對一組Promise(不是簡單的值)使用map(..)
:
var p1 = Promise.resolve( 21 );
var p2 = Promise.resolve( 42 );
var p3 = Promise.reject( "Oops" );
// 將列表中的值翻倍,即便它們在Promise中
Promise.map( [p1,p2,p3], function(pr,done){
// 確保列表中每一個值都是Promise
Promise.resolve( pr )
.then(
// 將值作為`v`抽取出來
function(v){
// 將完成的`v`映射到新的值
done( v * 2 );
},
// 或者,映射到promise的拒絕消息上
done
);
} )
.then( function(vals){
console.log( vals ); // [42,84,"Oops"]
} );
Promise API概覽
讓我們復習一下我們已經在本章中零散地展開的ES6Promise
API。
注意: 下面的API盡管在ES6中是原生的,但也存在一些語言規范兼容的填補(不光是擴展Promise庫),它們定義了Promise
和與之相關的所有行為,所以即使是在前ES6時代的瀏覽器中你也以使用原生的Promise。這類填補的其中之一是“Native Promise Only”(http://github.com/getify/native-promise-only),我寫的!
new Promise(..)構造器
揭示構造器(revealing constructor) Promise(..)
必須與new
一起使用,而且必須提供一個被同步/立即調用的回調函數。這個函數被傳入兩個回調函數,它們作為promise的解析能力。我們通常將它們標識為resolve(..)
和reject(..)
:
var p = new Promise( function(resolve,reject){
// `resolve(..)`給解析/完成的promise
// `reject(..)`給拒絕的promise
} );
reject(..)
簡單地拒絕promise,但是resolve(..)
既可以完成promise,也可以拒絕promise,這要看它被傳入什么值。如果resolve(..)
被傳入一個立即的,非Promise,非thenable的值,那么這個promise將用這個值完成。
但如果resolve(..)
被傳入一個Promise或者thenable的值,那么這個值將被遞歸地展開,而且無論它最終解析結果/狀態是什么,都將被promise采用。
Promise.resolve(..) 和 Promise.reject(..)
一個用于創建已被拒絕的Promise的簡便方法是Promise.reject(..)
,所以這兩個promise是等價的:
var p1 = new Promise( function(resolve,reject){
reject( "Oops" );
} );
var p2 = Promise.reject( "Oops" );
與Promise.reject(..)
相似,Promise.resolve(..)
通常用來創建一個已完成的Promise。然而,Promise.resolve(..)
還會展開thenale值(就像我們已經幾次討論過的)。在這種情況下,返回的Promise將會采用你傳入的thenable的解析,它既可能是完成,也可能是拒絕:
var fulfilledTh = {
then: function(cb) { cb( 42 ); }
};
var rejectedTh = {
then: function(cb,errCb) {
errCb( "Oops" );
}
};
var p1 = Promise.resolve( fulfilledTh );
var p2 = Promise.resolve( rejectedTh );
// `p1`將是一個完成的promise
// `p2`將是一個拒絕的promise
而且要記住,如果你傳入一個純粹的Promise,Promise.resolve(..)
不會做任何事情;它僅僅會直接返回這個值。所以在你不知道其本性的值上調用Promise.resolve(..)
不會有額外的開銷,如果它偶然已經是一個純粹的Promise。
then(..) 和 catch(..)
每個Promise實例(不是 Promise
API 名稱空間)都有then(..)
和catch(..)
方法,它們允許你為Promise注冊成功或拒絕處理器。一旦Promise被解析,它們中的一個就會被調用,但不是都會被調用,而且它們總是會被異步地調用(參見第一章的“Jobs”)。
then(..)
接收兩個參數,第一個用于完成回調,第二個用戶拒絕回調。如果它們其中之一被省略,或者被傳入一個非函數的值,那么一個默認的回調就會分別頂替上來。默認的完成回調簡單地將值向下傳遞,而默認的拒絕回調簡單地重新拋出(傳播)收到的拒絕理由。
catch(..)
僅僅接收一個拒絕回調作為參數,而且會自動的頂替一個默認的成功回調,就像我們討論過的。換句話說,它等價于then(null,..)
:
p.then( fulfilled );
p.then( fulfilled, rejected );
p.catch( rejected ); // 或者`p.then( null, rejected )`
then(..)
和catch(..)
也會創建并返回一個新的promise,它可以用來表達Promise鏈式流程控制。如果完成或拒絕回調有異常被拋出,這個返回的promise就會被拒絕。如果這兩個回調之一返回一個立即,非Promise,非thenable值,那么這個值就會作為被返回的promise的完成。如果完成處理器指定地返回一個promise或thenable值這個值就會被展開而且變成被返回的promise的解析。
Promise.all([ .. ]) 和 Promise.race([ .. ])
在ES6的Promise
API的靜態幫助方法Promise.all([ .. ])
和Promise.race([ .. ])
都創建一個Promise作為它們的返回值。這個promise的解析完全由你傳入的promise數組控制。
對于Promise.all([ .. ])
,為了被返回的promise完成,所有你傳入的promise都必須完成。如果其中任意一個被拒絕,返回的主promise也會立即被拒絕(丟棄其他所有promise的結果)。至于完成狀態,你會收到一個含有所有被傳入的promise的完成值的array
。至于拒絕狀態,你僅會收到第一個promise拒絕的理由值。這種模式通常稱為“門”:在門打開前所有人都必須到達。
對于Promise.race([ .. ])
,只有第一個解析(成功或拒絕)的promise會“勝出”,而且不論解析的結果是什么,都會成為被返回的promise的解析結果。這種模式通常成為“閂”:第一個打開門閂的人才能進來。考慮這段代碼:
var p1 = Promise.resolve( 42 );
var p2 = Promise.resolve( "Hello World" );
var p3 = Promise.reject( "Oops" );
Promise.race( [p1,p2,p3] )
.then( function(msg){
console.log( msg ); // 42
} );
Promise.all( [p1,p2,p3] )
.catch( function(err){
console.error( err ); // "Oops"
} );
Promise.all( [p1,p2] )
.then( function(msgs){
console.log( msgs ); // [42,"Hello World"]
} );
警告: 要小心!如果一個空的array
被傳入Promise.all([ .. ])
,它會立即完成,但Promise.race([ .. ])
卻會永遠掛起,永遠不會解析。
ES6的Promise
API十分簡單和直接。對服務于大多數基本的異步情況來說它足夠好了,而且當你要把你的代碼從回調地獄變為某些更好的東西時,它是一個開始的好地方。
但是依然還有許多應用程序所要求的精巧的異步處理,由于Promise本身所受的限制而不能解決。在下一節中,為了有效利用Promise庫,我們將深入檢視這些限制。
Promise限制
本節中我們將要討論的許多細節已經在這一章中被提及了,但我們將明確地復習這些限制。
順序的錯誤處理
我們在本章前面的部分詳細講解了Promise風格的錯誤處理。Promise的設計方式——特別是他們如何鏈接——所產生的限制,創建了一個非常容易掉進去的陷阱,Promise鏈中的錯誤會被意外地無聲地忽略掉。
但關于Promise的錯誤還有一些其他事情要考慮。因為Promise鏈只不過是將組成它的Promise連在一起,沒有一個實體可以用來將整個鏈條表達為一個單獨的 東西,這意味著沒有外部的方法能夠監聽可能發生的任何錯誤。
如果你構建一個不包含錯誤處理器的Promise鏈,這個鏈條的任意位置發生的任何錯誤都將沿著鏈條向下無限傳播,直到被監聽為止(通過在某一步上注冊拒絕處理器)。所以,在這種特定情況下,擁有鏈條的最后一個promise的引用就夠了(下面代碼段中的p
),因為你可以在這里注冊拒絕處理器,而且它會被所有傳播的錯誤通知:
// `foo(..)`, `STEP2(..)` 和 `STEP3(..)`
// 都是promise兼容的工具
var p = foo( 42 )
.then( STEP2 )
.then( STEP3 );
雖然這看起來有點兒小糊涂,但是這里的p
沒有指向鏈條中的第一個promise(foo(42)
調用中來的那一個),而是指向了最后一個promise,來自于then(STEP3)
調用的那一個。
另外,這個promise鏈條上看不到一個步驟做了自己的錯誤處理。這意味著你可以在p
上注冊一個拒絕處理器,如果在鏈條的任意位置發生了錯誤,它就會被通知。
p.catch( handleErrors );
但如果這個鏈條中的某一步事實上做了自己的錯誤處理(也許是隱藏/抽象出去了,所以你看不到),那么你的handleErrors(..)
就不會被通知。這可能是你想要的——它畢竟是一個“被處理過的拒絕”——但它也可能 不 是你想要的。完全缺乏被通知的能力(被“已處理過的”拒絕錯誤通知)是一個在某些用法中約束功能的一種限制。
它基本上和try..catch
中存在的限制是相同的,它可以捕獲一個異常并簡單地吞掉。所以這不是一個 Promise特有 的問題,但它確實是一個我們希望繞過的限制。
不幸的是,許多時候Promise鏈序列的中間步驟不會被留下引用,所以沒有這些引用,你就不能添加錯誤處理器來可靠地監聽錯誤。
單獨的值
根據定義,Promise只能有一個單獨的完成值或一個單獨的拒絕理由。在簡單的例子中,這沒什么大不了的,但在更精巧的場景下,你可能發現這個限制。
通常的建議是構建一個包裝值(比如object
或array
)來包含這些多個消息。這個方法好用,但是在你的Promise鏈的每一步上把消息包裝再拆開顯得十分尷尬和煩人。
分割值
有時你可以將這種情況當做一個信號,表示你可以/應當將問題拆分為兩個或更多的Promise。
想象你有一個工具foo(..)
,它異步地產生兩個值(x
和y
):
function getY(x) {
return new Promise( function(resolve,reject){
setTimeout( function(){
resolve( (3 * x) - 1 );
}, 100 );
} );
}
function foo(bar,baz) {
var x = bar * baz;
return getY( x )
.then( function(y){
// 將兩個值包裝近一個容器
return [x,y];
} );
}
foo( 10, 20 )
.then( function(msgs){
var x = msgs[0];
var y = msgs[1];
console.log( x, y ); // 200 599
} );
首先,讓我們重新安排一下foo(..)
返回的東西,以便于我們不必再將x
和y
包裝進一個單獨的array
值中來傳送給一個Promise。相反,我們將每一個值包裝進它自己的promise:
function foo(bar,baz) {
var x = bar * baz;
// 將兩個promise返回
return [
Promise.resolve( x ),
getY( x )
];
}
Promise.all(
foo( 10, 20 )
)
.then( function(msgs){
var x = msgs[0];
var y = msgs[1];
console.log( x, y );
} );
一個promise的array
真的要比傳遞給一個單獨的Promise的值的array
要好嗎?語法上,它沒有太多改進。
但是這種方式更加接近于Promise的設計原理。現在它更易于在未來將x
與y
的計算分開,重構進兩個分離的函數中。它更清晰,也允許調用端代碼更靈活地安排這兩個promise——這里使用了Promise.all([ .. ])
,但它當然不是唯一的選擇——而不是將這樣的細節在foo(..)
內部進行抽象。
展開/散開參數
var x = ..
和var y = ..
的賦值依然是一個尷尬的負擔。我們可以在一個幫助工具中利用一些函數式技巧(向Reginald Braithwaite致敬,在推特上 @raganwald ):
function spread(fn) {
return Function.apply.bind( fn, null );
}
Promise.all(
foo( 10, 20 )
)
.then(
spread( function(x,y){
console.log( x, y ); // 200 599
} )
)
看起來好些了!當然,你可以內聯這個函數式魔法來避免額外的幫助函數:
Promise.all(
foo( 10, 20 )
)
.then( Function.apply.bind(
function(x,y){
console.log( x, y ); // 200 599
},
null
) );
這個技巧可能很整潔,但是ES6給了我們一個更好的答案:解構(destructuring)。數組的解構賦值形式看起來像這樣:
Promise.all(
foo( 10, 20 )
)
.then( function(msgs){
var [x,y] = msgs;
console.log( x, y ); // 200 599
} );
最棒的是,ES6提供了數組參數解構形式:
Promise.all(
foo( 10, 20 )
)
.then( function([x,y]){
console.log( x, y ); // 200 599
} );
我們現在已經接受了“每個Promise一個值”的準則,繼續讓我們把模板代碼最小化!
注意: 更多關于ES6解構形式的信息,參閱本系列的 ES6與未來。
單次解析
Promise的一個最固有的行為之一就是,一個Promise只能被解析一次(成功或拒絕)。對于多數異步用例來說,你僅僅取用這個值一次,所以這工作的很好。
但也有許多異步情況適用于一個不同的模型——更類似于事件和/或數據流。表面上看不清Promise能對這種用例適應的多好,如果能的話。沒有基于Promise的重大抽象過程,它們完全缺乏對多個值解析的處理。
想象這樣一個場景,你可能想要為響應一個刺激(比如事件)觸發一系列異步處理步驟,而這實際上將會發生多次,比如按鈕點擊。
這可能不會像你想的那樣工作:
// `click(..)` 綁定了一個DOM元素的 `"click"` 事件
// `request(..)` 是先前定義的支持Promise的Ajax
var p = new Promise( function(resolve,reject){
click( "#mybtn", resolve );
} );
p.then( function(evt){
var btnID = evt.currentTarget.id;
return request( "http://some.url.1/?id=" + btnID );
} )
.then( function(text){
console.log( text );
} );
這里的行為僅能在你的應用程序只讓按鈕被點擊一次的情況下工作。如果按鈕被點擊第二次,promisep
已經被解析了,所以第二個resolve(..)
將被忽略。
相反的,你可能需要將模式反過來,在每次事件觸發時創建一個全新的Promise鏈:
click( "#mybtn", function(evt){
var btnID = evt.currentTarget.id;
request( "http://some.url.1/?id=" + btnID )
.then( function(text){
console.log( text );
} );
} );
這種方式會 好用,為每個按鈕上的"click"
事件發起一個全新的Promise序列。
但是除了在事件處理器內部定義一整套Promise鏈看起來很丑以外,這樣的設計在某種意義上違背了關注/能力分離原則(SoC)。你可能非常想在一個你的代碼不同的地方定義事件處理器:你定義對事件的 響應(Promise鏈)的地方。如果沒有幫助機制,在這種模式下這么做很尷尬。
注意: 這種限制的另一種表述方法是,如果我們能夠構建某種能在它上面進行Promise鏈監聽的“可監聽對象(observable)”就好了。有一些庫已經建立這些抽象(比如RxJS——http://rxjs.codeplex.com/),但是這種抽象看起來是如此的重,以至于你甚至再也看不到Promise的性質。這樣的重抽象帶來一個重要的問題:這些機制是否像Promise本身被設計的一樣 可靠。我們將會在附錄B中重新討論“觀察者(Observable)”模式。
惰性
對于在你的代碼中使用Promise而言一個實在的壁壘是,現存的所有代碼都沒有支持Promise。如果你有許多基于回調的代碼,讓代碼保持相同的風格容易多了。
“一段基于動作(用回調)的代碼將仍然基于動作(用回調),除非一個更聰明,具有Promise意識的開發者對它采取行動。”
Promise提供了一種不同的模式規范,如此,代碼的表達方式可能會變得有一點兒不同,某些情況下,則根本不同。你不得不有意這么做,因為Promise不僅只是把那些為你服務至今的老式編碼方法自然地抖落掉。
考慮一個像這樣的基于回調的場景:
function foo(x,y,cb) {
ajax(
"http://some.url.1/?x=" + x + "&y=" + y,
cb
);
}
foo( 11, 31, function(err,text) {
if (err) {
console.error( err );
}
else {
console.log( text );
}
} );
將這個基于回調的代碼轉換為支持Promise的代碼的第一步該怎么做,是立即明確的嗎?這要看你的經驗。你練習的越多,它就感覺越自然。但當然,Promise沒有明確告知到底怎么做——沒有一個放之四海而皆準的答案——所以這要靠你的責任心。
就像我們以前講過的,我們絕對需要一種支持Promise的Ajax工具來取代基于回調的工具,我們可以稱它為request(..)
。你可以制造自己的,正如我們已經做過的。但是不得不為每個基于回調的工具手動定義Promise相關的包裝器的負擔,使得你根本就不太可能選擇將代碼重構為Promise相關的。
Promise沒有為這種限制提供直接的答案。但是大多數Promise庫確實提供了幫助函數。想象一個這樣的幫助函數:
// 填補的安全檢查
if (!Promise.wrap) {
Promise.wrap = function(fn) {
return function() {
var args = [].slice.call( arguments );
return new Promise( function(resolve,reject){
fn.apply(
null,
args.concat( function(err,v){
if (err) {
reject( err );
}
else {
resolve( v );
}
} )
);
} );
};
};
}
好吧,這可不是一個微不足道的工具。然而,雖然他可能看起來有點兒令人生畏,但也沒有你想的那么糟。它接收一個函數,這個函數期望一個錯誤優先風格的回調作為第一個參數,然后返回一個可以自動創建Promise并返回的新函數,然后為你替換掉回調,與Promise的完成/拒絕連接在一起。
與其浪費太多時間談論這個Promise.wrap(..)
幫助函數 如何 工作,還不如讓我們來看看如何使用它:
var request = Promise.wrap( ajax );
request( "http://some.url.1/" )
.then( .. )
..
哇哦,真簡單!
Promise.wrap(..)
不會 生產Promise。它生產一個將會生產Promise的函數。某種意義上,一個Promise生產函數可以被看做一個“Promise工廠”。我提議將這樣的東西命名為“promisory”("Promise" + "factory")。
這種將期望回調的函數包裝為一個Promise相關的函數的行為,有時被稱為“提升(lifting)”或“promise化(promisifying)”。但是除了“提升過的函數”以外,看起來沒有一個標準的名詞來稱呼這個結果函數,所以我更喜歡“promisory”,因為我認為他更具描述性。
注意: Promisory不是一個瞎編的詞。它是一個真實存在的詞匯,而且它的定義是含有或載有一個promise。這正是這些函數所做的,所以這個術語匹配得簡直完美!
那么,Promise.wrap(ajax)
生產了一個我們稱為request(..)
的ajax(..)
promisory,而這個promisory為Ajax應答生產Promise。
如果所有的函數已經都是promisory,我們就不需要自己制造它們,所以額外的步驟就有點兒多余。但是至少包裝模式是(通常都是)可重復的,所以我們可以把它放進Promise.wrap(..)
幫助函數中來支援我們的promise編碼。
那么回到剛才的例子,我們需要為ajax(..)
和foo(..)
都做一個promisory。
// 為`ajax(..)`制造一個promisory
var request = Promise.wrap( ajax );
// 重構`foo(..)`,但是為了代碼其他部分
// 的兼容性暫且保持它對外是基于回調的
// ——僅在內部使用`request(..)`'的promise
function foo(x,y,cb) {
request(
"http://some.url.1/?x=" + x + "&y=" + y
)
.then(
function fulfilled(text){
cb( null, text );
},
cb
);
}
// 現在,為了這段代碼本來的目的,為`foo(..)`制造一個promisory
var betterFoo = Promise.wrap( foo );
// 并使用這個promisory
betterFoo( 11, 31 )
.then(
function fulfilled(text){
console.log( text );
},
function rejected(err){
console.error( err );
}
);
當然,雖然我們將foo(..)
重構為使用我們的新request(..)
promisory,我們可以將foo(..)
本身制成promisory,而不是保留基于會掉的實現并需要制造和使用后續的betterFoo(..)
promisory。這個決定只是要看foo(..)
是否需要保持基于回調的形式以便于代碼的其他部分兼容。
考慮這段代碼:
// 現在,`foo(..)`也是一個promisory
// 因為它委托到`request(..)` promisory
function foo(x,y) {
return request(
"http://some.url.1/?x=" + x + "&y=" + y
);
}
foo( 11, 31 )
.then( .. )
..
雖然ES6的Promise沒有為這樣的promisory包裝提供原生的幫助函數,但是大多數庫提供它們,或者你可以制造自己的。不管哪種方法,這種Promise特定的限制是可以不費太多勁兒就可以解決的(當然是和回調地獄的痛苦相比!)。
Promise不可撤銷
一旦你創建了一個Promise并給它注冊了一個完成和/或拒絕處理器,就沒有什么你可以從外部做的事情能停止這個進程,即使是某些其他的事情使這個任務變得毫無意義。
注意: 許多Promise抽象庫都提供取消Promise的功能,但這是一個非常壞的主意!許多開發者都希望Promise被原生地設計為具有外部取消能力,但問題是這將允許Promise的一個消費者/監聽器影響某些其他消費者監聽同一個Promise的能力。這違反了未來值得可靠性原則(外部不可變),另外就是嵌入了“遠距離行為(action at a distance)”的反模式(http://en.wikipedia.org/wiki/Action_at_a_distance_%28computer_programming%29)。不管它看起來多么有用,它實際上會直接將你引回與回調地獄相同的噩夢。
考慮我們早先的Promise超時場景:
var p = foo( 42 );
Promise.race( [
p,
timeoutPromise( 3000 )
] )
.then(
doSomething,
handleError
);
p.then( function(){
// 即使是在超時的情況下也會發生 :(
} );
“超時”對于promisep
來說是外部的,所以p
本身繼續運行,這可能不是我們想要的。
一個選項是侵入性地定義你的解析回調:
var OK = true;
var p = foo( 42 );
Promise.race( [
p,
timeoutPromise( 3000 )
.catch( function(err){
OK = false;
throw err;
} )
] )
.then(
doSomething,
handleError
);
p.then( function(){
if (OK) {
// 僅在沒有超時的情況下發生! :)
}
} );
這很難看。這可以工作,但是遠不理想。一般來說,你應當避免這樣的場景。
但是如果你不能,這種解決方案的丑陋應當是一個線索,說明 取消 是一種屬于在Promise之上的更高層抽象的功能。我推薦你找一個Promise抽象庫來輔助你,而不是自己使用黑科技。
注意: 我的 asynquence Promise抽象庫提供了這樣的抽象,還為序列提供了一個abort()
能力,這一切將在附錄A中討論。
一個單獨的Promise不是真正的流程控制機制(至少沒有多大實際意義),而流程控制機制正是 取消 要表達的;這就是為什么Promise取消顯得尷尬。
相比之下,一個鏈條的Promise集合在一起——我稱之為“序列”—— 是 一個流程控制的表達,如此在這一層面的抽象上它就適于定義取消。
沒有一個單獨的Promise應該是可以取消的,但是一個 序列 可以取消是有道理的,因為你不會將一個序列作為一個不可變值傳來傳去,就像Promise那樣。
Promise性能
這種限制既簡單又復雜。
比較一下在基于回調的異步任務鏈和Promise鏈上有多少東西在動,很明顯Promise有多得多的事情發生,這意味著它們自然地會更慢一點點。回想一下Promise提供的保證信任的簡單列表,將它和你為了達到相同保護效果而在回調上面添加的特殊代碼比較一下。
更多工作要做,更多的安全要保護,意味著Promise與赤裸裸的,不可靠的回調相比 確實 更慢。這些都很明顯,可能很容易縈繞在你腦海中。
但是慢多少?好吧……這實際上是一個難到不可思議的問題,無法絕對,全面地回答。
坦白地說,這是一個比較蘋果和橘子的問題,所以可能是問錯了。你實際上應當比較的是,帶有所有手動保護層的經過特殊處理的回調系統,是否比一個Promise實現要快。
如果說Promise有一種合理的性能限制,那就是它并不將可靠性保護的選項羅列出來讓你選擇——你總是一下得到全部。
如果我們承認Promise一般來說要比它的非Promise,不可靠的回調等價物 慢一點兒——假定在有些地方你覺得你可以自己調整可靠性的缺失——難道這意味著Promise應當被全面地避免,就好像你的整個應用程序僅僅由一些可能的“必須絕對最快”的代碼驅動著?
捫心自問:如果你的代碼有那么合理,那么 對于這樣的任務,JavaScript是正確的選擇嗎? 為了運行應用程序JavaScript可以被優化得十分高效(參見第五章和第六章)。但是在Promise提供的所有好處的光輝之下,過于沉迷它微小的性能權衡,真的 合適嗎?
另一個微妙的問題是Promise使 所有事情 都成為異步的,這意味著有些應當立即完成的(同步的)步驟也要推遲到下一個Job步驟中(參見第一章)。也就是說一個Promise任務序列要比使用回調連接的相同序列要完成的稍微慢一些是可能的。
當然,這里的問題是:這些關于性能的微小零頭的潛在疏忽,和我們在本章通篇闡述的Promise帶來的益處相比,還值得考慮嗎?
我的觀點是,在幾乎所有你可能認為Promise的性能慢到了需要被考慮的情況下,完全回避Promise并將它的可靠性和組合性優化掉,實際上一種反模式。
相反地,你應當默認地在代碼中廣泛使用它們,然后再記錄并分析你的應用程序的熱(關鍵)路徑。Promise 真的 是瓶頸?還是它們只是理論上慢了下來?只有在那 之后,拿著實際合法的基準分析觀測數據(參見第六章),再將Promise從這些關鍵區域中重構移除才稱得上是合理與謹慎。
Promise是有一點兒慢,但作為交換你得到了很多內建的可靠性,無Zalgo的可預測性,與組合性。也許真正的限制不是它們的性能,而是你對它們的益處缺乏認識?
復習
Promise很牛。用它們。它們解決了肆虐在回調代碼中的 控制倒轉 問題。
它們沒有擺脫回調,而是重新定向了這些回調的組織安排方式,是它成為一種坐落于我們和其他工具之間的可靠的中間機制。
Promise鏈還開始以順序的風格定義了一種更好的(當然,還不完美)表達異步流程的方式,它幫我們的大腦更好的規劃和維護異步JS代碼。我們會在下一章中看到一個更好的解決 這個 問題的方法!