JavaScript異步編程__“回調地獄”的一些解決方案

異步編程在JavaScript中非常重要。過多的異步編程也帶了回調嵌套的問題,本文會提供一些解決“回調地獄”的方法。

setTimeout(function () {
    console.log('延時觸發');
}, 2000);

fs.readFile('./sample.txt', 'utf-8', function (err, res) {
    console.log(res);
});

上面就是典型的回調函數,不論是在瀏覽器中,還是在node中,JavaScript本身是單線程,因此,為了應對一些單線程帶來的問題,異步編程成為了JavaScript中非常重要的一部分。

不論是瀏覽器中最為常見的ajax、事件監聽,還是node中文件讀取、網絡編程、數據庫等操作,都離不開異步編程。在異步編程中,許多操作都會放在回調函數(callback)中。同步與異步的混雜、過多的回調嵌套都會使得代碼變得難以理解與維護,這也是常受人詬病的地方。

先看下面這段代碼

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,然后根據該keyword進行數據庫查詢,最后依據查詢結果請求數據。

其中包含了三個異步操作:

  • 文件讀取:fs.readFile
  • 數據庫查詢:db.find
  • http請求:get

可以看到,我們沒增加一個異步請求,就會多添加一層回調函數的嵌套,這段代碼中三個異步函數的嵌套已經開始使一段本可以語言明確的代碼編程不易閱讀與維護了。

抽象出來這種代碼會變成下面這樣:

asyncFunc1(opt, (...args1) => {
    asyncFunc2(opt, (...args2) => {
        asyncFunc3(opt, (...args3) => {
            asyncFunc4(opt, (...args4) => {
                // some operation
            });
        });
    });
});

左側明顯出現了一個三角形的縮進區域,過多的回調也就讓我們陷入“回調地獄”。接下來會介紹一些方法來規避回調地獄。

一、拆解function

回調嵌套所帶來的一個重要問題就是代碼不易閱讀與維護。因為普遍來說,過多的縮進(嵌套)會極大的影響代碼的可讀性。基于這一點,可以進行一個最簡單的優化——將各步拆解為單個的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');

可以看到,通過上面的改寫方式,代碼清晰了許多。該方法非常簡單,具有一定的效果,但是缺少通用性。

二、事件發布/監聽模式

如果在瀏覽器中寫過事件監聽addEventListener,那么你對這種事件發布/監聽的模式一定不陌生。

借鑒這種思想,一方面,我們可以監聽某一事件,當事件發生時,進行相應回調操作;另一方面,當某些操作完成后,通過發布事件觸發回調。這樣就可以將原本捆綁在一起的代碼解耦。

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);
});

使用這種模式的實現需要一個事件發布/監聽的庫。上面代碼中使用node原生的events模塊,當然你可以使用任何你喜歡的庫。

三、Promise

Promise是一種異步解決方案,最早由社區提出并實現,后來寫進了es6規范。

目前一些主流的瀏覽器已經原生實現了Promise的API,可以在Can I use里查看瀏覽器的支持情況。當然,如果想要做瀏覽器的兼容,可以考慮使用一些Promise的實現庫,例如bluebirdQ等。下面以bluebird為例:

首先,我們需要將異步方法改寫為Promise,對于符合node規范的回調函數(第一個參數必須是Error),可以使用bluebird的promisify方法。該方法接收一個標準的異步方法并返回一個Promise對象。

const bluebird = require('bluebird');
const fs = require("fs");
const readFile = bluebird.promisify(fs.readFile);

這樣,readFile就變成了一個Promise對象。

但是,有的異步方法無法進行轉換,或者我們需要使用原生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;
}

我們在方法中創建了一個Promise對象,并在異步回調中根據不同的情況使用rejectresolve來改變Promise對象的狀態。該方法返回這個Promise對象。其他的一些異步方法也可以參照這種方式進行改造。

假設通過改造,readFilequeryDBgetData方法均會返回一個Promise對象。代碼就變為了:

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關鍵字后添加*即可將函數變為generator

const gen = function* () {
    yield 1;
    yield 2;
    return 3;
}

執行generator將會返回一個遍歷器對象,用于遍歷generator內部的狀態。

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函數有一個最大的特點,可以在內部執行的過程中交出程序的控制權,yield相當于起到了一個暫停的作用;而當一定情況下,外部又將控制權再移交回來。

想象一下,我們用generator來封裝代碼,在異步任務處使用yield關鍵詞,此時generator會將程序執行權交給其他代碼,而在異步任務完成后,調用next方法來恢復yield下方代碼的執行。以readFile為例,大致流程如下:

// 我們的主任務——顯示關鍵字
// 使用yield暫時中斷下方代碼執行
// 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異步的部分變成了類似同步的寫法,代碼變得非常清晰。而在下半部分,則是對于什么時候需要移交回控制權給generator的流程控制。

然而,我們需要手動控制generator的流程,如果能夠自動執行generator——在需要的時候自動移交控制權,那么會更加具有實用性。

為此,我們可以使用 co 這個庫。它可以是省去我們對于generator流程控制的代碼

const co = reuqire('co');
// 我們的主任務——顯示關鍵字
// 使用yield暫時中斷下方代碼執行
// yield后面為promise對象
const showKeyword = function* (filepath) {
    console.log('開始讀取');
    let keyword = yield readFile(filepath);
    console.log(`關鍵字為${filepath}`);
}

// 使用co
co(showKeyword);

其中,yeild關鍵字后面需要是functio, promise, generator, arrayobject。可以改寫文章一開始的例子:

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

可以看到,上面的方法雖然都在一定程度上解決了異步編程中回調帶來的問題。然而

  • function拆分的方式其實僅僅只是拆分代碼塊,時常會不利于后續維護;
  • 事件發布/監聽方式模糊了異步方法之間的流程關系;
  • Promise雖然使得多個嵌套的異步調用能夠通過鏈式的API進行操作,但是過多的then也增加了代碼的冗余,也對閱讀代碼中各階段的異步任務產生了一定干擾;
  • 通過generator雖然能提供較好的語法結構,但是畢竟generatoryield的語境用在這里多少還有些不太貼切。

因此,這里再介紹一個方法,它就是es7中的async/await。

簡單介紹一下async/await。基本上,任何一個函數都可以成為async函數,以下都是合法的書寫形式:

async function foo () {};
const foo = async function () {};
const foo = async () => {};

async函數中可以使用await語句。await后一般是一個Promise對象。

async function foo () {
    console.log('開始');
    let res = await post(data);
    console.log(`post已完成,結果為:${res}`);
};

當上面的函數執行到await時,可以簡單理解為,函數掛起,等待await后的Promise返回,再執行下面的語句。

值得注意的是,這段異步操作的代碼,看起來就像是“同步操作”。這就大大方便了異步代碼的編寫與閱讀。下面改寫我們的例子。

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');

可以看到,代碼簡潔清晰,異步代碼也具有了“同步”代碼的結構。

注意,其中readFilequeryDBgetData方法都需要返回一個Promise對象。這可以通過在第三部分Promise里提供的方式進行改寫。

后記

異步編程作為JavaScript中的一部分,具有非常重要的位置,它幫助我們避免同步代碼帶來的線程阻塞的同時,也為編碼與閱讀帶來了一定的困難。過多的回調嵌套很容易會讓我們陷入“回調地獄”中,使代碼變成一團亂麻。為了解決“回調地獄”,我們可以使用文中所述的這五種常用方法:

  • function拆解
  • 事件發布/訂閱模式
  • Promise
  • Generator
  • async / await

理解各類方法的原理與實現方式,了解其中利弊,可以幫助我們更好得進行異步編程。


Happy Coding!


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

推薦閱讀更多精彩內容

  • 異步編程對JavaScript語言太重要。Javascript語言的執行環境是“單線程”的,如果沒有異步編程,根本...
    呼呼哥閱讀 7,323評論 5 22
  • 弄懂js異步 講異步之前,我們必須掌握一個基礎知識-event-loop。 我們知道JavaScript的一大特點...
    DCbryant閱讀 2,744評論 0 5
  • 本文旨在通過實例演示JS異步編程幾種常見的解決方案,讓你寫出最優雅的異步代碼。 異步是Javascript區別于其...
    沐童Hankle閱讀 3,392評論 2 3
  • 本文首發在個人博客:http://muyunyun.cn/posts/7b9fdc87/ 提到 Node.js, ...
    牧云云閱讀 1,700評論 0 3
  • 選擇權 一七七五年,美國人帕特里克·亨利面對英國政府的殖民統治,向同胞大聲疾呼:不自由,毋寧死!(give me ...
    江左不歸人閱讀 234評論 0 0