使用Promise解決多層異步調用的簡單學習

前言

第一次接觸到Promise這個東西,是2012年微軟發布Windows8操作系統后抱著作死好奇的心態研究用html5寫Metro應用的時候。當時配合html5提供的WinJS庫里面的異步接口全都是Promise形式,這對那時候剛剛畢業一點javascript基礎都沒有的我而言簡直就是天書。我當時想的是,微軟又在腦洞大開的瞎搗鼓了。

結果沒想到,到了2015年,Promise居然寫進ES6標準里面了。而且一項調查顯示,js程序員們用這玩意用的還挺high。

諷刺的是,作為早在2012年就在Metro應用開發接口里面廣泛使用Promise的微軟,其自家瀏覽器IE直到2015年壽終正寢了都還不支持Promise,看來微軟不是沒有這個技術,而是真的對IE放棄治療了。。。

現在回想起來,當時看到Promise最頭疼的,就是初學者看起來匪夷所思,也是最被js程序員廣為稱道的特性:then函數調用鏈。

then函數調用鏈,從其本質上而言,就是對多個異步過程的依次調用,本文就從這一點著手,對Promise這一特性進行研究和學習。

Promise解決的問題

考慮如下場景,函數延時2秒之后打印一行日志,再延時3秒打印一行日志,再延時4秒打印一行日志,這在其他的編程語言當中是非常簡單的事情,但是到了js里面就比較費勁,代碼大約會寫成下面的樣子:

var myfunc = function() {   
    setTimeout(function() {
        console.log("log1");
        setTimeout(function() {
            console.log("log2");
            setTimeout(function() {
                console.log("log3");
            }, 4000);
        }, 3000); 
    }, 2000);
}

由于嵌套了多層回調結構,這里形成了一個典型的金字塔結構。如果業務邏輯再復雜一些,就會變成令人聞風喪膽的回調地獄。

如果意識比較好,知道提煉出簡單的函數,那么代碼差不多是這個樣子:

var func1 = function() {
    setTimeout(func2, 2000);
};

var func2 = function() {
    console.log("log1");
    setTimeout(func3, 3000);
};

var func3 = function() {
    console.log("log2");
    setTimeout(func4, 4000);
};

var func4 = function() {
    console.log("log3");
};

這樣看起來稍微好一點了,但是總覺得有點怪怪的。。。好吧,其實我js水平有限,說不上來為什么這樣寫不好。如果你知道為什么這樣寫不太好所以發明了Promise,請告訴我。

現在讓我們言歸正傳,說說Promise這個東西。

Promise的描述

這里請允許我引用MDN對Promise的描述:

Promise 對象用于延遲(deferred) 計算和異步(asynchronous ) 計算.。一個Promise對象代表著一個還未完成,但預期將來會完成的操作。

Promise 對象是一個返回值的代理,這個返回值在promise對象創建時未必已知。它允許你為異步操作的成功或失敗指定處理方法。 這使得異步方法可以像同步方法那樣返回值:異步方法會返回一個包含了原返回值的 promise 對象來替代原返回值。

Promise對象有以下幾種狀態:

  • pending: 初始狀態, 非 fulfilled 或 rejected。
  • fulfilled: 成功的操作。
  • rejected: 失敗的操作。

pending狀態的promise對象既可轉換為帶著一個成功值的fulfilled 狀態,也可變為帶著一個失敗信息的 rejected 狀態。當狀態發生轉換時,promise.then綁定的方法(函數句柄)就會被調用。(當綁定方法時,如果 promise對象已經處于 fulfilled 或 rejected 狀態,那么相應的方法將會被立刻調用, 所以在異步操作的完成情況和它的綁定方法之間不存在競爭條件。)

更多關于Promise的描述和示例可以參考MDN的Promise條目,或者MSDN的Promise條目。

嘗試使用Promise解決我們的問題

基于以上對Promise的了解,我們知道可以使用它來解決多層回調嵌套后的代碼蠢笨難以維護的問題。關于Promise的語法和參數上面給出的兩個鏈接已經說的很清楚了,這里不重復,直接上代碼。

我們先來嘗試一個比較簡單的情況,只執行一次延時和回調:

new Promise(function(res, rej) {
    console.log(Date.now() + " start setTimeout");
    setTimeout(res, 2000);
}).then(function() {
    console.log(Date.now() + " timeout call back");
});

看起來和MSDN里的示例也沒什么區別,執行結果如下:

$ node promisTest.js
1450194136374 start setTimeout
1450194138391 timeout call back

那么如果我們要再做一個延時呢,那么我可以這樣寫:

new Promise(function(res, rej) {
    console.log(Date.now() + " start setTimeout 1");
    setTimeout(res, 2000);
}).then(function() {
    console.log(Date.now() + " timeout 1 call back");
    new Promise(function(res, rej) {
        console.log(Date.now() + " start setTimeout 2");
        setTimeout(res, 3000);
    }).then(function() {
        console.log(Date.now() + " timeout 2 call back");
    })
});

似乎也能正確運行:

$ node promisTest.js
1450194338710 start setTimeout 1
1450194340720 timeout 1 call back
1450194340720 start setTimeout 2
1450194343722 timeout 2 call back

不過代碼看起來蠢萌蠢萌的是不是,而且隱約又在搭金字塔了。這和引入Promise的目的背道而馳。

那么問題出在哪呢?正確的姿勢又是怎樣的?

答案藏在then函數以及then函數的onFulfilled(或者叫onCompleted)回調函數的返回值里面。

首先明確的一點是,then函數會返回一個新的Promise變量,你可以再次調用這個新的Promise變量的then函數,像這樣:

new Promise(...).then(...)
    .then(...).then(...).then(...)...

then函數返回的是什么樣的Promies,取決于onFulfilled回調的返回值。

事實上,onFulfilled可以返回一個普通的變量,也可以是另一個Promise變量。

如果onFulfilled返回的是一個普通的值,那么then函數會返回一個默認的Promise變量。執行這個Promise的then函數會使Promise立即被滿足,執行onFulfilled函數,而這個onFulfilled的入參,即是上一個onFulfilled的返回值。

而如果onFulfilled返回的是一個Promise變量,那個這個Promise變量就會作為then函數的返回值。

關于then函數和onFulfilled函數的返回值的這一系列設定,MDN和MSDN上的文檔都沒有明確的正面描述,至于ES6官方文檔ECMAScript 2015 (6th Edition, ECMA-262)。。。我的水平有限實在看不懂,如果哪位高手能解釋清楚官方文檔里面對著兩個返回值的描述,請一定留言指教!!!

所以以上為我的自由發揮,語言組織的有點拗口,上代碼看一下大家就明白了。

首先是返回普通變量的情況:

new Promise(function(res, rej) {
    console.log(Date.now() + " start setTimeout 1");
    setTimeout(res, 2000);
}).then(function() {
    console.log(Date.now() + " timeout 1 call back");
    return 1024;
}).then(function(arg) {
    console.log(Date.now() + " last onFulfilled return " + arg);    
});

以上代碼執行結果為:

$ node promisTest.js
1450277122125 start setTimeout 1
1450277124129 timeout 1 call back
1450277124129 last onFulfilled return 1024

有點意思對不對,但這不是關鍵。關鍵是onFulfilled函數返回一個Promise變量可以使我們很方便的連續調用多個異步過程。比如我們可以這樣來嘗試連續做兩個延時操作:

new Promise(function(res, rej) {
    console.log(Date.now() + " start setTimeout 1");
    setTimeout(res, 2000);
}).then(function() {
    console.log(Date.now() + " timeout 1 call back");
    return new Promise(function(res, rej) {
        console.log(Date.now() + " start setTimeout 2");
        setTimeout(res, 3000);
    });
}).then(function() {
    console.log(Date.now() + " timeout 2 call back");
});

執行結果如下:

$ node promisTest.js
1450277510275 start setTimeout 1
1450277512276 timeout 1 call back
1450277512276 start setTimeout 2
1450277515327 timeout 2 call back

如果覺得這也沒什么了不起,那再多來幾次也不在話下:

new Promise(function(res, rej) {
    console.log(Date.now() + " start setTimeout 1");
    setTimeout(res, 2000);
}).then(function() {
    console.log(Date.now() + " timeout 1 call back");
    return new Promise(function(res, rej) {
        console.log(Date.now() + " start setTimeout 2");
        setTimeout(res, 3000);
    });
}).then(function() {
    console.log(Date.now() + " timeout 2 call back");
    return new Promise(function(res, rej) {
        console.log(Date.now() + " start setTimeout 3");
        setTimeout(res, 4000);
    });
}).then(function() {
    console.log(Date.now() + " timeout 3 call back");
    return new Promise(function(res, rej) {
        console.log(Date.now() + " start setTimeout 4");
        setTimeout(res, 5000);
    });
}).then(function() {
    console.log(Date.now() + " timeout 4 call back");
});
$ node promisTest.js
1450277902714 start setTimeout 1
1450277904722 timeout 1 call back
1450277904724 start setTimeout 2
1450277907725 timeout 2 call back
1450277907725 start setTimeout 3
1450277911730 timeout 3 call back
1450277911730 start setTimeout 4
1450277916744 timeout 4 call back

可以看到,多個延時的回調函數被有序的排列下來,并沒有出現喜聞樂見的金字塔狀結構。雖然代碼里面調用的都是異步過程,但是看起來就像是全部由同步過程構成的一樣。這就是Promise帶給我們的好處。

如果你有把啰嗦的代碼提煉成單獨函數的好習慣,那就更加畫美不看了:

function timeout1() {
    return new Promise(function(res, rej) {
        console.log(Date.now() + " start timeout1");
        setTimeout(res, 2000);
    });
}

function timeout2() {
    return new Promise(function(res, rej) {
        console.log(Date.now() + " start timeout2");
        setTimeout(res, 3000);
    });
}

function timeout3() {
    return new Promise(function(res, rej) {
        console.log(Date.now() + " start timeout3");
        setTimeout(res, 4000);
    });
}

function timeout4() {
    return new Promise(function(res, rej) {
        console.log(Date.now() + " start timeout4");
        setTimeout(res, 5000);
    });
}

timeout1()
    .then(timeout2)
    .then(timeout3)
    .then(timeout4)
    .then(function() {
        console.log(Date.now() + " timout4 callback");
    });
$ node promisTest.js
1450278983342 start timeout1
1450278985343 start timeout2
1450278988351 start timeout3
1450278992356 start timeout4
1450278997370 timout4 callback

接下來我們可以再繼續研究一下onFulfilled函數傳入入參的問題。

我們已經知道,如果上一個onFulfilled函數返回了一個普通的值,那么這個值為作為這個onFulfilled函數的入參;那么如果上一個onFulfilled返回了一個Promise變量,這個onFulfilled的入參又來自哪里?

答案是,這個onFulfilled函數的入參,是上一個Promise中調用resolve函數時傳入的值。

跳躍的有點大一時間無法接受對不對,讓我們來好好縷一縷。

首先,Promise.resolve這個函數是什么,用MDN上面文鄒鄒的說法

用成功值value解決一個Promise對象。如果該value為可繼續的(thenable,即帶有then方法),返回的Promise對象會“跟隨”這個value,采用這個value的最終狀態;否則的話返回值會用這個value滿足(fullfil)返回的Promise對象。

簡而言之,這就是異步調用成功情況下的回調。

我們來看看普通的異步接口中,成功情況的回調是什么樣的,就拿nodejs的上的fs.readFile(file[, options], callback)來說,它的典型調用例子如下

fs.readFile('/etc/passwd', function (err, data) {
  if (err) throw err;
  console.log(data);
});

因為對于fs.readFile這個函數而言,無論成功還是失敗,它都會調用callback這個回調函數,所以這個回調接受兩個入參,即失敗時的異常描述err和成功時的返回結果data

那么假如我們用Promise來重構這個讀取文件的例子,我們應該怎么寫呢?

首先是封裝fs.readFile函數:

function readFile(fileName) {
    return new Promise(function(resolve, reject) {
        fs.readFile(fileName, function (err, data) {
            if (err) {
                reject(err);
            } else {
                resolve(data);
            }
        });
    });
}

其次是調用:

readFile('theFile.txt').then(
    function(data) {
        console.log(data);
    }, 
    function(err) {
        throw err;
    }   
);

想象一下,在其他語言的讀取文件的同步調用接口的里面,文件的內容通常是放在哪里?函數返回值對不對!答案出來了,這個resolve的入參是什么?就是異步調用成功情況下的返回值。

有了這個概念之后,我們就不難理解“onFulfilled函數的入參,是上一個Promise中調用resolve函數時傳入的值”這件事了。因為onFulfilled的任務,就是對上一個異步調用成功后的結果做處理的。

哎終于理順了。。。

總結

下面請允許我用一段代碼對本文講解到的要點進行總結:

function callp1() {
    console.log(Date.now() + " start callp1");
    return new Promise(function(res, rej) {
        setTimeout(res, 2000);
    });
}

function callp2() {
    console.log(Date.now() + " start callp2");
    return new Promise(function(res, rej) {
        setTimeout(function() {
            res({arg1: 4, arg2: "arg2 value"});
        }, 3000);
    });
}

function callp3(arg) {
    console.log(Date.now() + " start callp3 with arg = " + arg);
    return new Promise(function(res, rej) {
        setTimeout(function() {
            res("callp3");
        }, arg * 1000);
    });
}

callp1().then(function() {
    console.log(Date.now() + " callp1 return");
    return callp2();
}).then(function(ret) {
    console.log(Date.now() + " callp2 return with ret value = " + JSON.stringify(ret));
    return callp3(ret.arg1);
}).then(function(ret) {
    console.log(Date.now() + " callp3 return with ret value = " + ret);
})
$ node promisTest.js
1450191479575 start callp1
1450191481597 callp1 return
1450191481599 start callp2
1450191484605 callp2 return with ret value = {"arg1":4,"arg2":"arg2 value"}
1450191484605 start callp3 with arg = 4
1450191488610 callp3 return with ret value = callp3

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

推薦閱讀更多精彩內容