回調之痛
每一位前端工程師上輩子都是折翼的天使。
相信很多前端工程師都同我一樣,初次接觸到前端時,了解了些許 HTML、CSS、JS 知識,便驚嘆于前端的美好,沉醉于這種所見即所得的成就感之中。但很快我就發現,前端并沒有想象中的那么美好,JS 也并不是彈一個 alert
這么簡單。尤其是當我想這么干,卻發現無法得到結果時:
var data = ajax('/url/to/data');
在查閱很多資料后,我知道了 JS 是事件驅動的,ajax 異步請求是非阻塞的,我封裝的 ajax 函數無法直接返回服務器數據,除非聲明為同步請求(顯然這不是我想要的)。于是我學會了或者說接受了這樣的事實,并改造了我的 ajax 函數:
ajax('/url/to/data', function(data){
//deal with data
});
在很長一段時間,我并沒有認為這樣的代碼是不優雅的,甚至認為這就是 JS 區別于其他語言的特征之一 —— 隨處可見的匿名函數,隨處可見的 calllback 參數。直到有一天,我發現代碼里出現了這樣的結構:
ajax('/get/data/1', function(data1){
ajax('/get/data/2', function(data2){
ajax('/get/data/3', function(data3){
dealData(data1, data2, data3, function(result){
setTimeout(function(){
ajax('/post/data', result.data, function(ret){
//...
});
}, 1000);
});
});
});
});
這就是著名的回調金字塔
!
在我的理想中,這段代碼應該是這樣的:
var data1 = ajax('/get/data/1');
var data2 = ajax('/get/data/2');
var data3 = ajax('/get/data/3');
var result = dealData(data1, data2, data3);
sleep(1000);
var ret = ajax('/post/data', result.data);
//...
承諾的救贖
理想是豐滿的,奈何現實太骨干。這種回調之痛
在前端人心中是揮之不去的,它使得代碼結構混亂,可讀性變差,維護困難。在忍受這種一坨坨的代碼很久之后,有一天我偶遇了 Promise,她的優雅讓我久久為之贊嘆:世間竟有如此曼妙的異步回調解決方案。
Promises/A+規范中對 promise
的解釋是這樣的: promise
表示一個異步操作的最終結果。與 promise 進行交互的主要方式是通過 then 方法,該方法注冊了兩個回調函數,用于接受 promise 的最終結果或者 promise 的拒絕原因。一個 Promise 必須處于等待態(Pending)、兌現態(Fulfilled)和拒絕態(Rejected)這三種狀態中的一種之中。
- 處于等待態時
- 可以轉移至執行態或拒絕態
- 處于兌現態時
- 不能遷移至其他任何狀態
- 必須擁有一個不可變的值作為兌現結果
- 處于拒絕態時
- 不能遷移至其他任何狀態
- 必須擁有一個不可變的值作為拒絕原因
通過 resolve
可以將承諾轉化為兌現態,通過 reject
可以將承諾轉換為拒絕態。
關于 then 方法,它接受兩個參數:
promise.then(onFulfilled, onRejected)
then 方法可以被同一個 promise
調用多次:
- 當
promise
成功執行時,所有onFulfilled
需按照其注冊順序依次回調 - 當
promise
被拒絕執行時,所有的onRejected
需按照其注冊順序依次回調
使用 Promise 后,我的 ajax 函數使用起來變成了這個樣子:
ajax('/url/to/data')
.then(function(data){
//deal with data
});
看起來和普通的回調沒什么變化是么?讓我們繼續研究 then 方法的神奇之處吧。
then 方法的返回值是一個新的 promise
:
promise2 = promise1.then(onFulfilled, onRejected);
如果 onFulfilled
、onRejected
的返回值 x
是一個 promise
,promise2 會根據 x
的狀態來決定如何處理自己的狀態。
- 如果 x 處于等待態, promise2 需保持為等待態直至 x 被兌現或拒絕
- 如果 x 處于兌現態,用相同的值兌現 promise2
- 如果 x 處于拒絕態,用相同的值拒絕 promise2
這意味著串聯異步流程的實現會變得非常簡單。我試著用 Promise 來改寫所有的異步接口,上面的金字塔代碼便成為這樣的:
when( ajax('/get/data/1'), ajax('/get/data/2'), ajax('/get/data/3') )
.then(dealData)
.then(sleep.bind(null,1000))
.then(function(result){
return ajax('/post/data', result.data);
})
.then(function(ret){
//...
});
一下子被驚艷到了啊!回調嵌套被拉平了,小肚腩不見了!這種鏈式 then 方法的形式,頗有幾分 stream/pipe 的意味。
$.Deferred
jQuery 中很早就有 Promise 的實現,它稱之為 Deferred 對象。使用 jQuery 舉例寫一個 sleep 函數:
function sleep(s){
var d = $.Deferred();
setTimeout(function(){
d.resolve();
}, s);
return d.promise(); //返回 promise 對象防止在外部被別人 resolve
}
我們來使用一下:
sleep(1000)
.then(function(){
console.log('1秒過去了');
})
.then(sleep.bind(null,3000))
.then(function(){
console.log('4秒過去了');
});
jQuery 實現規范的 API 之外,還實現了一對接口:notify/progress。這對接口在某些場合下,簡直太有用了,例如倒計時功能。對上述 sleep 函數改造一下,我們寫一個 countDown 函數:
function countDown(second) {
var d = $.Deferred();
var loop = function(){
if(second <= 0) {
return d.resolve();
}
d.notify(second--);
setTimeout(loop, 1000);
};
loop();
return d.promise();
}
現在我們來使用這個函數,感受一下 Promise 帶來的美好。比如,實現一個 60 秒后重新獲取驗證碼的功能:
var btn = $("#getSMSCodeBtn");
btn.addClass("disabled");
countDown(60)
.progress(function(s){
btn.val(s+'秒后可重新獲取');
})
.then(function(){
btn.val('重新獲取驗證碼').removeClass('disabled');
});
簡直驚艷!離絕對的同步編寫非阻塞形式的代碼已經很近了!
與 ES6 Generator 碰撞出火花
我深刻感受到,前端技術發展是這樣一種狀況: 當我們驚嘆于最新技術標準的美好,感覺一個最好的時代即將到來時,回到實際生產環境,卻發現一張小小的 png24 透明圖片在 IE6 下還需要前端進行特殊處理。但,那又怎樣,IE6 也不能阻擋我們對前端技術灼熱追求的腳步,說不定哪天那些不支持新標準的瀏覽器就悄然消失了呢?(扯遠了...)
ES6 標準中最令我驚嘆的是 Generator —— 生成器。顧名思義,它用來生成某些東西。且上例子:
這里我們看到了 function*() 的新語法,還有 yield 關鍵字和 for/of 循環。新東西總是能讓人產生振奮的心情,即使現在還不能將之投入使用(如果你需要,其實可以通過 ES6->ES5 的編譯工具預處理你的 js 文件)。如果你了解 Python , 這很輕松就能理解。Generator 是一種特殊的 function,在括號前加一個 *
號以區別。Generator 通過 yield
操作產生返回值,最終生成了一個類似數組的東西,確切的說,它返回了 Iterator,即迭代器。迭代器可以通過 for/of 循環來進行遍歷,也可以通過 next
方法不斷迭代,直到迭代完畢。
yield
是一個神奇的功能,它類似于 return
,但是和 return
又不盡相同。return
只能在一個函數中出現一次,yield
卻只能出現在生成器中且可以出現多次。迭代器的 next
方法被調用時,將觸發生成器中的代碼執行,執行到 yield
語句時,會將 yield
后的值帶出到迭代器的 next
方法的返回值中,并保存好運行時環境,將代碼掛起,直到下一次 next
方法被調用時繼續往下執行。
有沒有嗅到異步的味道?外部可以通過 next
方法控制內部代碼的執行!天然的異步有木有!感受一下這個例子:
還有還有,yield
大法還有一個功能,它不僅可以帶出值到 next
方法,還可以帶入值到生成器內部 yield
的占位處,使得 Generator 內部和外部可以通過 next
方法進行數據通信!
好了,生成器了解的差不多了,現在看看把 Promise 和 Generator 放一起會產生什么黑魔法吧!
這里寫一個 delayGet 函數用來模擬費時操作,延遲 1 秒返回某個值。在此借助一個 run 方法,就實現了同步編寫非阻塞的邏輯!這就是 TJ 大神 co 框架的基本思想。
回首一下我們曾經的理想,那段代碼用 co 框架編寫可以是這樣的:
co(function*(){
var data1 = yield ajax('/get/data/1');
var data2 = yield ajax('/get/data/2');
var data3 = yield ajax('/get/data/3');
var result = yield dealData(data1, data2, data3);
yield sleep(1000);
var ret = yield ajax('/post/data', result.data);
//...
})();
Perfect!完美!
ES7 async-await
ES3 時代我們用閉包來模擬 private 成員,ES5 便加入了 defineProperty 。Generator 最初的本意是用來生成迭代序列的,畢竟不是為異步而生的。ES7 索性引入 async
、await
關鍵字。async
標記的函數支持 await
表達式。包含 await
表達式的的函數是一個deferred function
。await
表達式的值,是一個 awaited object
。當該表達式的值被評估(evaluate) 之后,函數的執行就被暫停(suspend)。只有當 deffered 對象執行了回調(callback 或者 errback)后,函數才會繼續。
也就是說,只需將使用 co 框架的代碼中的 yield 換掉即可:
async function task(){
var data1 = await ajax('/get/data/1');
var data2 = await ajax('/get/data/2');
var data3 = await ajax('/get/data/3');
var result = await dealData(data1, data2, data3);
await sleep(1000);
var ret = await ajax('/post/data', result.data);
//...
}
至此,本文的全部內容都已完畢。前端標準不斷在完善,未來會越來越美好。永遠相信美好的事情即將發生!