承諾之美 —— 淺談基于 Promise 的異步 Javascript編程方法

回調之痛

每一位前端工程師上輩子都是折翼的天使。

相信很多前端工程師都同我一樣,初次接觸到前端時,了解了些許 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)這三種狀態中的一種之中。

  1. 處于等待態時
  • 可以轉移至執行態或拒絕態
  1. 處于兌現態時
  • 不能遷移至其他任何狀態
  • 必須擁有一個不可變的值作為兌現結果
  1. 處于拒絕態時
  • 不能遷移至其他任何狀態
  • 必須擁有一個不可變的值作為拒絕原因

通過 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);

如果 onFulfilledonRejected 的返回值 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 方法不斷迭代,直到迭代完畢。

生成器-next

yield 是一個神奇的功能,它類似于 return ,但是和 return 又不盡相同。return 只能在一個函數中出現一次,yield 卻只能出現在生成器中且可以出現多次。迭代器的 next 方法被調用時,將觸發生成器中的代碼執行,執行到 yield 語句時,會將 yield 后的值帶出到迭代器的 next 方法的返回值中,并保存好運行時環境,將代碼掛起,直到下一次 next 方法被調用時繼續往下執行。

有沒有嗅到異步的味道?外部可以通過 next 方法控制內部代碼的執行!天然的異步有木有!感受一下這個例子:

生成器-dead-loop

還有還有,yield 大法還有一個功能,它不僅可以帶出值到 next 方法,還可以帶入值到生成器內部 yield 的占位處,使得 Generator 內部和外部可以通過 next 方法進行數據通信!

生成器-interact

好了,生成器了解的差不多了,現在看看把 Promise 和 Generator 放一起會產生什么黑魔法吧!

生成器-Promise

這里寫一個 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 索性引入 asyncawait關鍵字。async 標記的函數支持 await 表達式。包含 await 表達式的的函數是一個deferred functionawait 表達式的值,是一個 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);
    //...
}

至此,本文的全部內容都已完畢。前端標準不斷在完善,未來會越來越美好。永遠相信美好的事情即將發生!

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,527評論 6 544
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,687評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,640評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,957評論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,682評論 6 413
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,011評論 1 329
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,009評論 3 449
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,183評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,714評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,435評論 3 359
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,665評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,148評論 5 365
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,838評論 3 350
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,251評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,588評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,379評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,627評論 2 380

推薦閱讀更多精彩內容

  • 異步編程對JavaScript語言太重要。Javascript語言的執行環境是“單線程”的,如果沒有異步編程,根本...
    呼呼哥閱讀 7,325評論 5 22
  • 官方中文版原文鏈接 感謝社區中各位的大力支持,譯者再次奉上一點點福利:阿里云產品券,享受所有官網優惠,并抽取幸運大...
    HetfieldJoe閱讀 6,387評論 9 19
  • 簡介 基本概念 Generator函數是ES6提供的一種異步編程解決方案,語法行為與傳統函數完全不同。本章詳細介紹...
    呼呼哥閱讀 1,086評論 0 4
  • 在此處先列下本篇文章的主要內容 簡介 next方法的參數 for...of循環 Generator.prototy...
    醉生夢死閱讀 1,463評論 3 8
  • 弄懂js異步 講異步之前,我們必須掌握一個基礎知識-event-loop。 我們知道JavaScript的一大特點...
    DCbryant閱讀 2,746評論 0 5