【javascript】異步編年史,從“純回調”到Promise


異步和分塊——程序的分塊執行

 
一開始學習javascript的時候, 我對異步的概念一臉懵逼, 因為當時百度了很多文章,但很多各種文章不負責任的把籠統的描述混雜在一起,讓我對這個 JS中的重要概念難以理解, “異步是非阻塞的”, “Ajax執行是異步的”, "異步用來處理耗時操作".... 
 
所有人都再說這個是異步的,那個是異步的,異步會怎么怎樣,可我還是不知道:“異步到底是什么?”
 
后來我發現,其實理解異步最主要的一點,就是記住: 我們的程序是分塊執行的
 
分成兩塊, 同步執行的湊一塊, 異步執行的湊一塊,搞完同步,再搞異步
 
廢話不多說, 直接上圖:
 
 圖1

 

圖2 
 

 

 

異步和非阻塞

 

我對異步的另外一個難以理解的點是異步/同步和阻塞/非阻塞的關系
 
人們常說: “異步是非阻塞的” , 但為什么異步是非阻塞的, 或者說, 異步和非阻塞又有什么關系呢
 
非阻塞是對異步的要求, 異步是在“非阻塞”這一要求下的必然的解決方式 
 
咱們看看一個簡單的例子吧
ajax("http://XXX.", callback);
doOtherThing()

 

你肯定知道ajax這個函數的調用是發出請求取得一些數據回來, 這可能需要相當長的一段時間(相比於其他同步函數的調用)

 
對啊,如果我們所有代碼都是同步的,這就意味着, 在執行完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('我是異步的')  }, )
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]);

 

上面代碼中,只要p1、p2、p3之中有一個Promise率先改變狀態,p的狀態就跟着改變。那個率先改變的 Promise 實例的返回值,就傳遞給p的回調函數。
 
 
最后講個小故事
 
曾經我和小伙伴們搞比賽,合並代碼都是通過QQ傳代碼文件然后手動合並,經常會為代碼的管理不勝其煩, 遇到諸多問題。一個學長告訴我可以用git,但我當時卻覺得:“用QQ傳代碼合並就很好嘛, 用git的話學起來又麻煩,合並代碼辛苦一點也很正常的嘛~~~”,直到有一天我真的用上了git這個可愛的版本控制系統 ——
 
當初勸我用git的學長的溫暖的身影就浮現出來了....額...就像這樣:
 

 

如果不對新的東西加以學習, 你可能不知道舊的東西會給你帶來多少麻煩
如果永遠執着於舊的那一套東西, 你可能不知道新的東西能給你帶來多少希望和機遇
 
所以不要總是說:“用原來的就挺好的呀”
 
 
參考資料:《 你不知道的javascript》—— [美] Kyle Simpson
 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM