Javascript中常见的异步编程模型

发表:
大纲
  1. 1. 回调函数
    1. 1.1. 回调函数的缺陷
  2. 2. 事件监听
  3. 3. 观察者模式
  4. 4. Promise模式
  5. 5. ES6中的方案
    1. 5.1. ES6中的Promise
    2. 5.2. ES6中Generator
    3. 5.3. ES7中的async和await
    4. 5.4. fibjs

Javascript异步编程专题,目前包含以下几篇文章,


本篇文章是本专题的第二篇文章。

正文开始。

在Javascript异步编程专题的前一篇文章浅谈Javascript中的异步中,我简明的阐述了“Javascript中的异步原理”、“Javascript如何在单线程上实现异步调用”以及“Javascript中的定时器”等相关问题。

本篇文章我将会谈一谈Javascript中常用的几种异步编程模型。

在前端的代码编写中,异步的场景随处可见。比如鼠标点击、键盘回车、网络请求等这些与浏览器紧密联系的操作,比如一些延迟交互特效等等。

在这些场景中,你必须要使用所谓的“异步模式”,否则将会严重程序的可行性和用户体验。我们列举这些场景中常用的几种异步编程模型,包括回调函数、事件监听、观察者模式(消息订阅/发布)、promise模式。除此之外还会稍微介绍一番ES6(ES7)中新增的方案。

下面我们将针对每一种编程模型加以说明。

回调函数

回调函数可以说是Javascript异步编程最基本的方法。我们试想有这样一个场景,我们需要在页面上展示一个持续3秒钟的loading视觉样式,然后在页面上显示我们真正想显示的内容。示例代码如下,

// more code

function loading(callback) {
    // 持续3秒的loading展示
    setTimeout(function () {
        callback();
    }, 3000);
}

function show() {
    // 展示真实数据给用户
}

loading(show);

// more code

代码中的loading(show)就是将函数show()作为函数loading()的参数。在loading()完成3秒的loading之后,再去执行回调函数(示例使用了setTimeout来模拟)。通过这种方法,show()就变成了异步调用,它的执行时机被推迟到loading()即将完成之前。

回调函数的缺陷

回调函数往往就是调用用户提供的函数,该函数往往是以参数的形式提供的。回调函数并不一定是异步执行的。回调函数的特点就是使用简单、容易理解。缺点就是逻辑间存在一定耦合。最恶心的地方在于会造成所谓的callback hell。比如下面这样的一个例子,

A(function () {
    B(function () {
        C(function() {
            D(function() {
                // ...
            })
        })
    })
})

例子中A、B、C、D四个任务存在依赖关系,通过函数回调的方式,写出来的代码就会变成上面的这个样子。维护性和可读性都非常糟糕。

除了回调嵌套的问题之外,还可能会带来另一个问题,就是流程控制不方便。比如我们要发送3个请求,当3个请求都返回时,我们再执行相关逻辑,那么代码可能就是,

var count = 0
for (var i = 0; i < 3; i++) {
    request('source_' + i, function () {
        count++;
        if (count === 3) {
            // do my logic
        }
    });
}

上面的示例代码中,我通过request对三个url发送了请求,但是我不知道这三个请求的返回情况。无奈之下我添加了一个计数器count,在每个请求的回调中都进行计数器判断,当计数器为3时即表示三个请求都已经成功返回了,此时再去执行相关任务。显而易见,这种情况下的流程控制就显得比较丑陋。

最后,有时候我们为了程序的健壮性,可能会需要一个try...catch语法。比如,

// demo1
try {
    setTimeout(function () {
        throw new Error('error occured');
    })
} catch(e) {
    console.log(e);
}

// demo2
setTimeout(function () {
    try {
        // your logic
    } catch(e) {

    }
});

上面的示例代码中,如果我们像demo1那样将try...catch加在异步逻辑的外面,即使异步调用发生了异常我们也是捕获不到的,因为try...catch不能捕获未来的异常。无奈,我们只能像demo2那样将try...catch语句块放在具体的异步逻辑内。这样一旦异步调用多起来,那么就会多出来很多try...catch。这样肯定是不好的。

除了上面这些问题之外,我觉得回调函数真正的核心问题在于,嵌套的回到函数往往会破坏整个程序的调用堆栈,并且像returnthrow等这些用于代码流程控制的关键词都不能正常使用(因为前一个回调函数往往会影响到它后面所有的回调函数)。

事件监听

事件监听在UI编程中随处可见。比如我给一个按钮绑定一个点击事件,给一个输入框绑定一个键盘敲击事件等等。比如下面的代码,

$('#button').on('click', function () {
    console.log('我被点了');
});

上面使用了JQuery的语法,给一个按钮绑定了一个事件。当事件触发时,会执行绑定的逻辑。这比较容易理解。

除了界面事件之外,通常我们还有各种网络请求事件,比如ajax,websocket等等。这些网络请求在不同阶段也会触发各种事件,如果程序中有绑定相关处理逻辑,那么当事件触发时就会去执行相关逻辑。

除此之外,我们还可以自定义事件。比如,

$('#div').on('data-loaded', function () {
    console.log('data loaded');
});

$('#div').trigger('data-loaded');

上面采用JQuery的语法,我们自定义了一个事件,叫做”data-loaded”,并在此事件上定义了一个触发逻辑。当我们通过trigger触发这个事件时,之前绑定的逻辑就会执行了。

观察者模式

之前在事件监听中提到了自定义事件,其实自定义事件是观察者模式的一种具体表现。观察者模式,又称为消息订阅/发布模式。它的含义是,我们先假设有一个“信号中心”,当某个任务执行完毕就向信号中心发出一个信号(事件),然后信号中心收到这个信号之后将会进行广播。如果有其他任务订阅了该信号,那么这些任务就会收到一个通知,然后执行任务相关的逻辑。

下面是观察者模式的一个简单实现(可参阅用AngularJS实现观察者模式),

var ob = {
    channels: [],
    subscribe: function(topic, callback) {
       if (!_.isArray(this.channels[topic])) {
           channels[topic] = [];
       }
       var handlers = channels[topic];
       handlers.push(callback);
    },
    unsubscribe: function(topic, callback) {
       if (!_.isArray(this.channels[topic])) {
           return;
       }
       var handlers = this.channels[topic];
       var index = _.indexOf(handlers, callback);
       if (index >= 0) {
           handlers.splice(index, 1);
       }
   },
   publish: function(topic, data) {
       var self = this;
       var handlers = this.channels[topic] || [];
       _.each(handlers, function(handler) {
           try {
               handler.apply(self, [data]);
           } catch (ex) {
               console.log(ex);
           }
       });
   }
};

其用法如下,

ob.subscribe('done', function () {
    console.log('done');
});

setTimeout(function () {
    ob.publish('done')
}, 1000);

观察者模式的实现方式有很多,不过基本核心都差不多,都会有消息订阅和发布。从本质上说,前面所说的事件监听也是一种观察者模式。

观察者模式用好了自然好处多多,能够把解耦做的相当好。但是复杂的系统如果要用观察者模式来做逻辑,必须要做好事件订阅和发布的设计,否则会导致程序的运行流程混乱。

Promise模式

Promise严格来说不是一种新技术,它只是一种语法糖,一种机制,一种代码结构和流程,用于管理异步回调。

jQuery中的Promise实现源自Promises/A规范。使用promise来管理回调,可以将回调逻辑扁平化,可以避免之前提到的回调地狱。示例代码如下,

function fn1() {
    var dfd = $.Deferred();
    setTimeout(function () {
        console.log('fn1');
        dfd.resolve();
    }, 1000);
    return dfd.promise();
}

function fn2() {
    console.log('fn2');
}

fn1().then(fn2);

针对之前提到的回调地狱和异常难以捕获的问题,使用promise都可以轻松的解决。

A().then(B).then(C).then(D).catch(ERROR);

看,一行就搞定了。不过使用promise处理异步调用,有一点需要注意,就是所有的异步函数都要promise化。所谓promise化的意思就是需要对异步函数进行封装,让其返回一个promise对象。比如,

function A() {
    var promise = new Promise(function (resolve, reject) {
        // your logic 
    });

    return promise;
}

ES6中的方案

ES6于今年6月份左右已经正式发布了。其中新增了不少内容。其中有两项内容可能用来解决异步回调的内容。

ES6中的Promise

最新发布的ECMAScript2015中已经涵盖了promise的相关内容,不过ES6中的Promise规范其实是Promise/A+规范,可以说它是Promise/A规范的增强版。

现代浏览器Chrome,Firefox等已经对Promise提供了原生支持。详细的文档可以参阅MDN

简单来说,ES6中promise的内容具体如下,

  • promise有三种状态:pending(等待)、fulfilled(成功)、rejected(失败)。其中pending为初始状态。
  • promise的状态转换只能是:pending->fulfilled或者pending->rejected。转换方向不能颠倒,且fulfilled和rejected状态不能相互转换。每一种状态转换都会触发相关调用。
  • pending->fulfilled时,promise会带有一个value(成功状态的值);pending->rejected时,promise会带有一个reason(失败状态的原因)
  • promise拥有then方法。then方法必须返回一个promise。then可以多次链式调用,且回调的顺序跟then的声明顺序一致。
  • then方法接受两个参数,分别是“pending->fulfilled”的调用和“pending->rejected”的调用。
  • then还可以接受一个promise实例,也可以接受一个thenable(类then对象或者方法)实例。

总得来说promise的内容比较简单,涉及到三种状态和两种状态转换。其实promise的核心就是then方法的实现。

下面是来自MDN上Promise的代码示例(稍作改动),

var p1 = new Promise(function (resolve, reject) {
    console.log('p1 start');
    setTimeout(function() {
        resolve('p1 resolved');
    }, 2000);
});

p1.then(function (value) {
    console.log(value);
}, function(reason) {
    console.log(reason);
});

上述代码的执行结果是,先打印”p1 start”然后经过2秒左右再次打印”p1 resolved”。

当然我们还可以添加多个回调。我们可以通过在前一个then方法中调用return将promise往后传递。比如,

p1.then(function(v) {
    console.log('1: ', v);
    return v + ' 2';
}).then(function(v) {
    console.log('2: ', v);
});

不过在使用Promise的时候,有一些需要注意的地方,这篇文章We have a problem with promises翻译文)中总结得很好,有兴趣的可自行参阅。

不管是ES6中的promise还是jQuery中的promise/deferred,的确可以避免异步代码的嵌套问题,使整体代码结构变得清晰,不用再受callback hell折磨。但是也仅仅止步于此,因为它并没有触碰js异步回调真正核心的内容。

现在业界有许多关于PromiseA+规范的实现,不过博主个人觉得bluebird是个不错的库,可以值得一用,如果你有选择困难症,不妨试一试😎😎😎

ES6中Generator

ES6中引入的Generator可以理解为一种协程的实现机制,它允许函数在运行过程中将Javascript执行权交给其他函数(代码),并在需要的时候返回继续执行。

我们可以使用Generator配合ES6中Promise,进一步将异步调用扁平化(转化成同步风格)。

下面我们来看一个例子,

function* gen() {
    var ret = yield new Promise(function(resolve, reject) {
        console.log('async task start');
        setTimeout(function() {
            resolve('async task end');
        }, 2000);
    });

    console.log(ret);
}

上述Node.js代码中,我们定义了一个Generator函数,且创建了一个promise,promise内使用setTimeout模拟了一个异步任务。

接下来我们来执行这个Generator函数,因为yield返回的是一个promise,所以我们需要使用then方法,

var g = gen();
var result = g.next();

result.value.then(function(str){
    console.log(str);
    // 对resolve的数据重新包装,然后传递给下一个promise
    return {
        msg: str
    };
}).then(function(data){
    g.next(data);
});

最终的结果如下,

async task start
// 经过2秒左右
async task end
{msg: 'async task end'}

其实关于Generator还有很多的内容可以说,这里由于篇幅的关系就不展开了。业界已经有了基于Generator处理异步调用的功能库,比如cotask.js

ES7中的async和await

在单线程的Javascript上做异步任务(甚至并发任务)的确是一个让人头疼的问题,总会越到各种各样的问题。从最早的函数回调,到Promise,再到Generator,涌现的各种解决方案,虽然都有所改进,但是仍然让人觉得并没有彻底的解决这个问题。

举个例子来说,我现在就是想读取一个文件,这么简单的一件事,何必要考虑那么多呢?又是回调,又是promise的,烦不烦呐。我就想像下面这么简单的写代码,难道不行么?

function task() {
    var file1Content = readFile('file1path');
    var file2Content = readFile(fileContent);
    console.log(file2Content);
}

想要做的事情很简单,读取第一个文件,它的内容是要读取的第二个文件的文件名。

值得庆幸的是,ES7中的asyncawait可以帮你做到这件事。不过要稍微改动一下,

async function task() {
    var file1Content = await readFile('file1path');
    var file2Content = await readFile(fileContent);
    console.log(file2Content);
}

看,改动的地方很简单,只要在task前面加上关键词async,在函数内的异步任务前添加await声明即可。如果忽略这些额外的关键字,简直就是完完全全的同步写法嘛。

其实,这种方式就是前端提到的Generator和Promise方案的封装。ECMAScript组织也认为这是目前解决Javascript异步回调的最佳方案,所以可能会在ES7中将其纳入到规范中来。需要注意的是,这项特性是ES7的提案,依赖Generator,所以慎用(目前来说基本用不了)!

fibjs

除了上述的几种方案之外,其实还有另外一种方案。就是使用协程的方案来解决单线程上的异步调用问题。

之前我们也提到过,Generator的yield可以暂停函数执行,将执行权临时转交给其他任务,待其他任务完毕之后,再交还回执行权。这其实就是协程的基本模型。

业界有一款基于V8引擎的服务端开发框架fibjs,它的实现机制跟Node.js是不一样的。fibjs采用fiber解决v8引擎的多路复用,并通过大量c++组件,将重负荷运算委托给后台线程,释放v8线程,争取更大的并发时间。

一句话,fibjs从底层,使用的纤程模型解决了异步调用的问题。关于fibjs,有兴趣的话可以查阅相关资料。不过我个人对它是持谨慎态度的。原因是如下两点,

  • 生态原因。
  • 使用了js,但是又摒弃了js的异步。

不过还是可以作为兴趣去研究一下的。