異步編程解決方案筆記

最近讀了樸靈老師的《深入淺出NodeJS》中《異步編程》一章,並參考了一些有趣的文章。
在此做個筆記,記錄並鞏固學到的知識。
JavaScript異步編程的兩個核心難點

異步I/O、事件驅動使得單線程的JavaScript得以在不阻塞UI的情況下執行網絡、文件訪問功能,
且使之在後端實現了較高的性能。然而異步風格也引來了一些麻煩,其中比較核心的問題是:

一,函數嵌套過深

JavaScript的異步調用基於回調函數,當多個異步事務多級依賴時,回調函數會形成多級的嵌套,代碼變成
金字塔型結構。這不僅使得代碼變難看難懂,更使得調試、重構的過程充滿風險。

二,異常處理

回調嵌套不僅僅是使代碼變得雜亂,也使得錯誤處理更復雜。

異步編程中可能拋出錯誤的情況有兩種:

三,異步函數錯誤

由於異步函數是立刻返回的,異步事務中發生的錯誤是無法通過try-catch來捕捉的,只能採用由調用方提供錯誤處理回調的方案來解決。
例如Node中常見的function (err, …) {…}回調函數,就是Node中處理錯誤的約定:
即將錯誤作爲回調函數的第一個實參返回。
再比如HTML5FileReader對象的onerror函數,會被用於處理異步讀取文件過程中的錯誤。

四,回調函數錯誤

由於回調函數執行時,異步函數的上下文已經不存在了,通過try-catch無法捕捉回調函數內的錯誤。

可見,異步回調編程風格基本上廢掉了try-catchthrow。另外回調函數中的return也失去了意義,這會使我們的程序必須依賴於副作用。
這使得JavaScript的三個語義失效,同時又得引入新的錯誤處理方案,如果沒有像Node那樣統一的錯誤處理約定,問題會變得更加麻煩。
幾種解決方案

下面對幾種解決方案的討論主要集中於上面提到的兩個核心問題上,當然也會考慮其他方面的因素來評判其優缺點。

Async.js

首先是Node中非常著名的Async.js,這個庫能夠在Node中展露頭角,恐怕也得歸功於Node統一的錯誤處理約定。
而在前端,一開始並沒有形成這麼統一的約定,因此使用Async.js的話可能需要對現有的庫進行封裝。

Async.js的其實就是給回調函數的幾種常見使用模式加了一層包裝。比如我們需要三個前後依賴的異步操作,採用純回調函數寫法如下:

asyncOpA(a, b, (err, result) => {
    if (err) {
        handleErrorA(err);
    }
    asyncOpB(c, result, (err, result) => {
        if (err) {
            handleErrorB(err);
        }
        asyncOpB(d, result, (err, result) => {
            if (err) {
                handlerErrorC(err);
            }
            finalOp(result);
        });
    });
});

如果我們採用async庫來做:

async.waterfall([
    (cb) => {
        asyncOpA(a, b, (err, result) => {
            cb(err, c, result);
        });
    },
    (c, lastResult, cb) => {
        asyncOpB(c, lastResult, (err, result) => {
            cb(err, d, result);
        })
    },
    (d, lastResult, cb) => {
        asyncOpC(d, lastResult, (err, result) => {
            cb(err, result);
        });
    }
], (err, finalResult) => {
    if (err) {
        handlerError(err);
    }
    finalOp(finalResult);
});

可以看到,回調函數由原來的橫向發展轉變爲縱向發展,同時錯誤被統一傳遞到最後的處理函數中。
其原理是,將函數數組中的後一個函數包裝後作爲前一個函數的末參數cb傳入,同時要求:

  • 每一個函數都應當執行其cb參數;
  • cb的第一個參數用來傳遞錯誤。

我們可以自己寫一個async.waterfall的實現:

let async = {
    waterfall: (methods, finalCb = _emptyFunction) => {
        if (!_isArray(methods)) {
            return finalCb(new Error('First argument to waterfall must be an array of functions'));
        }
        if (!methods.length) {
            return finalCb();
        }
        function wrap(n) {
            if (n === methods.length) {
                return finalCb;
            }
            return function (err, ...args) {
                if (err) {
                    return finalCb(err);
                }
                methods[n](...args, wrap(n + 1));
            }
        }
        wrap(0)(false);
    }
};

Async.js還有series/parallel/whilst等多種流程控制方法,來實現常見的異步協作。

Async.js的問題是:

在外在上依然沒有擺脫回調函數,只是將其從橫向發展變爲縱向,還是需要程序員熟練異步回調風格。

錯誤處理上仍然沒有利用上try-catch和throw,依賴於“回調函數的第一個參數用來傳遞錯誤”這樣的一個約定。

Promise方案

ES6Promise來源於Promise/A+。使用Promise來進行異步流程控制,有幾個需要注意的問題,
We have a problem with promises一文中有很好的總結。

把前面提到的功能用Promise來實現,需要先包裝異步函數,使之能返回一個Promise:

function toPromiseStyle(fn) {
    return (...args) => {
        return new Promise((resolve, reject) => {
            fn(...args, (err, result) => {
                if (err) reject(err);
                resolve(result);
            })
        });
    };
}

這個函數可以把符合下述規則的異步函數轉換爲返回Promise的函數:

回調函數的第一個參數用於傳遞錯誤,第二個參數用於傳遞正常的結果。

接着就可以進行操作了:

let [opA, opB, opC] = [asyncOpA, asyncOpB, asyncOpC].map((fn) => toPromiseStyle(fn));

opA(a, b)
    .then((res) => {
        return opB(c, res);
    })
    .then((res) => {
        return opC(d, res);
    })
    .then((res) => {
        return finalOp(res);
    })
    .catch((err) => {
        handleError(err);
    });

通過Promise,原來明顯的異步回調函數風格顯得更像同步編程風格,我們只需要使用then方法將結果傳遞下去即可,同時return也有了相應的意義:
在每一個then的onFullfilled函數(以及onRejected)裏的return,都會爲下一個thenonFullfilled函數(以及onRejected)的參數設定好值。

如此一來,return、try-catch/throw都可以使用了,但catch是以方法的形式出現,還是不盡如人意。
Generator方案

ES6引入的Generator可以理解爲可在運行中轉移控制權給其他代碼,並在需要的時候返回繼續執行的函數。利用Generator可以實現協程的功能。

將Generator與Promise結合,可以進一步將異步代碼轉化爲同步風格:

function* getResult() {
    let res, a, b, c, d;
    try {
        res = yield opA(a, b);
        res = yield opB(c, res);
        res = yield opC(d);
        return res;
    } catch (err) {
        return handleError(err);
    }
}

然而我們還需要一個可以自動運行Generator的函數:

function spawn(genF, ...args) {
    return new Promise((resolve, reject) => {
        let gen = genF(...args);

        function next(fn) {
            try {
                let r = fn();
                if (r.done) {
                    resolve(r.value);
                }
                Promise.resolve(r.value)
                    .then((v) => {
                        next(() => {
                            return gen.next(v);
                        });
                    }).catch((err) => {
                        next(() => {
                            return gen.throw(err);
                        })
                    });
            } catch (err) {
                    reject(err);
            }
        }

        next(() => {
            return gen.next(undefined);
        });
    });
}

用這個函數來調用Generator即可:

spawn(getResult)
    .then((res) => {
        finalOp(res);
    })
    .catch((err) => {
        handleFinalOpError(err);
    });

可見try-catchreturn實際上已經以其原本面貌回到了代碼中,在代碼形式上也已經看不到異步風格的痕跡。

類似的功能有co/task.js等庫實現。
ES7的async/await

ES7中將會引入async functionawait關鍵字,利用這個功能,我們可以輕鬆寫出同步風格的代碼,
同時依然可以利用原有的異步I/O機制。

採用async function,我們可以將之前的代碼寫成這樣:

async function getResult() {
    let res, a, b, c, d;
    try {
        res = await opA(a, b);
        res = await opB(c, res);
        res = await opC(d);
        return res;
    } catch (err) {
        return handleError(err);
    }
}

getResult();

Generator & Promise方案看起來沒有太大區別,只是關鍵字換了換。
實際上async function就是對Generator方案的一個官方認可,將之作爲語言內置功能。

async function的缺點是:

await只能在async function內部使用,因此一旦你寫了幾個async function
或者使用了依賴於async function的庫,那你很可能會需要更多的async function

目前處於提案階段的async function還沒有得到任何瀏覽器或Node.JS/io.js的支持。

Babel轉碼器也需要打開實驗選項,並且對於不支持Generator的瀏覽器來說,
還需要引進一層厚厚的regenerator runtime,想在前端生產環境得到應用還需要時間。

參考

  1. A Study on Solving Callbacks with JavaScript Generators

  2. Async Functions

  3. 異步操作

  4. Promise - JavaScript MDN

  5. We have a problem with promises

  6. Taming the asynchronous beast with ES7

  7. Managing Node.js Callback Hell

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章