JS異步編程——從一個應用場景講起

本文旨在通過實例演示JS異步編程幾種常見的解決方案,讓你寫出最優雅的異步代碼。

異步是Javascript區別于其他編程語言的一個優秀的特性。由于JS是單線程執行的,如果沒有引入異步,站點頁面的性能將會是一個難以解決的、致命的問題。而隨之node的興起,異步更是被推崇到極致,躍身成為網絡編程的強手。

一段完整的任務A執行到一半,暫停,去執行另外一段任務B,完了再回到斷點處繼續執行A,這是異步編程最直觀的理解。這里的斷點,最普遍的應用場景就是發出一個耗時不可知的I/O任務,http請求或文件讀取。本文就這樣一個邏輯場景的假定,來聊聊JS異步編程的解決方案(為簡化思路,場景設計得略為蹩腳):

  • JS讀取文件a.txt,獲取到數據dataA=1;
  • 再讀取文件b.txt,獲取到數據dataB=2;
  • 再讀取文件c.txt,獲取到數據dataC=3;
  • 求和 sum = 6。

這類場景所呈現出來的順序依賴的問題,大多數情況下是可以通過修改設計邏輯來解決的,但有些時候并不適合。我曾在項目中遇到過向服務端按序同步做出多個http請求的需求,并且此時重新制定API的成本已經相當大了,所以,對于這種常見菜式,如何高效實現異步是JS開發的關鍵。下邊討論的事件回調、發布/訂閱模式、Promise、Generator,以及最出神入化的 Async/Await 新語法,都是異步編程可選的解決渠道。


事件回調

事件(event)與回調(callback)在JS中隨處可見,回調是常用的解決異步的方法,易于理解,在許多庫與函數中也容易實現。如果使用 node 原生支持的 fs.readFile() 來編寫那會是這樣的:

const fs = require('fs');

fs.readFile('a.txt', {encoding: 'utf8'}, function(err, dataA){
    if(err) throw Error('fail');
    console.log('dataA is %d', dataA);

    fs.readFile('b.txt', {encoding: 'utf8'}, function(err, dataB){
        if(err) throw Error('fail');
        console.log('dataB is %d', dataB);

        fs.readFile('c.txt', {encoding: 'utf8'}, function(err, dataC){
            if(err) throw Error('fail');
            console.log('dataC is %d', dataC);

            console.log('sum is %d', parseInt(dataA) + parseInt(dataB) + parseInt(dataC));
        })
    })
});

// $node index.js
// dataA is 1
// dataB is 2
// dataC is 3
// sum is 6

readFile()會在文件 I/O 返回結果之后觸發回調函數,通過這種嵌套的方式,我們能夠保證文件是按序讀取的,這種方式在JS中十分常見,比如定時器setInterval()setTimeout()。回調實現的異步代碼易于理解,但問題也很明顯,層層嵌套使得代碼逐層縮進,嚴重降低了可讀性,我們把這種現象稱為回調金字塔。

發布/訂閱模式

事件回調是通過觸發實現的,而發布/訂閱(pub/sub)模式實現的原理與事件回調基本一致。事實上,發布/訂閱模式更像是一般化的事件回調,是對事件回調的拆解和拓展。和事件回調機制一樣,發布/訂閱模式需要提供回調函數(訂閱),不同的是事件回調機制是自行觸發,而發布/訂閱模式把觸發權限交給了開發者,因此你可以選擇在任意時刻觸發回調(發布)。

在事件機制上,node 內置了一個功能強大的events模塊,對發布/訂閱模式提供了完美的支持,我們可以用它來實現這個應用場景:

const events = require('events');  
const fs = require('fs');
const eventEmitter = new events.EventEmitter();

const files = [
    { fileName: 'a.txt', dataName: 'dataA' },
    { fileName: 'b.txt', dataName: 'dataB' },
    { fileName: 'c.txt', dataName: 'dataC' },
];
let index = 0;

// 訂閱自定義事件
eventEmitter.on('next', function(data){
    if(index>2) return console.log('sum is %d', parseInt(data));

    fs.readFile(files[index].fileName, {encoding: 'utf8'}, function(err, newData){
        if(err) throw Error('fail');
        console.log(files[index].dataName+' is %d', newData);
        index++;
        // 驅動執行
        eventEmitter.emit('next', parseInt(data)+parseInt(newData));
    })
});

// 觸發自定義事件執行
eventEmitter.emit('next', 0);

// $ node index.js
// dataA is 1
// dataB is 2
// dataC is 3
// sum is 6

上面代碼掛載了一個自定義事件next,通過一個守衛變量index,使得next事件總能在前一步文件讀取完成后觸發,并且在按序完成三次文件讀取后輸出和。

事件機制在JS編程中十分常見,原生的 XHR 對象就是通過事件實現 AJAX 請求的。但從上面的代碼我們可以看出這種模式解決深度嵌套問題依然顯得吃力,events對象的引入、事件的訂閱和發布,讓代碼摻雜了許多邏輯以外的東西,十分晦澀難懂。人類語言的語法邏輯是同步的,我們希望避開丑陋的異步代碼,用同步的編寫方式去實現異步邏輯。

Promise

在 ES6 出現之前,人們飽受“回調地獄”的煎熬,現在,我們大可以使用 ES6 提供的 Promise 對象實現異步,徹底地告別“回調地獄”。Promise 是對社區早有的 Promise/Deferred 模式的實現,該模式最早出現在 jQuery1.5 版本,使得改寫后的 Ajax 支持鏈式表達。Promise 譯為“承諾”,個人理解為 Promise 對象承諾在異步操作完成后調用 then 方法指定的回調函數,因此,Promise的本質依然是事件回調,是基于事件機制實現的。

const fs = require('fs');

new Promise(function(resolve, reject){
    fs.readFile('a.txt', {encoding: 'utf8'}, function(err, newData){
        if(err) reject('fail');
        console.log('dataA is %d', newData);

        resolve(newData);
    });
}).then(function(data){
    // 返回一個新的 promise 對象,使得下一個回調函數會等待該異步操作完成
    return new Promise(function(resolve, reject){
        fs.readFile('b.txt', {encoding: 'utf8'}, function(err, newData){
            if(err) reject('fail');
            console.log('dataB is %d', newData);

            resolve(parseInt(data)+parseInt(newData));
        });
    });
}).then(function(data){
    return new Promise(function(resolve, reject){
        fs.readFile('c.txt', {encoding: 'utf8'}, function(err, newData){
            if(err) reject('fail');
            console.log('dataC is %d', newData);

            resolve(parseInt(data)+parseInt(newData));
        });
    });
}).then(function(data){
    console.log('sum is %d', parseInt(data));
}).catch(function(err){
    throw Error('fail');
});

// $ node index.js
// dataA is 1
// dataB is 2
// dataC is 3
// sum is 6

上面的代碼中,異步操作被按序編寫在每個 Promise 對象的then()方法中,then表示“然后”,可見按照這種方式編寫的代碼邏輯和業務邏輯是一致的,保證了代碼的可讀性,但引入了 Promise 對象后,代碼中出現了一堆的 then 和 catch 方法,這些代碼是業務邏輯之外的代碼,是影響閱讀、不應該出現的。我們不止希望代碼邏輯和業務邏輯是一致的,我們還想要最簡潔、最清晰的表達方式。

Generator

Generator 函數是 ES6 標準引入的新特性,旨在提供一類完全不同于以往異步編寫方式的解決方案。依照阮一峰老師在《Generator 函數的語法》一文中的說法,Generator 函數是一個狀態機,封裝了多個內部狀態。Generator 函數提供了一種機制,通過yield關鍵字和next()方法來交付和歸還線程執行權,實現代碼異步。我們前邊說過,異步直觀理解是中斷程序A去執行B之后再回到斷點處繼續執行A,本質上這就是一種執行權的借還。

Generator 函數不同于普通函數,Generator 函數執行到yield關鍵字處會自動暫停,保護現場,返回一個遍歷器(Iterator)對象,完成執行權的交付。之后,我們通過調用該遍歷器對象的next()方法,回歸現場,驅動 Generator 函數從斷點處繼續執行,完成執行權歸還。

歸還執行權一般借助于 Promise 對象來實現。

const fs = require('fs');

const getData = function(fileName){
    return new Promise(function(resolve, reject){
        fs.readFile(fileName, {encoding: 'utf8'}, function(err, data){
            if(err) throw Error('fail');
            resolve(data);
        })
    });
}

const g = function* (){
    try{
        let dataA = yield getData('a.txt');  // yield 在暫停時刻并沒有賦值,dataA 的值是在重新執行時刻由 next 方法的參數傳入的
        console.log('dataA is %d', dataA);  
        let dataB = yield getData('b.txt');  
        console.log('dataB is %d', dataB); 
        let dataC = yield getData('c.txt');
        console.log('dataC is %d', dataC);

        console.log('sum is %d', parseInt(dataA) + parseInt(dataB) + parseInt(dataC));
    }catch(err){
        console.log(err);
    }
};

// 驅動 Generator 執行
function run (generator) {
    let it = generator();

    function go(result) {
        // 判斷是否遍歷完成,標志位 result.done 為 true 表示遍歷完成
        if (result.done) return result.value;
        // result.value 即為返回的 promise 對象
        return result.value.then(function (value) {
            return go(it.next(value));
        }, function (error) {
            return go(it.throw(error));
        });
    }

    go(it.next());
}

run(g);

// $ node index.js
// dataA is 1
// dataB is 2
// dataC is 3
// sum is 6

上面代碼中,getData()函數返回了一個負責讀取文件的 Promise 對象, Promise 對象會在讀取到數據后驅動 Generator 函數繼續執行,run()函數是 Generator 函數的自動執行器。如果你把目光放到 Generator 函數上,你會驚訝地發現,這異步代碼簡直跟同步編寫的一模一樣!可以說Generator 函數的出現,為JS異步編程帶來了另一種風景,沒有累贅的代碼,沒有回調金字塔,代碼邏輯與設計思路映射完全一致。

基于此設計的 thunkify + co 組合,可以說極大地簡化了 Generator 的實現方式。thunkify 模塊是一個偏函數,用于將參數包含【執行參數】和【回調函數】的函數(比如fs.readFile(path[, options], callback))轉化為一個二級函數(形如readFile(path[, options])(callback)),而 co 模塊則完成對 Generator 函數的驅動,也就是上面代碼中 getData()run()實現的功能。

const co = require('co');
const thunkify = require('thunkify');

const fs = require('fs');
const readFile = thunkify(fs.readFile);

co(function* (){
    let dataA = yield readFile('a.txt', {encoding: 'utf8'});  
    console.log('dataA is %d', dataA);  
    let  dataB = yield readFile('b.txt', {encoding: 'utf8'});  
    console.log('dataB is %d', dataB); 
    let dataC = yield readFile('c.txt', {encoding: 'utf8'});  
    console.log('dataC is %d', dataC); 
    console.log('sum is %d', parseInt(dataA) + parseInt(dataB) + parseInt(dataC));
});
console.log('異步執行');

// $ node index.js
// 異步執行
// dataA is 1
// dataB is 2
// dataC is 3
// sum is 6

對照上面兩種實現方法,可以發現 co 幫我們處理掉了許多業務邏輯之外的累贅代碼。另外結果表明,程序依然還是完美地異步執行的,但我們已經基本看不出代碼的異步特性了。

Async/Await

在追求巔峰造極的路上,JS永遠是先鋒。ES7標準引入的 Async/Await 語法,可以說是JS異步編程的最佳實現。Async/Await 語法本質上只是 Generator 函數的語法糖,像 co 一樣,它內置了 Generator 函數的自動執行器,并且支持更簡潔更清晰的異步寫法。

const fs = require('fs');

// 封裝成 await 語句期望的 promise 對象
const readFile = function(){
    let args = arguments;
    return new Promise(function(resolve, reject){
        fs.readFile(...args, function(err, data){
            // await 會吸收 resolve 傳入的值作為返回值賦給變量
            resolve(data);
        })
    })
};

const asyncReadFile = async function(){
    let dataA = await readFile('a.txt', {encoding: 'utf8'});
    console.log('dataA is %d', dataA);
    let dataB = await readFile('b.txt', {encoding: 'utf8'});
    console.log('dataB is %d', dataB);
    let dataC = await readFile('c.txt', {encoding: 'utf8'});
    console.log('dataC is %d', dataC);
    console.log('sum is %d', parseInt(dataA) + parseInt(dataB) + parseInt(dataC));
};

asyncReadFile();
console.log('異步執行');

// $ node index.js
// 異步執行
// dataA is 1
// dataB is 2
// dataC is 3
// sum is 6

到這里,可能有些人已經不太敢相信這其實是一段異步代碼了。Async/Await 同樣需要借助 Promise 對象實現,但已經盡最大努力弱化了邏輯之外的輔助代碼了。當異步邏輯更加復雜時,Async/Await 語法編寫的異步代碼冗余成分比例將大大減小,我們所能注意到的,就只剩下優雅的邏輯代碼了。


以上多為個人心得,錯漏之處請指出。

參考自阮一峰ECMAScript 6 入門

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

推薦閱讀更多精彩內容