異步流程控制

單線程與異步

Javascript是單線程運行、支持異步機制的語言。進入正題以前,咱們有必要先理解這種運行方式。javascript

以「起牀上班」的過程爲例,假設有如下幾個步驟:java

  • 起牀(10min)
  • 洗刷(10min)
  • 換衣(5min)
  • 叫車(10min)
  • 上班(15min)

最簡單粗暴的執行方式就是按順序逐步執行,這樣從起牀到上班共需50分鐘,效率較低。若是能在「洗刷」以前先「叫車」,就能夠節省10分鐘的等車時間。ajax


這樣一來「叫車」就成了異步操做。但爲什麼只有「叫車」能夠異步呢?由於車不須要本身開過來,因此本身處於空閒狀態,能夠先乾點別的。npm

把上面的過程寫成代碼:編程

function 起牀() { console.info('起牀'); }
function 洗刷() { console.info('洗刷'); }
function 換衣() { console.info('換衣'); }
function 上班() { console.info('上班'); }
function 叫車(cb) {
    console.info('叫車');
    setTimeout(function() {
        cb('車來了');
    }, 1000);
}

起牀();
叫車(function() {
    上班();
});
洗刷();
換衣();
複製代碼

由於「上班」要在「叫車」以後才能執行,因此要做爲「叫車」的回調函數。然而,「叫車」須要10分鐘,「洗刷」也須要10分鐘,「洗刷」執行完後恰好車就到了,此時會不會先執行「上班」而不是「換衣」呢?Javascript是單線程的語言,它會先把當前的同步代碼執行完再去執行異步的回調。而異步的回調則是另外一片同步代碼,在這片代碼執行完以前,其餘的異步回調也不會被執行。因此「上班」不會先於「換衣」執行。數組

接下來考慮一種狀況:手機沒電了,想叫車得先充電。很明顯,充電的過程也能夠異步執行。整個過程應該是:promise


寫成代碼則是:
瀏覽器

function 充電(cb) {
    console.info('充電');
    setTimeout(function() {
        cb(0.1); // 0.1表示充了10%
    }, 1000);
}

起牀();
充電(function() {
    叫車(function() {
        上班();
    });
});
洗刷();
換衣()
複製代碼

充電、叫車、上班是異步串行(按順序執行)的,因此要把後者做爲前者的回調函數。可見,串行的異步操做越多,回調函數的嵌套就會越深,最終造成了回調金字塔(也叫回調地獄):
緩存

充電(function() {
    叫車(function() {
        其餘事情1(function() {
            其餘事情2(function() {
                其餘事情3(function() {
                    上班();
                });
            });
        });
    });
});
複製代碼

這樣的代碼極難閱讀,也極難維護。此外,還有更復雜的問題:bash

  • 除了異步串行,還有異步並行,甚至是串行、並行互相穿插。
  • 異步代碼的異常沒法經過try...catch捕獲,異常處理至關不方便。

可喜的是,隨着異步編程的發展,上面說起的這些問題愈來愈好解決了,下面就給你們介紹四種解決方案。

Async庫

Async是一個異步操做的工具庫,包含流程控制的功能。

async.series」即爲執行異步串行任務的方法。例如:

// 充電 -> 叫車
async.series([
    function(next) {
        充電(function(battery) {
            next(null, battery);
        });
    },
    function(next) {
        叫車(function(msg) {
            next(null, msg);
        });
    }
], function(err, results) {
    if (err) {
        console.error(err);
    } else {
        console.dir(results); // [0.1, '車來了']
        上班();
    }
});
複製代碼

「async.series」的第一個參數是要執行的步驟(數組),每個步驟都是一個函數。這個函數有一個參數「next」,異步操做完成後必須調用「next」:

  • 若是異步操做順利完成,則調用「next」時的第一個參數爲null,第二個參數爲執行結果;
  • 若是出現異常,則調用「next」時的第一個參數爲異常信息。

「async.series」的第二個參數則是這些步驟所有執行完成後的回調函數。其中:

  • 第一個參數是異常信息,不爲null時表示發生異常;
  • 第二個參數是由執行結果彙總而成的數組,順序與步驟的順序相對應。

async.waterfall」是另外一個用得更多的異步串行方法,它與「async.series」的區別是:把上一步的結果傳給下一步,而不是彙總到最後的回調函數。例如:

// 充電 -> 叫車
async.waterfall([
    function(next) {
        充電(function(battery) {
            next(null, battery);
        });
    },
    // battery爲上一步的next所傳的參數
    function(battery, next) {
        if (battery >= 0.1) {
            叫車(function(msg) {
                next(null, msg);
            });
        } else {
            next(new Error('電量不足'));
        }
    }
], function(err, result) {
    if (err) {
        console.error(err);
    } else {
        console.log(result); // '車來了'
        上班();
    }
});
複製代碼

而執行異步並行任務的方法則是「async.parallel」,用法與「async.series」相似,這裏就再也不詳細說明了。

那串行、並行相互穿插又是怎樣的呢?

// 從起牀到上班的整個過程
async.series([
    function(next) {
        起牀();
        next();
    },
    function(next) {
        async.parallel([
            function(next) {
                async.waterfall([
                    function(next) {
                        充電(function(battery) {
                            next(null, battery);
                        });
                    },
                    function(battery, next) {
                        if (battery >= 0.1) {
                            叫車(function(msg) {
                                next(null, msg);
                            });
                        } else {
                            next(new Error('電量不足'));
                        }
                    }
                ], next);
            },
            function(next) {
                洗刷();
                換衣();
                next();
            }
        ], next);
    }
], function(err, results) {
    if (err) {
        console.error(err);
    } else {
        上班();
    }
});
複製代碼

可見,若是串行和並行互相多穿插幾回,仍是會出現必定程度的回調金字塔現象。

Asycn庫的優勢是符合Node.js的異步編程模式(回調函數的第一個參數是異常信息,Node.js原生的異步接口都這樣)。然而它的缺點也正是如此,回調函數中有一個異常信息參數,還佔據了第一位,實在是太不方便了。

Promise

Promise是ES6標準的一部分,它提供了一種新的異步編程模式。可是ES6定稿比較晚,且舊的瀏覽器沒法支持新的標準,於是有一些第三方的實現(好比Bluebird,不只實現了Promise的標準,還進行了擴展)。順帶一提,Node.js 4.0+已經原生支持Promise。

那Promise到底是什麼玩意呢?Promise表明異步操做的最終結果,跟Promise交互的主要方式是經過它的「then」或者「catch」方法註冊回調函數去接收最終結果或者是不能完成的緣由(異常)。

使用Promise首先要把異步操做Promise化:

function 充電Promisify() {
    return new Promise(function(resolve) {
        充電(function(battery) {
            resolve(battery);
        });
        // 也能夠簡寫爲 充電(resolve)
    });
}

function 叫車Promisify(battery) {
    return new Promise(function(resolve, reject) {
        if (battery >= 0.1) {
            叫車(function(msg) {
                resolve(msg);
            });
            // 也能夠簡寫爲 叫車(resolve)
        } else {
            reject(new Error('電量不足'));
        }
    });
}
複製代碼

具體來講,就是建立一個Promise對象,建立時須要傳入一個函數,這個函數有兩個參數「resolve」和「reject」。操做成功時調用「resolve」,出現異常時調用「reject」。而想要得到異步操做的結果,正如前面所提到的,須要調用Promise對象的「then」方法:

叫車Promisify(0.1).then(function(result) {
    console.log(result); // '車來了'
}, function(err) {
    console.error(err);
});

叫車Promisify(0).then(function(result) {
    console.log(result);
}, function(err) {
    console.error(err.message);  // '電量不足'
});
複製代碼

「then」方法有兩個參數:

  • 第一個參數是操做成功(resolved)時的回調;
  • 第二個參數是操做拒絕(rejected)時的回調。

要注意的是,建立Promise對象時傳入的函數只會執行一次,即便屢次調用了「then」方法,該函數也不會重複執行。這樣一來,一個Promise實際上還緩存了異步操做的結果

下面看一下基於Promise的異步串行是怎樣的:

// 充電 -> 叫車
充電Promisify().then(function(battery) {
    return 叫車Promisify(battery);
}).then(function(result) {
    console.log(result); // '車來了'
    上班();
}).catch(function(err) {
    console.error(err);
});
複製代碼

若是「then」的回調函數返回的是一個Promise對象,那麼下一個「then」的回調函數就會在這個Promise對象完成以後再執行。因此多個步驟只須要經過「then」鏈式調用便可。此外,這段代碼的「then」只有一個參數,而異常則由「catch」方法統一處理。

接下來看一下異步並行,須要用到「Promise.all」這個方法:

// 充電、洗刷並行
Promise.all([
    充電Promisify(),
    new Promise(function(resolve) {
        洗刷();
        resolve();
    })
]).then(function(results) {
    console.dir(results); // [0.1, undefined]
}, function(err) {
    console.error(err);
});
複製代碼

最後是串行和並行穿插:

// 從起牀到上班的過程
new Promise(function(resolve) {
    起牀();
    resolve();
}).then(function() {
    return Promise.all([
        充電Promisify().then(function(battery) {
            return 叫車Promisify(battery);
        }),
        new Promise(function(resolve) {
            洗刷();
            換衣();
            resolve();
        })
    ]);
}).then(function(results) {
    console.dir(results); // ['車來了', undefined]
    上班();
}).catch(function(err) {
    console.error(err);
});
複製代碼

可見,基於Promise的異步代碼比Async庫的要簡潔得多,經過「then」的鏈式調用能夠很好地控制執行順序。可是因爲現有的大部分異步接口都不是基於Promise寫的,因此要進行二次封裝

順帶一提,其實jQuery的「$.ajax」方法返回的就是一個不徹底的Promise(沒有實現Promise的全部接口):

$.ajax('a.txt').then(function(resultA) {
    console.log(resultA);
    return $.ajax('b.txt');
}).then(function(resultB) {
    console.log(resultB);
});
複製代碼

Generator Function

Generator Function,中文譯名爲生成器函數,是ES6中的新特性。這種函數經過「function *」進行聲明,函數內部能夠經過「yield」關鍵字暫停函數執行

這是一個生成器函數的例子:

function* genFn() {
    console.log('begin');
    var value = yield 'a';
    console.log(value); // 'B'
    return 'end';
}

var gen = genFn();
console.log(typeof gen); // 'object'
var g1 = gen.next();
g1.value; // 'a'
g1.done; // false
var g2 = gen.next('B');
g2.value; // 'end'
g2.done; // true
複製代碼

若是是普通的函數,執行「genFn()」後就會返回「end」,但生成器函數並非這樣。執行「genFn()」後,其實是建立了一個生成器函數對象,此時函數內的代碼不會執行。而調用這個對象(gen)的「next」方法時,函數開始執行,直到「yield」暫停。「next」方法的返回值是一個對象,它有兩個屬性:

  • value:yield關鍵字後面的值(若是爲表達式,則爲表達式的計算結果);
  • done:函數是否執行完畢。

第二次調用「gen.next」時,傳入了一個參數值「B」。「next」方法的參數值即爲當前暫停函數的「yield」的返回值,因此函數內部value的值爲「B」。而後函數繼續執行,返回「end」。因此「g2.value」爲的值「end」,此時函數執行完畢,「g2.done」的值爲「true」。

那到底這玩意對異步編程有何助益呢?且看這段代碼:

function* 叫車Gen(battery) {
    try {
        var result = yield 叫車Promisify(battery);
        console.log(result); // '車來了'
    } catch (e) {
        console.error(e);
    }
}

var gen = 叫車Gen(0.1), promise = gen.next().value;
promise.then(function(result) {
    gen.next(result);
}, function(err) {
    gen.throw(err);
});
複製代碼

其執行過程大概是:執行異步操做後就暫停了「叫車Gen」的執行,異步操做完成後經過「gen.next」把「result」回傳到「叫車Gen」中;若是出現異常,就經過「gen.throw」拋出以便在「叫車Gen」裏面捕獲。

可是這樣繞來繞去又有什麼好處呢?仔細觀察能夠發現,「叫車Gen」內部雖然執行的是異步操做,但徹底就是同步的寫法(沒有回調函數,異常捕獲也是用常規的「try...catch」)。進一步思考,若是能把後面的細節封裝起來,那就真的能夠用同步的方式寫異步的代碼了。然後面的細節部分也是有規律可循的,封裝起來並非難事(只是有點繞):

function asyncByGen(genFn) {
    var gen = genFn();

    function nextStep(g) {
        if (g.done) { return; }

        if (g.value instanceof Promise) {
            g.value.then(function(result) {
                nextStep(gen.next(result));
            }, function(err) {
                gen.throw(err);
            });
        } else {
            nextStep(gen.next(g.value));
        }
    }

    nextStep(gen.next());
}
複製代碼

藉助這個函數,異步編程能夠史無前例地簡單:

// 異步串行:充電 -> 叫車
asyncByGen(function *() {
    try {
        var battery = yield 充電Promisify();
        console.log(
            yield 叫車Promisify(battery)
        ); // '車來了'
    } catch (e) {
        console.error(e);
    }
});
複製代碼

// 異步並行:充電、洗刷並行
asyncByGen(function *() {
    try {
    console.dir(
        yield Promise.all([
            充電Promisify(),
            new Promise(function(resolve) {
                洗刷();
                resolve()
            })
        ])
    ); // [0.1, undefined]
    } catch (e) {
        console.error(e);
    }
});
複製代碼

// 串行、並行互相穿插:從起牀到上班的過程
asyncByGen(function*() {
    try {
        起牀();
        console.dir(
            yield Promise.all([
                充電Promisify().then(function(battery) {
                    return 叫車Promisify(battery);
                }),
                new Promise(function(resolve) {
                    洗刷();
                    換衣();
                    resolve();
                })
            ])
        ); // [0.1, undefined]
        上班();
    } catch (e) {
         console.error(e);
    }
});
複製代碼

生成器函數是一種比較新的特性,雖然Node.js 4.0+已經原生支持,但在舊版本瀏覽器上確定沒法運行。所以若是要在瀏覽器端使用還得經過編譯器(如Babel)編譯成ES5的代碼,這也是這種解決方案的最大缺點。

講到這裏,順便介紹一下「co」庫。這個庫的功能相似於「asyncByGen」,但它封裝得更好,功能也更多,是用生成器函數寫異步代碼必不可少的利器。

async/await

若是你仍是看不懂生成器函數的執行過程,那也不要緊,由於它已經「過期」了!ES7提供了「async」、「await」兩個關鍵字,能夠達到跟「asyncByGen」同樣的效果。

首先給你們介紹一個這兩個關鍵字的用法。「async」是用來聲明異步函數的,這種函數的返回值老是Promise對象(即便函數內部返回的不是Promise對象,也會返回一個結果爲undefined的Promise對象)。

async function asyncFnA() {
    return Promise.resolve('A');
}
asyncFnA().then(function(result) {
    console.log(result); // 'A'
});

async function asyncFnB() {

}
asyncFnB().then(function(result) {
    console.log(result); // undefined
});
複製代碼

「await」只能用在由「async」聲明的異步函數的內部,它會等待其後的Promise對象肯定狀態後再執行後續的語句:

(async function() {
    var battery = await 充電Promisify();
    console.log(battery); // 0.1
})();
複製代碼

順帶提一下,「await」後面不必定非要跟着Promise對象,也能夠是一個普通的值,這樣至關因而執行同步代碼。

下面用「async/await」重寫上面的例子:

// 異步串行:充電 -> 洗刷
(async function() {
    try {
        var battery = await 充電Promisify();
        return await 叫車Promisify(battery);
    } catch (e) {
        console.error(e);
    }
})().then(function(msg) {
    console.log(msg); // 車來了
});
複製代碼

// 異步並行:充電、洗刷並行
(async function() {
    try {
        return await Promise.all([
            充電Promisify(),
            (async function() {
                洗刷();
            })()
        ]);
    } catch (e) {
        console.error(e);
    }
})().then((results) => {
    console.dir(results); // [0.1, undefined]
});
複製代碼

// 串行、並行互相穿插:從起牀到上班的過程
(async function() {
    try {
        起牀();
        console.dir(
            await Promise.all([
                充電Promisify().then(function(battery) {
                    return 叫車Promisify(battery);
                }),
                (async function() {
                    洗刷();
                    換衣();
                })()
            ])
        ); // [0.1, undefined]
        上班();
    } catch (e) {
        console.error(e);
    }
})();
複製代碼

可見,與生成器函數相比,「async/await」又使異步編程變得更爲簡單了。Node.js 7.6+以及大部分主流瀏覽器的最新版本都已經支持這兩個關鍵字了,但仍是那句話:若是要在瀏覽器端使用,編譯器(如Babel)是少不了的。

後記

本文的初版寫於2015年年末,如今(2017年中)重讀一遍,以爲有很多能夠改進的地方,並且技術也在不斷髮展,因而又修改了一遍。改動包括:

  • 把示例代碼由原來的「AJAX讀取文件」改爲文章開頭所述的「從起牀到上班的過程」。雖然用到了中文函數名,但都是能夠運行的。
  • 新增「async/await」一節。


本文也發表在做者我的博客:異步流程控制 | Node.js開發 | Heero's Blog

文章同步發佈在:[貝聊知乎]

相關文章
相關標籤/搜索