如何避免回撥地獄

廣州蘆葦科技web前端發表於2018-12-15

問題來源

平時我們日常寫程式碼中,可能會遇到這種某個回撥有非同步請求,請求的回撥又有非同步請求、迴圈

目前有幾個比較好的解決方法

  1. 拆解function
  2. 事件釋出/監聽模式
  3. Promise
  4. generator
  5. async/await

先來看一點程式碼

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);
        });
    });
});
複製程式碼

以上程式碼包括了三個非同步操作:

  • 檔案讀取: fs.readFile
  • 資料庫查詢:db.find
  • http請求:get

我們每增加一個非同步請求,就會多新增一層回撥函式的巢狀,這樣下去,可讀性會越來越低,也不易於以後的程式碼維護。過多的回撥也就讓我們陷入“回撥地獄”。接下來會大概介紹一下規避回撥地獄的方法。

1、拆分function

回撥巢狀所帶來的一個重要的問題就是程式碼不易閱讀與維護。因為普遍來說,過多的巢狀(縮排)會極大的影響程式碼的可讀性。基於這一點,可以進行一個最簡單的優化----將各個步驟拆解為單個function

//HTTP請求
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');
複製程式碼

通過改寫,再加上註釋,可以很清晰的知道這段程式碼要做的事情。該方法非常簡單,具有一定的效果,但是缺少通用性。

2、事件釋出/監聽模式

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

複製程式碼

events 模組是node原生模組,用node實現這種模式只需要一個事件釋出/監聽的庫。

3、Promise

Promise是es6的規範 首先,我們需要將非同步方法改寫成Promise,對於符合node規範的回撥函式(第一個引數必須是Error), 可以使用bluebird的promisify方法。該方法接受一個標準的非同步方法並返回一個Promise物件

const bluebird = require('bluebird');
const fs = require("fs");
const readFile = bluebird.promisify(fs.readFile);
複製程式碼

這樣fs.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物件,並在非同步回撥中根據不同的情況使用reject與resolve來改變Promise物件的狀態。該方法返回這個Promise物件。其他的一些非同步方法可以參照這種方式進行改造。 假設通過改造,readFile、queryDB與getData方法均會返回一個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的鏈式改造。使程式碼的整潔度在一定的程度上有了一個較大的提高。

4、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));
複製程式碼
ps:這部分暫時沒理清楚,待續

5、async/await

可以看到,上面的方法雖然都在一定程度上解決了非同步程式設計中回撥帶來的問題。然而

  • function拆分的方式其實僅僅只是拆分程式碼塊,時常會不利於後續的維護;
  • 事件釋出/監聽方式模糊了非同步方法之間的流程關係;
  • Promise雖然使得多個巢狀的非同步呼叫能通過鏈式API進行操作,但是過多的then也增加了程式碼的冗餘,也對閱讀程式碼中各個階段的非同步任務產生了一定的干擾;
  • 通過generator雖然能提供較好的語法結構,但是畢竟generator與yield的語境用在這裡多少還有點不太貼切。

因此,這裡在介紹一個方法,它就是es7中的async/await。 簡單介紹一下async/await。基本上,任何一個函式都可以成為async函式,以下都是合法的書寫形式

async function foo () {};
const foo = async function () {};
const foo = async () => {};
複製程式碼

未完待續——

  • 作者簡介:何永峰,蘆葦科技web前端開發工程師,喜歡到處尋找好吃的,平時愛好是跳舞,打籃球,聽音樂,有時會出席一些大型的舞蹈商演活動,目前是Acum.Revolution現狀革命成員之一。並且代表作品:萌雞駕到、美旅出行小程式、電競桌子小程式。擅長網站建設、公眾號開發、微信小程式開發、小遊戲、公眾號開發,專注於前端領域框架、互動設計、影象繪製、資料分析等研究,訪問 www.talkmoney.cn 瞭解更多。

相關文章