本文旨在通過(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)