[貝聊科技]異步流程控制

單線程與異步

Javascript是單線程運(yùn)行、支持異步機(jī)制的語言。進(jìn)入正題之前,我們有必要先理解這種運(yùn)行方式。

以「起床上班」的過程為例,假設(shè)有以下幾個(gè)步驟:

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

最簡(jiǎn)單粗暴的執(zhí)行方式就是按順序逐步執(zhí)行,這樣從起床到上班共需50分鐘,效率較低。如果能在「洗刷」之前先「叫車」,就可以節(jié)省10分鐘的等車時(shí)間。

image.png

這樣一來「叫車」就成了異步操作。但為何只有「叫車」可以異步呢?因?yàn)檐嚥恍枰约洪_過來,所以自己處于空閑狀態(tài),可以先干點(diǎn)別的。

把上面的過程寫成代碼:

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

起床();
叫車(function() {
    上班();
});
洗刷();
換衣();

因?yàn)椤干习唷挂凇附熊嚒怪蟛拍軋?zhí)行,所以要作為「叫車」的回調(diào)函數(shù)。然而,「叫車」需要10分鐘,「洗刷」也需要10分鐘,「洗刷」執(zhí)行完后剛好車就到了,此時(shí)會(huì)不會(huì)先執(zhí)行「上班」而不是「換衣」呢?Javascript是單線程的語言,它會(huì)先把當(dāng)前的同步代碼執(zhí)行完再去執(zhí)行異步的回調(diào)。而異步的回調(diào)則是另一片同步代碼,在這片代碼執(zhí)行完之前,其他的異步回調(diào)也不會(huì)被執(zhí)行。所以「上班」不會(huì)先于「換衣」執(zhí)行。

接下來考慮一種情況:手機(jī)沒電了,想叫車得先充電。很明顯,充電的過程也可以異步執(zhí)行。整個(gè)過程應(yīng)該是:

image.png

寫成代碼則是:

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

起床();
充電(function() {
    叫車(function() {
        上班();
    });
});
洗刷();
換衣()

充電、叫車、上班是異步串行(按順序執(zhí)行)的,所以要把后者作為前者的回調(diào)函數(shù)??梢?,串行的異步操作越多,回調(diào)函數(shù)的嵌套就會(huì)越深,最終形成了回調(diào)金字塔(也叫回調(diào)地獄):

充電(function() {
    叫車(function() {
        其他事情1(function() {
            其他事情2(function() {
                其他事情3(function() {
                    上班();
                });
            });
        });
    });
});

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

  • 除了異步串行,還有異步并行,甚至是串行、并行互相穿插。
  • 異步代碼的異常無法通過try...catch捕獲,異常處理相當(dāng)不方便。

可喜的是,隨著異步編程的發(fā)展,上面提及的這些問題越來越好解決了,下面就給大家介紹四種解決方案。

Async庫

Async是一個(gè)異步操作的工具庫,包含流程控制的功能。

「async.series」即為執(zhí)行異步串行任務(wù)的方法。例如:

// 充電 -> 叫車
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」的第一個(gè)參數(shù)是要執(zhí)行的步驟(數(shù)組),每一個(gè)步驟都是一個(gè)函數(shù)。這個(gè)函數(shù)有一個(gè)參數(shù)「next」,異步操作完成后必須調(diào)用「next」:

  • 如果異步操作順利完成,則調(diào)用「next」時(shí)的第一個(gè)參數(shù)為null,第二個(gè)參數(shù)為執(zhí)行結(jié)果;
  • 如果出現(xiàn)異常,則調(diào)用「next」時(shí)的第一個(gè)參數(shù)為異常信息。

「async.series」的第二個(gè)參數(shù)則是這些步驟全部執(zhí)行完成后的回調(diào)函數(shù)。其中:

  • 第一個(gè)參數(shù)是異常信息,不為null時(shí)表示發(fā)生異常;
  • 第二個(gè)參數(shù)是由執(zhí)行結(jié)果匯總而成的數(shù)組,順序與步驟的順序相對(duì)應(yīng)。

「async.waterfall」是另一個(gè)用得更多的異步串行方法,它與「async.series」的區(qū)別是:把上一步的結(jié)果傳給下一步,而不是匯總到最后的回調(diào)函數(shù)。例如:

// 充電 -> 叫車
async.waterfall([
    function(next) {
        充電(function(battery) {
            next(null, battery);
        });
    },
    // battery為上一步的next所傳的參數(shù)
    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); // '車來了'
        上班();
    }
});

而執(zhí)行異步并行任務(wù)的方法則是「async.parallel」,用法與「async.series」類似,這里就不再詳細(xì)說明了。

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

// 從起床到上班的整個(gè)過程
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 {
        上班();
    }
});

可見,如果串行和并行互相多穿插幾次,還是會(huì)出現(xiàn)一定程度的回調(diào)金字塔現(xiàn)象。

Asycn庫的優(yōu)點(diǎn)是符合Node.js的異步編程模式(回調(diào)函數(shù)的第一個(gè)參數(shù)是異常信息,Node.js原生的異步接口都這樣)。然而它的缺點(diǎn)也正是如此,回調(diào)函數(shù)中有一個(gè)異常信息參數(shù),還占據(jù)了第一位,實(shí)在是太不方便了。

Promise

Promise是ES6標(biāo)準(zhǔn)的一部分,它提供了一種新的異步編程模式。但是ES6定稿比較晚,且舊的瀏覽器無法支持新的標(biāo)準(zhǔn),因而有一些第三方的實(shí)現(xiàn)(比如Bluebird,不僅實(shí)現(xiàn)了Promise的標(biāo)準(zhǔn),還進(jìn)行了擴(kuò)展)。順帶一提,Node.js 4.0+已經(jīng)原生支持Promise。

那Promise究竟是什么玩意呢?Promise代表異步操作的最終結(jié)果,跟Promise交互的主要方式是通過它的「then」或者「catch」方法注冊(cè)回調(diào)函數(shù)去接收最終結(jié)果或者是不能完成的原因(異常)。

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

function 充電Promisify() {
    return new Promise(function(resolve) {
        充電(function(battery) {
            resolve(battery);
        });
        // 也可以簡(jiǎn)寫為 充電(resolve)
    });
}

function 叫車Promisify(battery) {
    return new Promise(function(resolve, reject) {
        if (battery >= 0.1) {
            叫車(function(msg) {
                resolve(msg);
            });
            // 也可以簡(jiǎn)寫為 叫車(resolve)
        } else {
            reject(new Error('電量不足'));
        }
    });
}

具體來說,就是創(chuàng)建一個(gè)Promise對(duì)象,創(chuàng)建時(shí)需要傳入一個(gè)函數(shù),這個(gè)函數(shù)有兩個(gè)參數(shù)「resolve」和「reject」。操作成功時(shí)調(diào)用「resolve」,出現(xiàn)異常時(shí)調(diào)用「reject」。而想要獲得異步操作的結(jié)果,正如前面所提到的,需要調(diào)用Promise對(duì)象的「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」方法有兩個(gè)參數(shù):

  • 第一個(gè)參數(shù)是操作成功(resolved)時(shí)的回調(diào);
  • 第二個(gè)參數(shù)是操作拒絕(rejected)時(shí)的回調(diào)。

要注意的是,創(chuàng)建Promise對(duì)象時(shí)傳入的函數(shù)只會(huì)執(zhí)行一次,即使多次調(diào)用了「then」方法,該函數(shù)也不會(huì)重復(fù)執(zhí)行。這樣一來,一個(gè)Promise實(shí)際上還緩存了異步操作的結(jié)果。

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

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

如果「then」的回調(diào)函數(shù)返回的是一個(gè)Promise對(duì)象,那么下一個(gè)「then」的回調(diào)函數(shù)就會(huì)在這個(gè)Promise對(duì)象完成之后再執(zhí)行。所以多個(gè)步驟只需要通過「then」鏈?zhǔn)秸{(diào)用即可。此外,這段代碼的「then」只有一個(gè)參數(shù),而異常則由「catch」方法統(tǒng)一處理。

接下來看一下異步并行,需要用到「Promise.all」這個(gè)方法:

// 充電、洗刷并行
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庫的要簡(jiǎn)潔得多,通過「then」的鏈?zhǔn)秸{(diào)用可以很好地控制執(zhí)行順序。但是由于現(xiàn)有的大部分異步接口都不是基于Promise寫的,所以要進(jìn)行二次封裝。

順帶一提,其實(shí)jQuery的「$.ajax」方法返回的就是一個(gè)不完全的Promise(沒有實(shí)現(xiàn)Promise的所有接口):

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

Generator Function

Generator Function,中文譯名為生成器函數(shù),是ES6中的新特性。這種函數(shù)通過「function *」進(jìn)行聲明,函數(shù)內(nèi)部可以通過「yield」關(guān)鍵字暫停函數(shù)執(zhí)行。

這是一個(gè)生成器函數(shù)的例子:

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

如果是普通的函數(shù),執(zhí)行「genFn()」后就會(huì)返回「end」,但生成器函數(shù)并不是這樣。執(zhí)行「genFn()」后,實(shí)際上是創(chuàng)建了一個(gè)生成器函數(shù)對(duì)象,此時(shí)函數(shù)內(nèi)的代碼不會(huì)執(zhí)行。而調(diào)用這個(gè)對(duì)象(gen)的「next」方法時(shí),函數(shù)開始執(zhí)行,直到「yield」暫停?!竛ext」方法的返回值是一個(gè)對(duì)象,它有兩個(gè)屬性:

  • value:yield關(guān)鍵字后面的值(如果為表達(dá)式,則為表達(dá)式的計(jì)算結(jié)果);
  • done:函數(shù)是否執(zhí)行完畢。

第二次調(diào)用「gen.next」時(shí),傳入了一個(gè)參數(shù)值「B」?!竛ext」方法的參數(shù)值即為當(dāng)前暫停函數(shù)的「yield」的返回值,所以函數(shù)內(nèi)部value的值為「B」。然后函數(shù)繼續(xù)執(zhí)行,返回「end」。所以「g2.value」為的值「end」,此時(shí)函數(shù)執(zhí)行完畢,「g2.done」的值為「true」。

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

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

其執(zhí)行過程大概是:執(zhí)行異步操作后就暫停了「叫車Gen」的執(zhí)行,異步操作完成后通過「gen.next」把「result」回傳到「叫車Gen」中;如果出現(xiàn)異常,就通過「gen.throw」拋出以便在「叫車Gen」里面捕獲。

但是這樣繞來繞去又有什么好處呢?仔細(xì)觀察可以發(fā)現(xiàn),「叫車Gen」內(nèi)部雖然執(zhí)行的是異步操作,但完全就是同步的寫法(沒有回調(diào)函數(shù),異常捕獲也是用常規(guī)的「try...catch」)。進(jìn)一步思考,如果能把后面的細(xì)節(jié)封裝起來,那就真的可以用同步的方式寫異步的代碼了。而后面的細(xì)節(jié)部分也是有規(guī)律可循的,封裝起來并不是難事(只是有點(diǎn)繞):

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

借助這個(gè)函數(shù),異步編程可以前所未有地簡(jiǎn)單:

// 異步串行:充電 -> 叫車
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);
    }
});

生成器函數(shù)是一種比較新的特性,雖然Node.js 4.0+已經(jīng)原生支持,但在舊版本瀏覽器上肯定無法運(yùn)行。因此如果要在瀏覽器端使用還得通過編譯器(如Babel)編譯成ES5的代碼,這也是這種解決方案的最大缺點(diǎn)。

講到這里,順便介紹一下「co」庫。這個(gè)庫的功能類似于「asyncByGen」,但它封裝得更好,功能也更多,是用生成器函數(shù)寫異步代碼必不可少的利器。

async/await

如果你還是看不懂生成器函數(shù)的執(zhí)行過程,那也沒關(guān)系,因?yàn)樗呀?jīng)“過時(shí)”了!ES7提供了「async」、「await」兩個(gè)關(guān)鍵字,可以達(dá)到跟「asyncByGen」一樣的效果。

首先給大家介紹一個(gè)這兩個(gè)關(guān)鍵字的用法?!竌sync」是用來聲明異步函數(shù)的,這種函數(shù)的返回值總是Promise對(duì)象(即使函數(shù)內(nèi)部返回的不是Promise對(duì)象,也會(huì)返回一個(gè)結(jié)果為undefined的Promise對(duì)象)。

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」聲明的異步函數(shù)的內(nèi)部,它會(huì)等待其后的Promise對(duì)象確定狀態(tài)后再執(zhí)行后續(xù)的語句:

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

順帶提一下,「await」后面不一定非要跟著Promise對(duì)象,也可以是一個(gè)普通的值,這樣相當(dāng)于是執(zhí)行同步代碼。

下面用「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);
    }
})();

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

后記

本文的第一版寫于2015年年底,現(xiàn)在(2017年中)重讀一遍,覺得有不少可以改進(jìn)的地方,而且技術(shù)也在不斷發(fā)展,于是又修改了一遍。改動(dòng)包括:

  • 把示例代碼由原來的「AJAX讀取文件」改成文章開頭所述的「從起床到上班的過程」。雖然用到了中文函數(shù)名,但都是可以運(yùn)行的。
  • 新增「async/await」一節(jié)。

文章同步發(fā)布在:https://zhuanlan.zhihu.com/ibeiliao

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 異步編程對(duì)JavaScript語言太重要。Javascript語言的執(zhí)行環(huán)境是“單線程”的,如果沒有異步編程,根本...
    呼呼哥閱讀 7,400評(píng)論 5 22
  • 弄懂js異步 講異步之前,我們必須掌握一個(gè)基礎(chǔ)知識(shí)-event-loop。 我們知道JavaScript的一大特點(diǎn)...
    DCbryant閱讀 2,879評(píng)論 0 5
  • 本文首發(fā)在個(gè)人博客:http://muyunyun.cn/posts/7b9fdc87/ 提到 Node.js, ...
    牧云云閱讀 1,755評(píng)論 0 3
  • 一.非阻塞和異步 借用知乎用戶嚴(yán)肅的回答在此總結(jié)下,同步和異步是針對(duì)消息通信機(jī)制,同步代表一個(gè)client發(fā)出一個(gè)...
    Daniel_adu閱讀 1,922評(píng)論 0 8
  • 你不知道JS:異步 第三章:Promises 接上篇3-1 錯(cuò)誤處理(Error Handling) 在異步編程中...
    purple_force閱讀 1,494評(píng)論 0 2

友情鏈接更多精彩內(nèi)容