異步編程在JavaScript中非常重要。過多的異步編程也帶了回調(diào)嵌套的問題,本文會提供一些解決“回調(diào)地獄”的方法。
setTimeout(function () {
console.log('延時觸發(fā)');
}, 2000);
fs.readFile('./sample.txt', 'utf-8', function (err, res) {
console.log(res);
});
上面就是典型的回調(diào)函數(shù),不論是在瀏覽器中,還是在node中,JavaScript本身是單線程,因此,為了應對一些單線程帶來的問題,異步編程成為了JavaScript中非常重要的一部分。
不論是瀏覽器中最為常見的ajax、事件監(jiān)聽,還是node中文件讀取、網(wǎng)絡編程、數(shù)據(jù)庫等操作,都離不開異步編程。在異步編程中,許多操作都會放在回調(diào)函數(shù)(callback)中。同步與異步的混雜、過多的回調(diào)嵌套都會使得代碼變得難以理解與維護,這也是常受人詬病的地方。
先看下面這段代碼
fs.readFile('./sample.txt', 'utf-8', (err, content) => {
let keyword = content.substring(0, 5);
db.find(`select * from sample where kw = ${keyword}`, (err, res) => {
get(`/sampleget?count=${res.length}`, data => {
console.log(data);
});
});
});
首先我們讀取的一個文件中的關鍵字keyword,然后根據(jù)該keyword進行數(shù)據(jù)庫查詢,最后依據(jù)查詢結(jié)果請求數(shù)據(jù)。
其中包含了三個異步操作:
- 文件讀?。篺s.readFile
- 數(shù)據(jù)庫查詢:db.find
- http請求:get
可以看到,我們沒增加一個異步請求,就會多添加一層回調(diào)函數(shù)的嵌套,這段代碼中三個異步函數(shù)的嵌套已經(jīng)開始使一段本可以語言明確的代碼編程不易閱讀與維護了。
抽象出來這種代碼會變成下面這樣:
asyncFunc1(opt, (...args1) => {
asyncFunc2(opt, (...args2) => {
asyncFunc3(opt, (...args3) => {
asyncFunc4(opt, (...args4) => {
// some operation
});
});
});
});
左側(cè)明顯出現(xiàn)了一個三角形的縮進區(qū)域,過多的回調(diào)也就讓我們陷入“回調(diào)地獄”。接下來會介紹一些方法來規(guī)避回調(diào)地獄。
一、拆解function
回調(diào)嵌套所帶來的一個重要問題就是代碼不易閱讀與維護。因為普遍來說,過多的縮進(嵌套)會極大的影響代碼的可讀性。基于這一點,可以進行一個最簡單的優(yōu)化——將各步拆解為單個的function
function getData(count) {
get(`/sampleget?count=${count}`, data => {
console.log(data);
});
}
function queryDB(kw) {
db.find(`select * from sample where kw = ${kw}`, (err, res) => {
getData(res.length);
});
}
function readFile(filepath) {
fs.readFile(filepath, 'utf-8', (err, content) => {
let keyword = content.substring(0, 5);
queryDB(keyword);
});
}
readFile('./sample.txt');
可以看到,通過上面的改寫方式,代碼清晰了許多。該方法非常簡單,具有一定的效果,但是缺少通用性。
二、事件發(fā)布/監(jiān)聽模式
如果在瀏覽器中寫過事件監(jiān)聽addEventListener,那么你對這種事件發(fā)布/監(jiān)聽的模式一定不陌生。
借鑒這種思想,一方面,我們可以監(jiān)聽某一事件,當事件發(fā)生時,進行相應回調(diào)操作;另一方面,當某些操作完成后,通過發(fā)布事件觸發(fā)回調(diào)。這樣就可以將原本捆綁在一起的代碼解耦。
const events = require('events');
const eventEmitter = new events.EventEmitter();
eventEmitter.on('db', (err, kw) => {
db.find(`select * from sample where kw = ${kw}`, (err, res) => {
eventEmitter('get', res.length);
});
});
eventEmitter.on('get', (err, count) => {
get(`/sampleget?count=${count}`, data => {
console.log(data);
});
});
fs.readFile('./sample.txt', 'utf-8', (err, content) => {
let keyword = content.substring(0, 5);
eventEmitter. emit('db', keyword);
});
使用這種模式的實現(xiàn)需要一個事件發(fā)布/監(jiān)聽的庫。上面代碼中使用node原生的events模塊,當然你可以使用任何你喜歡的庫。
三、Promise
Promise是一種異步解決方案,最早由社區(qū)提出并實現(xiàn),后來寫進了es6規(guī)范。
目前一些主流的瀏覽器已經(jīng)原生實現(xiàn)了Promise的API,可以在Can I use里查看瀏覽器的支持情況。當然,如果想要做瀏覽器的兼容,可以考慮使用一些Promise的實現(xiàn)庫,例如bluebird、 Q等。下面以bluebird為例:
首先,我們需要將異步方法改寫為Promise,對于符合node規(guī)范的回調(diào)函數(shù)(第一個參數(shù)必須是Error),可以使用bluebird的promisify方法。該方法接收一個標準的異步方法并返回一個Promise對象。
const bluebird = require('bluebird');
const fs = require("fs");
const readFile = bluebird.promisify(fs.readFile);
這樣,readFile就變成了一個Promise對象。
但是,有的異步方法無法進行轉(zhuǎn)換,或者我們需要使用原生Promise,這就需要我們手動進行一些改造。下面提供一種改造的方法。
以fs.readFile為例,借助原生Promise來改造該方法:
const readFile = function (filepath) {
let resolve,
reject;
let promise = new Promise((_resolve, _reject) => {
resolve = _resolve;
reject = _reject;
});
let deferred = {
resolve,
reject,
promise
};
fs.readFile(filepath, 'utf-8', function (err, ...args) {
if (err) {
deferred.reject(err);
}
else {
deferred.resolve(...args);
}
});
return deferred.promise;
}
我們在方法中創(chuàng)建了一個Promise對象,并在異步回調(diào)中根據(jù)不同的情況使用reject與resolve來改變Promise對象的狀態(tài)。該方法返回這個Promise對象。其他的一些異步方法也可以參照這種方式進行改造。
假設通過改造,readFile、queryDB與getData方法均會返回一個Promise對象。代碼就變?yōu)榱耍?/p>
readFile('./sample.txt').then(content => {
let keyword = content.substring(0, 5);
return queryDB(keyword);
}).then(res => {
return getData(res.length);
}).then(data => {
console.log(data);
}).catch(err => {
console.warn(err);
});
可以看到,之前的嵌套操作編程了通過then連接的鏈式操作。代碼的整潔度上有了一個較大的提高。
四、generator
generator是es6中的一個新的語法。在function關鍵字后添加*即可將函數(shù)變?yōu)?code>generator。
const gen = function* () {
yield 1;
yield 2;
return 3;
}
執(zhí)行generator將會返回一個遍歷器對象,用于遍歷generator內(nèi)部的狀態(tài)。
let g = gen();
g.next(); // { value: 1, done: false }
g.next(); // { value: 2, done: false }
g.next(); // { value: 3, done: true }
g.next(); // { value: undefined, done: true }
可以看到,generator函數(shù)有一個最大的特點,可以在內(nèi)部執(zhí)行的過程中交出程序的控制權(quán),yield相當于起到了一個暫停的作用;而當一定情況下,外部又將控制權(quán)再移交回來。
想象一下,我們用generator來封裝代碼,在異步任務處使用yield關鍵詞,此時generator會將程序執(zhí)行權(quán)交給其他代碼,而在異步任務完成后,調(diào)用next方法來恢復yield下方代碼的執(zhí)行。以readFile為例,大致流程如下:
// 我們的主任務——顯示關鍵字
// 使用yield暫時中斷下方代碼執(zhí)行
// yield后面為promise對象
const showKeyword = function* (filepath) {
console.log('開始讀取');
let keyword = yield readFile(filepath);
console.log(`關鍵字為${filepath}`);
}
// generator的流程控制
let gen = showKeyword();
let res = gen.next();
res.value.then(res => gen.next(res));
在主任務部分,原本readFile異步的部分變成了類似同步的寫法,代碼變得非常清晰。而在下半部分,則是對于什么時候需要移交回控制權(quán)給generator的流程控制。
然而,我們需要手動控制generator的流程,如果能夠自動執(zhí)行generator——在需要的時候自動移交控制權(quán),那么會更加具有實用性。
為此,我們可以使用 co 這個庫。它可以是省去我們對于generator流程控制的代碼
const co = reuqire('co');
// 我們的主任務——顯示關鍵字
// 使用yield暫時中斷下方代碼執(zhí)行
// yield后面為promise對象
const showKeyword = function* (filepath) {
console.log('開始讀取');
let keyword = yield readFile(filepath);
console.log(`關鍵字為${filepath}`);
}
// 使用co
co(showKeyword);
其中,yeild關鍵字后面需要是functio, promise, generator, array或object??梢愿膶懳恼乱婚_始的例子:
const co = reuqire('co');
const task = function* (filepath) {
let keyword = yield readFile(filepath);
let count = yield queryDB(keyword);
let data = yield getData(res.length);
console.log(data);
});
co(task, './sample.txt');
五、async/await
可以看到,上面的方法雖然都在一定程度上解決了異步編程中回調(diào)帶來的問題。然而
- function拆分的方式其實僅僅只是拆分代碼塊,時常會不利于后續(xù)維護;
- 事件發(fā)布/監(jiān)聽方式模糊了異步方法之間的流程關系;
-
Promise雖然使得多個嵌套的異步調(diào)用能夠通過鏈式的API進行操作,但是過多的then也增加了代碼的冗余,也對閱讀代碼中各階段的異步任務產(chǎn)生了一定干擾; - 通過
generator雖然能提供較好的語法結(jié)構(gòu),但是畢竟generator與yield的語境用在這里多少還有些不太貼切。
因此,這里再介紹一個方法,它就是es7中的async/await。
簡單介紹一下async/await?;旧?,任何一個函數(shù)都可以成為async函數(shù),以下都是合法的書寫形式:
async function foo () {};
const foo = async function () {};
const foo = async () => {};
在async函數(shù)中可以使用await語句。await后一般是一個Promise對象。
async function foo () {
console.log('開始');
let res = await post(data);
console.log(`post已完成,結(jié)果為:${res}`);
};
當上面的函數(shù)執(zhí)行到await時,可以簡單理解為,函數(shù)掛起,等待await后的Promise返回,再執(zhí)行下面的語句。
值得注意的是,這段異步操作的代碼,看起來就像是“同步操作”。這就大大方便了異步代碼的編寫與閱讀。下面改寫我們的例子。
const printData = async function (filepath) {
let keyword = await readFile(filepath);
let count = await queryDB(keyword);
let data = await getData(res.length);
console.log(data);
});
printData('./sample.txt');
可以看到,代碼簡潔清晰,異步代碼也具有了“同步”代碼的結(jié)構(gòu)。
注意,其中readFile、queryDB與getData方法都需要返回一個Promise對象。這可以通過在第三部分Promise里提供的方式進行改寫。
后記
異步編程作為JavaScript中的一部分,具有非常重要的位置,它幫助我們避免同步代碼帶來的線程阻塞的同時,也為編碼與閱讀帶來了一定的困難。過多的回調(diào)嵌套很容易會讓我們陷入“回調(diào)地獄”中,使代碼變成一團亂麻。為了解決“回調(diào)地獄”,我們可以使用文中所述的這五種常用方法:
- function拆解
- 事件發(fā)布/訂閱模式
- Promise
- Generator
- async / await
理解各類方法的原理與實現(xiàn)方式,了解其中利弊,可以幫助我們更好得進行異步編程。