专栏名称: 贝聊科技
移动开发部@贝聊科技
目录
相关文章推荐
51好读  ›  专栏  ›  贝聊科技

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

贝聊科技  · 简书  ·  · 2017-10-26 18:32

正文

单线程与异步

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」方法统一处理。







请到「今天看啥」查看全文