本文旨在通過實例演示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 入門