JS異步編程——從一個(gè)應(yīng)用場(chǎng)景講起

本文旨在通過(guò)實(shí)例演示JS異步編程幾種常見(jiàn)的解決方案,讓你寫(xiě)出最優(yōu)雅的異步代碼。

異步是Javascript區(qū)別于其他編程語(yǔ)言的一個(gè)優(yōu)秀的特性。由于JS是單線(xiàn)程執(zhí)行的,如果沒(méi)有引入異步,站點(diǎn)頁(yè)面的性能將會(huì)是一個(gè)難以解決的、致命的問(wèn)題。而隨之node的興起,異步更是被推崇到極致,躍身成為網(wǎng)絡(luò)編程的強(qiáng)手。

一段完整的任務(wù)A執(zhí)行到一半,暫停,去執(zhí)行另外一段任務(wù)B,完了再回到斷點(diǎn)處繼續(xù)執(zhí)行A,這是異步編程最直觀的理解。這里的斷點(diǎn),最普遍的應(yīng)用場(chǎng)景就是發(fā)出一個(gè)耗時(shí)不可知的I/O任務(wù),http請(qǐng)求或文件讀取。本文就這樣一個(gè)邏輯場(chǎng)景的假定,來(lái)聊聊JS異步編程的解決方案(為簡(jiǎn)化思路,場(chǎng)景設(shè)計(jì)得略為蹩腳):

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

這類(lèi)場(chǎng)景所呈現(xiàn)出來(lái)的順序依賴(lài)的問(wèn)題,大多數(shù)情況下是可以通過(guò)修改設(shè)計(jì)邏輯來(lái)解決的,但有些時(shí)候并不適合。我曾在項(xiàng)目中遇到過(guò)向服務(wù)端按序同步做出多個(gè)http請(qǐng)求的需求,并且此時(shí)重新制定API的成本已經(jīng)相當(dāng)大了,所以,對(duì)于這種常見(jiàn)菜式,如何高效實(shí)現(xiàn)異步是JS開(kāi)發(fā)的關(guān)鍵。下邊討論的事件回調(diào)、發(fā)布/訂閱模式、Promise、Generator,以及最出神入化的 Async/Await 新語(yǔ)法,都是異步編程可選的解決渠道。


事件回調(diào)

事件(event)與回調(diào)(callback)在JS中隨處可見(jiàn),回調(diào)是常用的解決異步的方法,易于理解,在許多庫(kù)與函數(shù)中也容易實(shí)現(xiàn)。如果使用 node 原生支持的 fs.readFile() 來(lái)編寫(xiě)那會(huì)是這樣的:

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()會(huì)在文件 I/O 返回結(jié)果之后觸發(fā)回調(diào)函數(shù),通過(guò)這種嵌套的方式,我們能夠保證文件是按序讀取的,這種方式在JS中十分常見(jiàn),比如定時(shí)器setInterval()、setTimeout()。回調(diào)實(shí)現(xiàn)的異步代碼易于理解,但問(wèn)題也很明顯,層層嵌套使得代碼逐層縮進(jìn),嚴(yán)重降低了可讀性,我們把這種現(xiàn)象稱(chēng)為回調(diào)金字塔。

發(fā)布/訂閱模式

事件回調(diào)是通過(guò)觸發(fā)實(shí)現(xiàn)的,而發(fā)布/訂閱(pub/sub)模式實(shí)現(xiàn)的原理與事件回調(diào)基本一致。事實(shí)上,發(fā)布/訂閱模式更像是一般化的事件回調(diào),是對(duì)事件回調(diào)的拆解和拓展。和事件回調(diào)機(jī)制一樣,發(fā)布/訂閱模式需要提供回調(diào)函數(shù)(訂閱),不同的是事件回調(diào)機(jī)制是自行觸發(fā),而發(fā)布/訂閱模式把觸發(fā)權(quán)限交給了開(kāi)發(fā)者,因此你可以選擇在任意時(shí)刻觸發(fā)回調(diào)(發(fā)布)。

在事件機(jī)制上,node 內(nèi)置了一個(gè)功能強(qiáng)大的events模塊,對(duì)發(fā)布/訂閱模式提供了完美的支持,我們可以用它來(lái)實(shí)現(xiàn)這個(gè)應(yīng)用場(chǎng)景:

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++;
        // 驅(qū)動(dòng)執(zhí)行
        eventEmitter.emit('next', parseInt(data)+parseInt(newData));
    })
});

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

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

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

事件機(jī)制在JS編程中十分常見(jiàn),原生的 XHR 對(duì)象就是通過(guò)事件實(shí)現(xiàn) AJAX 請(qǐng)求的。但從上面的代碼我們可以看出這種模式解決深度嵌套問(wèn)題依然顯得吃力,events對(duì)象的引入、事件的訂閱和發(fā)布,讓代碼摻雜了許多邏輯以外的東西,十分晦澀難懂。人類(lèi)語(yǔ)言的語(yǔ)法邏輯是同步的,我們希望避開(kāi)丑陋的異步代碼,用同步的編寫(xiě)方式去實(shí)現(xiàn)異步邏輯。

Promise

在 ES6 出現(xiàn)之前,人們飽受“回調(diào)地獄”的煎熬,現(xiàn)在,我們大可以使用 ES6 提供的 Promise 對(duì)象實(shí)現(xiàn)異步,徹底地告別“回調(diào)地獄”。Promise 是對(duì)社區(qū)早有的 Promise/Deferred 模式的實(shí)現(xiàn),該模式最早出現(xiàn)在 jQuery1.5 版本,使得改寫(xiě)后的 Ajax 支持鏈?zhǔn)奖磉_(dá)。Promise 譯為“承諾”,個(gè)人理解為 Promise 對(duì)象承諾在異步操作完成后調(diào)用 then 方法指定的回調(diào)函數(shù),因此,Promise的本質(zhì)依然是事件回調(diào),是基于事件機(jī)制實(shí)現(xiàn)的。

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){
    // 返回一個(gè)新的 promise 對(duì)象,使得下一個(gè)回調(diào)函數(shù)會(huì)等待該異步操作完成
    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

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

Generator

Generator 函數(shù)是 ES6 標(biāo)準(zhǔn)引入的新特性,旨在提供一類(lèi)完全不同于以往異步編寫(xiě)方式的解決方案。依照阮一峰老師在《Generator 函數(shù)的語(yǔ)法》一文中的說(shuō)法,Generator 函數(shù)是一個(gè)狀態(tài)機(jī),封裝了多個(gè)內(nèi)部狀態(tài)。Generator 函數(shù)提供了一種機(jī)制,通過(guò)yield關(guān)鍵字和next()方法來(lái)交付和歸還線(xiàn)程執(zhí)行權(quán),實(shí)現(xiàn)代碼異步。我們前邊說(shuō)過(guò),異步直觀理解是中斷程序A去執(zhí)行B之后再回到斷點(diǎn)處繼續(xù)執(zhí)行A,本質(zhì)上這就是一種執(zhí)行權(quán)的借還。

Generator 函數(shù)不同于普通函數(shù),Generator 函數(shù)執(zhí)行到yield關(guān)鍵字處會(huì)自動(dòng)暫停,保護(hù)現(xiàn)場(chǎng),返回一個(gè)遍歷器(Iterator)對(duì)象,完成執(zhí)行權(quán)的交付。之后,我們通過(guò)調(diào)用該遍歷器對(duì)象的next()方法,回歸現(xiàn)場(chǎng),驅(qū)動(dòng) Generator 函數(shù)從斷點(diǎn)處繼續(xù)執(zhí)行,完成執(zhí)行權(quán)歸還。

歸還執(zhí)行權(quán)一般借助于 Promise 對(duì)象來(lái)實(shí)現(xiàn)。

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 在暫停時(shí)刻并沒(méi)有賦值,dataA 的值是在重新執(zhí)行時(shí)刻由 next 方法的參數(shù)傳入的
        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);
    }
};

// 驅(qū)動(dòng) Generator 執(zhí)行
function run (generator) {
    let it = generator();

    function go(result) {
        // 判斷是否遍歷完成,標(biāo)志位 result.done 為 true 表示遍歷完成
        if (result.done) return result.value;
        // result.value 即為返回的 promise 對(duì)象
        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()函數(shù)返回了一個(gè)負(fù)責(zé)讀取文件的 Promise 對(duì)象, Promise 對(duì)象會(huì)在讀取到數(shù)據(jù)后驅(qū)動(dòng) Generator 函數(shù)繼續(xù)執(zhí)行,run()函數(shù)是 Generator 函數(shù)的自動(dòng)執(zhí)行器。如果你把目光放到 Generator 函數(shù)上,你會(huì)驚訝地發(fā)現(xiàn),這異步代碼簡(jiǎn)直跟同步編寫(xiě)的一模一樣!可以說(shuō)Generator 函數(shù)的出現(xiàn),為JS異步編程帶來(lái)了另一種風(fēng)景,沒(méi)有累贅的代碼,沒(méi)有回調(diào)金字塔,代碼邏輯與設(shè)計(jì)思路映射完全一致。

基于此設(shè)計(jì)的 thunkify + co 組合,可以說(shuō)極大地簡(jiǎn)化了 Generator 的實(shí)現(xiàn)方式。thunkify 模塊是一個(gè)偏函數(shù),用于將參數(shù)包含【執(zhí)行參數(shù)】和【回調(diào)函數(shù)】的函數(shù)(比如fs.readFile(path[, options], callback))轉(zhuǎn)化為一個(gè)二級(jí)函數(shù)(形如readFile(path[, options])(callback)),而 co 模塊則完成對(duì) Generator 函數(shù)的驅(qū)動(dòng),也就是上面代碼中 getData()run()實(shí)現(xiàn)的功能。

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('異步執(zhí)行');

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

對(duì)照上面兩種實(shí)現(xiàn)方法,可以發(fā)現(xiàn) co 幫我們處理掉了許多業(yè)務(wù)邏輯之外的累贅代碼。另外結(jié)果表明,程序依然還是完美地異步執(zhí)行的,但我們已經(jīng)基本看不出代碼的異步特性了。

Async/Await

在追求巔峰造極的路上,JS永遠(yuǎn)是先鋒。ES7標(biāo)準(zhǔn)引入的 Async/Await 語(yǔ)法,可以說(shuō)是JS異步編程的最佳實(shí)現(xiàn)。Async/Await 語(yǔ)法本質(zhì)上只是 Generator 函數(shù)的語(yǔ)法糖,像 co 一樣,它內(nèi)置了 Generator 函數(shù)的自動(dòng)執(zhí)行器,并且支持更簡(jiǎn)潔更清晰的異步寫(xiě)法。

const fs = require('fs');

// 封裝成 await 語(yǔ)句期望的 promise 對(duì)象
const readFile = function(){
    let args = arguments;
    return new Promise(function(resolve, reject){
        fs.readFile(...args, function(err, data){
            // await 會(huì)吸收 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('異步執(zhí)行');

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

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


以上多為個(gè)人心得,錯(cuò)漏之處請(qǐng)指出。

參考自阮一峰ECMAScript 6 入門(mén)

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

推薦閱讀更多精彩內(nèi)容

  • 官方中文版原文鏈接 感謝社區(qū)中各位的大力支持,譯者再次奉上一點(diǎn)點(diǎn)福利:阿里云產(chǎn)品券,享受所有官網(wǎng)優(yōu)惠,并抽取幸運(yùn)大...
    HetfieldJoe閱讀 6,399評(píng)論 9 19
  • 你不知道JS:異步 第三章:Promises 在第二章,我們指出了采用回調(diào)來(lái)表達(dá)異步和管理并發(fā)時(shí)的兩種主要不足:缺...
    purple_force閱讀 2,122評(píng)論 0 4
  • 弄懂js異步 講異步之前,我們必須掌握一個(gè)基礎(chǔ)知識(shí)-event-loop。 我們知道JavaScript的一大特點(diǎn)...
    DCbryant閱讀 2,750評(píng)論 0 5
  • 官方中文版原文鏈接 感謝社區(qū)中各位的大力支持,譯者再次奉上一點(diǎn)點(diǎn)福利:阿里云產(chǎn)品券,享受所有官網(wǎng)優(yōu)惠,并抽取幸運(yùn)大...
    HetfieldJoe閱讀 8,700評(píng)論 0 29
  • 異步編程對(duì)JavaScript語(yǔ)言太重要。Javascript語(yǔ)言的執(zhí)行環(huán)境是“單線(xiàn)程”的,如果沒(méi)有異步編程,根本...
    呼呼哥閱讀 7,334評(píng)論 5 22