異步和分塊——程序的分塊執行
一開始學習javascript的時候, 我對異步的概念一臉懵逼, 由於當時百度了不少文章,但不少各類文章不負責任的把籠統的描述混雜在一塊兒,讓我對這個 JS中的重要概念難以理解, 「異步是非阻塞的」, 「Ajax執行是異步的」, "異步用來處理耗時操做"....
全部人都再說這個是異步的,那個是異步的,異步會怎麼怎樣,可我仍是不知道:「異步究竟是什麼?」
後來我發現,其實理解異步最主要的一點,就是記住: 咱們的程序是分塊執行的。
分紅兩塊, 同步執行的湊一塊, 異步執行的湊一塊,搞完同步,再搞異步
廢話很少說, 直接上圖:
圖1
圖2
我對異步的另一個難以理解的點是異步/同步和阻塞/非阻塞的關係
人們常說: 「異步是非阻塞的」 , 但爲何異步是非阻塞的, 或者說, 異步和非阻塞又有什麼關係呢
非阻塞是對異步的要求, 異步是在「非阻塞」這一要求下的必然的解決方式
我們看看一個簡單的例子吧
ajax("http://XXX.", callback);
doOtherThing()
你確定知道ajax這個函數的調用是發出請求取得一些數據回來, 這可能須要至關長的一段時間(相比於其餘同步函數的調用)javascript
對啊,若是咱們全部代碼都是同步的,這就意味着, 在執行完ajax("http://XXX.", callback)這段代碼前, doOtherThing這個函數是不會執行的,在外表看起來, 咱們的程序不就「阻塞」在ajax("http://XXX.", callback);這個函數裏了麼? 這就是所謂的阻塞啊
讓咱們再想想doOtherThing由於「同步」形成「阻塞」的話會有多少麻煩: doOtherThing()裏面包含了這些東西: 這個簡略的函數表明了它你接下來頁面的全部的交互程序, 但你如今在ajax執行結束前,你都沒有辦法去doOtherThing,去作接下來全部的交互程序了。 在外觀上看來, 頁面將會處於一個「徹底假死」的狀態。
由於咱們要保證在大量ajax(或相似的耗時操做)的狀況下,交互能正常進行
因此同步是不行的
由於同步是不行的, 因此這一塊的處理, 不就都是異步的嘛
若是這樣還不太理解的話, 咱們反方向思考一下, 假設一個有趣的烏托邦場景: 假設ajax的執行能像一個同步執行的foreach函數的執行那樣迅速, javascript又何苦對它作一些異步處理呢? 就是由於它如此耗時, 因此javascript「審時度勢」, 拿出了「異步」的這一把刷子,來解決問題
正由於有「非阻塞」的剛需, javascript纔會對ajax等一律採用異步處理
「由於要非阻塞, 因此要異步」,這就是我我的對異步/同步和阻塞/非阻塞關係的理解
可能你沒有注意到,回調實際上是存在不少問題的
沒錯,接下來的畫風是這樣子的:
回調存在的問題
回調存在的問題可歸納爲兩類:
信任問題和控制反轉
可能你比較少意識到的一點是:咱們是沒法在主程序中掌控對回調的控制權的。
例如:
ajax( "..", function(..){ } );
咱們對ajax的調用發生於如今,這在 JavaScript 主程序的直接控制之下。但ajax裏的回調會延遲到未來發生,而且是在第三方(而不是咱們的主程序)的控制下——在本例中就是函數 ajax(..) 。這種控制權的轉移, 被叫作「控制反轉」
1.調用函數過早
調用函數過早的最值得讓人注意的問題, 是你不當心定義了一個函數,使得做爲函數參數的回調可能延時調用,也可能當即調用。 也即你使用了一個可能同步調用, 也可能異步調用的回調。 這樣一種難以預測的回調。
大多數時候,咱們的函數老是同步的,或者老是異步的
例如foreach()函數老是同步的
array.foreach(
x => console.log(x)
)
console.log(array)
雖然foreach函數的調用須要必定的時間,但array數組的輸出必定是在全部的數組元素都被輸出以後才輸出, 由於foreach是同步的
又如setTimeout老是異步的:
setTimeout( () => { console.log('我是異步的') },0 )
console.log('我是同步的')
有經驗的JS老司機們一眼就能看出, 必定是輸出
而不是
但有些時候,咱們仍有可能會寫出一個既可能同步, 又可能異步的函數,
例以下面這個極簡的例子:
我試圖用這段代碼檢查一個輸入框內輸入的帳號是否爲空, 若是不爲空就用它發起請求。(注:callback不管帳號是否爲空都會被調用)
// 注: 這是一個至關烏托邦,且省略諸多內容的函數
function login (callback) {
// 當取得的帳號變量name的值爲空時, 當即調用函數,此時callback同步調用)
if(!name) {
callback();
return // name爲空時在這裏結束函數
}
// 當取得的帳號變量name的值不爲空時, 在請求成功後調用函數(此時callback異步調用)
request('post', name, callback)
}
相信各位機智的園友憑第六感就能知曉:這種函數絕B不是什麼好東西。
的確,這種函數的編寫是公認的須要杜絕的,在英語世界裏, 這種可能同步也可能異步調用的回調以及包裹它的函數, 被稱做是 「Zalgo」 (一種都市傳說中的魔鬼), 而編寫這種函數的行爲, 被稱做是"release Zalgo" (將Zalgo釋放了出來)
爲何它如此可怕? 由於函數的調用時間是不肯定的,難以預料的。 我想沒有人會喜歡這樣難以掌控的代碼。
例如:
var a =1
zalgoFunction () {
// 這裏還有不少其餘代碼,使得a = 2可能被異步調用也可能被同步調用
[ a = 2 ]
}
console.log(a)
結果會輸出什麼呢? 若是zalgoFunction是同步的, 那麼a 顯然等於2, 但若是 zalgoFunction是異步的,那麼 a顯然等於1。因而, 咱們陷入了沒法判斷調用影響的窘境。
這只是一個極爲簡單的場景, 若是場景變得至關複雜, 結果又會如何呢?
你可能想說: 我本身寫的函數我怎麼會不知道呢?
請看下面:
1. 不少時候這個不肯定的函數來源於它人之手,甚至來源於徹底沒法覈實的第三方代碼
2. 在1的基礎上,咱們把這種不肯定的狀況稍微變得誇張一些: 這個函數中傳入的回調, 有99%的概率被異步調用, 有1%的概率被同步調用
在1和2的基礎上, 你向一個第三方的函數傳了一個回調, 而後在通過了一系列不可描述的bug後......
2.調用次數過多
這裏取《你不知道的javascript(中卷)》的例子給你們看一看:
做爲一個公司的員工, 你須要開發一個網上商城, payWithYourMoney是你在確認購買後執行的扣費的函數, 因爲公司須要對購買的數據作追蹤分析, 這裏須要用到一個作數據分析的第三方公司提供的analytics對象中的purchase函數。 代碼看起來像這樣
analytics.purchase( purchaseData, function () {
payWithYourMoney ()
} );
在這狀況下,可能咱們會忽略的一個事實是: 咱們已經把payWithYourMoney 的控制權徹底交給了analytics.purchase函數了,這讓咱們的回調「任人宰割」
而後上線後的一天, 數據分析公司的一個隱蔽的bug終於顯露出來, 讓其中一個本來只執行一次的payWithYourMoney執行了5次, 這讓那個網上商城的客戶極爲惱怒, 並投訴了大家公司。
可大家公司也很無奈, 這個時候驚奇的發現: payWithYourMoney的控制徹底不在本身的手裏 !!!!!
後來, 爲了保證只支付一次, 代碼改爲了這樣:
var analysisFlag = true // 判斷是否已經分析(支付)過一次了
analytics.purchase( purchaseData, function(){
if (!analysisFlag) {
payWithYourMoney ()
analysisFlag = false
}
} );
可是, 這種方式雖然巧妙, 但卻仍不夠簡潔優雅(後文提到的Promise將改變這一點)
並且, 在回調函數的無數「痛點」中, 它只能規避掉一個, 若是你嘗試規避掉全部的「痛點」,代碼將比上面更加複雜而混亂。
3.太晚調用或根本沒有調用
由於你失去了對回調的控制權, 你的回調可能會出現預期以外的過晚調用或者不調用的狀況(爲了處理這個「痛點」你又將混入一些複雜的代碼邏輯)
4.吞掉報錯
回調內的報錯是可能被包裹回調的外部函數捕捉而不報錯,(爲了處理這個「痛點」你又又又將混入一些複雜的代碼邏輯)
5.回調根本沒有被調用
沒辦法在複雜的異步場景中很好地表達代碼邏輯
哎呀這裏我就不說廢話了: 在異步中若是你老是依賴回調的話,很容易就寫出你們都看不懂, 甚至本身過段時間也看不懂的代碼來, 嗯, 就這樣
看個例子,下面的doA到doF都是異步的函數
doA( function(){
doB();
doC( function(){
doD();
} )
doE();
} );
doF();
請問這段代碼的調用順序 ? 固然你知道它確定不是A -> B -> C -> D -> E,但即便你富有經驗,通常也得花上一段時間的功夫才能把它理清楚吧。( A → B → C → D → E → F 。)
這並非咱們開發人員的鍋, 而是由於人腦的思惟方式原本就是線性的, 而回調卻打破了這種線性的思惟, 咱們須要強制地拋棄咱們看到的A -> B -> C -> D -> E的順序,去構建另外一套思惟。
因此說,異步編程中有大量回調混雜的時候, 所形成的可讀性差的問題,是回調自己的「表達方式「形成的
回調的侷限性僅僅如此? NO,請看下面:
對於一些比較常見的異步場景回調也沒辦法用足夠簡潔優雅的方式去處理:
這些場景包括但不限於:鏈式,門和競態
鏈式
首先你確定知道用回調處理大量存在鏈式的異步場景的畫風是怎樣的
例如這樣:
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');
讓人一臉蒙逼的回調函數地獄
很顯然,大多數時候你嘗試這樣作,是由於
你須要經過調用第一層異步函數,取得結果
而後把結果傳給第二層異步函數,第二層異步函數也取得結果後
傳遞結果給第三個異步函數, 。。。。。 N
很顯然,咱們的代碼風格應該是「鏈式」風格, 但卻由於回調的緣由被硬生生折騰成了難懂的「嵌套」風格! (別擔憂, 我下面介紹的Promise將改變這一點)
門
什麼叫「門」?, 你能夠大概理解成: 如今有一羣人準備進屋,但只有他們全部人都到齊了,才能「進門」 ,也就是: 只有全部的異步操做都完成了, 咱們才認爲它總體完成了,才能進行下一步操做
下面這個例子裏, 咱們試圖經過兩個異步請求操做,分別取得a和b的值並將它們以 a + b的形式
(前提: 咱們但願當a和b的取值都到達的時候才輸出!!)
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函數
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );
這段代碼比前面那段「鏈式」裏的回調地獄好懂多了,可是卻依然存在這一些問題:
咱們使用了兩個 if (a && b) { } 去分別保證baz是在a和b都到達後才執行的,試着思考一下:
兩個 if (a && b) { } 的判斷條件是否能夠合併到一塊兒呢,由於這兩個判斷條件都試圖表達同一種語意: a 和 b都到達, 能合併成一條語句的話豈不是更加簡潔優雅 ? (一切都在爲Promise作鋪墊哦~~~~啦啦啦)
競態(可能跟你通常理解的競態有些不一樣)
一組異步操做,其中一個完成了, 這組異步操做便算是總體完成了
在下面,咱們但願經過異步請求的方式,取得x的值,而後執行foo或者bar,但但願只把foo或者bar其中一個函數執行一次
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函數
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );
在這裏,咱們設置了一個flag, 設它的初始值爲true, 這時候foo或者bar在第一次執行的時候, 是能夠進入if內部的代碼塊而且執行baz函數的, 但在if內部的代碼塊結束的時候, 咱們把flag的值置爲false,這個時候下一個函數就沒法進入代碼塊執行了, 這就是回調對於競態的處理
正由於回調給咱們帶來的麻煩不少,ES6引入了Promise的機制:
一步一步地揭開Promise神祕的面紗
首先讓咱們回顧一下「回調函數」給咱們帶來信任危機的緣由: 咱們沒法信任放入回調參數的函數, 由於 它沒有強制要求經過一種肯定的(或固定的)形式給咱們回調傳遞有效的信息參數,例如: 異步操做成功的信息, 異步操做失敗的信息,等等。 咱們既然都無從獲得這些信息, 又怎麼能擁有對回調的控制權呢?
沒錯,咱們急需作的的就是獲得這些對咱們的「回調」相當重要的信息(異步操做成功的信息, 異步操做失敗的信息), 而且經過一種規則讓它們強制地傳遞給咱們的回調
讓咱們一步步來看看什麼是Promise
1.首先Promise是一個能夠包含異步操做的對象
new Promise(function() {
/* 異步操做 */
}
2.其次, 這個對象擁有本身的狀態(state),能夠分別用來表示異步操做的「成功」, 「失敗」,「正在進行中」。
它們是:
Fulfilled: 成功
Rejected:拒絕
Pending: 進行中
3.那怎麼控制這三個狀態的改變呢?
當new 一個Promise對象的時候, 咱們能接收到兩個方法參數: resolve和reject, 當調用 resolve方法的時候,會把Promise對象的狀態從Pending變爲Fulfilled(表示異步操做成功了),當調用 reject方法的時候, 會把Promise對象的狀態從Pending變爲Rejected,表示異步操做失敗了, 而若是這兩個函數沒有調用,則Promise對象的狀態一直是Pending(表示異步操做正在進行)
咱們異步執行的函數能夠放在Promise對象裏, 而後變成這樣
var promise = new Promise(function(resolve, reject) {
// 這裏是一堆異步操做的代碼
if (/* 異步操做成功 */){
resolve(value);
} else {
reject(error);
}
});
4. 最重要的一點, 咱們怎麼把這個狀態信息傳遞給咱們異步處理後的函數:
咱們剛剛說了, Promise有Resolved和Rejected兩種狀態, 這兩種狀態分別對應Promise的then方法裏的兩個回調參數
promise.then(function(value) {
// 成功
}, function(error) {
// 失敗
});
第一個參數方法對應Resolved, 第二個參數方法對應Rejected
並且Promise成功的時候(調用resolve), resolve返回的參數能夠被第一個回調接收到, 如上面的value參數
而當Promise失敗的時候(調用reject), reject返回的錯誤會被傳遞給第二個回調, 如上面的error
【辯解】: 你可能會說:哎呀咱們繞了一圈不是又回到了回調了嗎? Promise好像也不是特別革命性的一個新東西嘛!可是, 咱們就圍繞信任問題來講, Promise的確以一種強制的方式, 將回調的形式固定了下來(兩個方法參數),而且傳遞了必要的數據(異步取得的值或拋出的錯誤)給咱們的回調。
而這樣作,咱們已經達到了咱們的目的: 相對來講,咱們使得回調變得「可控」了, 而不是像單純使用回調那樣, 由於控制反轉而陷入信任危機的噩夢。
打個比方, 讓司機們依據對自身的道德要求讓不闖紅燈,和經過扣分的機制和法律限制闖紅燈的現象, 不管是性質上仍是效果上,這二者之間都是大相徑庭的。
Promise是怎麼一個個地解決回調帶來的問題的
1.回調過早調用
讓咱們回到那個回調的痛點:咱們有可能會寫出一個既可能同步執行, 又可能異步執行的「zalgo」函數。但Promise能夠自動幫咱們避免這個問題:
若是對一個 Promise 調用 then(..) 的時候,即便這個 Promise是當即resolve的函數(即Promise內部沒有ajax等異步操做,只有同步操做), 提供給then(..) 的回調也是會被異步調用的,這幫助咱們省了很多心
2. 回調調用次數過多
Promise 的內部機制決定了調用單個Promise的then方法, 回調只會被執行一次,由於Promise的狀態變化是單向不可逆的,當這個Promise第一次調用resolve方法, 使得它的狀態從pending(正在進行)變成fullfilled(已成功)或者rejected(被拒絕)後, 它的狀態就不再能變化了
因此你徹底沒必要擔憂Promise.then( function ) 中的function會被調用屢次的狀況
3. 回調中的報錯被吞掉
要說明一點的是Promise中的then方法中的error回調被調用的時機有兩種狀況:
1. Promise中主動調用了reject (有意識地使得Promise的狀態被拒絕), 這時error回調可以接收到reject方法傳來的參數(reject(error))
2. 在定義的Promise中, 運行時候報錯(未預料到的錯誤), 也會使得Promise的狀態被拒絕,從而使得error回調可以接收到捕捉到的錯誤
例如:
var p = new Promise( function(resolve,reject){
foo.bar(); // foo未定義,因此會出錯!
resolve( 42 ); // 永遠不會到達這裏 :(
} );
p.then(
function fulfilled(){
// 永遠不會到達這裏 :(
},
function rejected(err){
// err將會是一個TypeError異常對象來自foo.bar()這一行
}
);
4. 還有一種狀況是回調根本就沒有被調用,這是能夠用Promise的race方法解決(下文將介紹)
// 用於超時一個Promise的工具
function timeoutPromise(delay) {
return new Promise( function(resolve,reject){
setTimeout( function(){
reject( "Timeout!" );
}, delay );
} );
}
// 設置foo()超時
Promise.race( [
foo(), // 試着開始foo()
timeoutPromise( 3000 ) // 給它3秒鐘
] )
.then(
function(){
// foo(..)及時完成!
},
function(err){
// 或者foo()被拒絕,或者只是沒能按時完成
// 查看err來了解是哪一種狀況
}
);
Promise的完善的API設計使得它可以簡潔優雅地處理相對複雜的場景
鏈式
咱們上面說了, 純回調的一大痛點就是「金字塔回調地獄」, 這種「嵌套風格」的代碼醜陋難懂,但Promise就能夠把這種「嵌套」風格的代碼改裝成咱們喜聞樂見的「鏈式」風格
由於then函數是能夠鏈式調用的, 你的代碼能夠變成這樣
Promise.then(
// 第一個異步操做
).then(
// 第二個異步操做
).then(
// 第三個異步操做
)
並且, 你每個then裏面的異步操做能夠返回一個值,傳遞給下一個異步操做
getJSON('/post/1.json').then(function(post) {
return getJSON(post.commentURL);
}).then(function(comments) {
// some code
})
第二個then接收到的comments參數等於都一個then裏面接收到的getJSON(post.commentURL);
例如咱們上面提到的
門
可使用 Promise.all方法:
Promise.all([
promise1,
promise2
])
.then(([data1, data2]) => getDataAndDoSomething (data1,data2)
all方法接收一個Promise數組,而且返回一個新的「大Promise」, 只有數組裏的所有Promise的狀態都轉爲Fulfilled(成功),這個「大Promise」的狀態纔會轉爲Fulfilled(成功), 這時候, then方法裏的成功的回調接收的參數也是數組,分別和數組裏的子Promise一一對應, 例如promise1對應data1,promise2對應data2
而若是任意一個數組裏的子Promise失敗了, 這個「大Promise」的狀態會轉爲Rejected, 而且將錯誤參數傳遞給then的第二個回調
競態
能夠用Promise.race方法簡單地解決
romise.race方法一樣是將多個Promise實例,包裝成一個新的「大Promise」
例如
var p = Promise.race([p1, p2, p3]);
上面代碼中,只要p一、p二、p3之中有一個Promise率先改變狀態,p的狀態就跟着改變。那個率先改變的 Promise 實例的返回值,就傳遞給p的回調函數。
最後講個小故事
曾經我和小夥伴們搞比賽,合併代碼都是經過QQ傳代碼文件而後手動合併,常常會爲代碼的管理不勝其煩, 遇到諸多問題。一個學長告訴我能夠用git,但我當時卻以爲:「用QQ傳代碼合併就很好嘛, 用git的話學起來又麻煩,合併代碼辛苦一點也很正常的嘛~~~」,直到有一天我真的用上了git這個可愛的版本控制系統 ——
當初勸我用git的學長的溫暖的身影就浮現出來了....額...就像這樣:
若是不對新的東西加以學習, 你可能不知道舊的東西會給你帶來多少麻煩
若是永遠執着於舊的那一套東西, 你可能不知道新的東西能給你帶來多少但願和機遇
因此不要老是說:「用原來的就挺好的呀」
參考資料:《 你不知道的javascript》—— [美] Kyle Simpson