[贝聊科技]异步流程控制

单线程与异步

Javascript是单线程运行、支持异步机制的语言。进入正题之前,我们有必要先理解这种运行方式。

以「起床上班」的过程为例,假设有以下几个步骤:

  • 起床(10min)
  • 洗刷(10min)
  • 换衣(5min)
  • 叫车(10min)
  • 上班(15min)

最简单粗暴的执行方式就是按顺序逐步执行,这样从起床到上班共需50分钟,效率较低。如果能在「洗刷」之前先「叫车」,就可以节省10分钟的等车时间。

image.png

这样一来「叫车」就成了异步操作。但为何只有「叫车」可以异步呢?因为车不需要自己开过来,所以自己处于空闲状态,可以先干点别的。

把上面的过程写成代码:

function 起床() { console.info('起床'); }
function 洗刷() { console.info('洗刷'); }
function 换衣() { console.info('换衣'); }
function 上班() { console.info('上班'); }
function 叫车(cb) {
    console.info('叫车');
    setTimeout(function() {
        cb('车来了');
    }, 1000);
}

起床();
叫车(function() {
    上班();
});
洗刷();
换衣();

因为「上班」要在「叫车」之后才能执行,所以要作为「叫车」的回调函数。然而,「叫车」需要10分钟,「洗刷」也需要10分钟,「洗刷」执行完后刚好车就到了,此时会不会先执行「上班」而不是「换衣」呢?Javascript是单线程的语言,它会先把当前的同步代码执行完再去执行异步的回调。而异步的回调则是另一片同步代码,在这片代码执行完之前,其他的异步回调也不会被执行。所以「上班」不会先于「换衣」执行。

接下来考虑一种情况:手机没电了,想叫车得先充电。很明显,充电的过程也可以异步执行。整个过程应该是:

image.png

写成代码则是:

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

起床();
充电(function() {
    叫车(function() {
        上班();
    });
});
洗刷();
换衣()

充电、叫车、上班是异步串行(按顺序执行)的,所以要把后者作为前者的回调函数。可见,串行的异步操作越多,回调函数的嵌套就会越深,最终形成了回调金字塔(也叫回调地狱):

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

这样的代码极难阅读,也极难维护。此外,还有更复杂的问题:

  • 除了异步串行,还有异步并行,甚至是串行、并行互相穿插。
  • 异步代码的异常无法通过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」一节。

文章同步发布在:https://zhuanlan.zhihu.com/ibeiliao

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,542评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,596评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,021评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,682评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,792评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,985评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,107评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,845评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,299评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,612评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,747评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,441评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,072评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,828评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,069评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,545评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,658评论 2 350

推荐阅读更多精彩内容

  • 异步编程对JavaScript语言太重要。Javascript语言的执行环境是“单线程”的,如果没有异步编程,根本...
    呼呼哥阅读 7,302评论 5 22
  • 弄懂js异步 讲异步之前,我们必须掌握一个基础知识-event-loop。 我们知道JavaScript的一大特点...
    DCbryant阅读 2,707评论 0 5
  • 本文首发在个人博客:http://muyunyun.cn/posts/7b9fdc87/ 提到 Node.js, ...
    牧云云阅读 1,679评论 0 3
  • 一.非阻塞和异步 借用知乎用户严肃的回答在此总结下,同步和异步是针对消息通信机制,同步代表一个client发出一个...
    Daniel_adu阅读 1,816评论 0 8
  • 你不知道JS:异步 第三章:Promises 接上篇3-1 错误处理(Error Handling) 在异步编程中...
    purple_force阅读 1,391评论 0 2