JavaScript异步编程的主要解决方案—对不起,我和你不在同一个频率上

众所周知(这也忒夸张了吧?),Javascript通过事件驱动机制,在单线程模型下,以异步的形式来实现非阻塞的IO操作。这种模式使得JavaScript在处理事务时非常高效,但这带来了很多问题,比如异常处理困难、函数嵌套过深。下面介绍几种目前已知的实现异步操作的解决方案。

一、回调函数

这是最古老的一种异步解决方案:通过参数传入回调,未来调用回调时让函数的调用者判断发生了什么。
直接偷懒上阮大神的例子:
假定有两个函数f1和f2,后者等待前者的执行结果。
如果f1是一个很耗时的任务,可以考虑改写f1,把f2写成f1的回调函数。

function f1(callback){
    setTimeout(function () {
      // f1的任务代码
      callback();
    }, 1000);
  }

执行代码就变成下面这样:
f1(f2);
采用这种方式,我们把同步操作变成了异步操作,f1不会堵塞程序运行,相当于先执行程序的主要逻辑,将耗时的操作推迟执行。
回调函数的优点是简单、容易理解和部署,缺点是不利于代码的阅读和维护,各个部分之间高度耦合,流程会很混乱.也许你觉得上面的流程还算清晰。那是因为我等初级菜鸟还没见过世面,试想在前端领域打怪升级的过程中,遇到了下面的代码:

doA(function(){
    doB();
    doC(function(){
        doD();
    })
    doE();
});
doF();

要想理清上述代码中函数的执行顺序,还真得停下来分析很久,正确的执行顺序是doA->doF->doB->doC->doE->doD.
回调函数的优点是简单、容易理解和部署,缺点是不利于代码的阅读和维护,程序的流程会很混乱,而且每个任务只能指定一个回调函数。

二、事件发布/订阅模式(观察者模式)

事件监听模式是一种广泛应用于异步编程的模式,是回调函数的事件化,任务的执行不取决于代码的顺序,而取决于某个事件是否发生。这种设计模式常被成为发布/订阅模式或者观察者模式。
浏览器原生支持事件,如Ajax请求获取响应、与DOM的交互等,这些事件天生就是异步执行的。在后端的Node环境中也自带了events模块,Node中事件发布/订阅的模式及其简单,使用事件发射器即可,示例代码如下:

//订阅
emitter.on("event1",function(message){
  console.log(message);
});
//发布
emitter.emit('event1',"I am message!");

我们也可以自己实现一个事件发射器,代码实现参考了《JavaScript设计模式与开发实践》

var event={
    clientList:[],
    listen:function (key,fn) {
        if (!this.clientList[key]) {
            this.clientList[key]=[];
        }
        this.clientList[key].push(fn);//订阅的消息添加进缓存列表
    },
    trigger:function(){
        var key=Array.prototype.shift.call(arguments),//提取第一个参数为事件名称
        fns=this.clientList[key];
        if (!fns || fns.length===0) {//如果没有绑定对应的消息
            return false;
        }
        for (var i = 0,fn;fn=fns[i++];) {
            fn.apply(this,arguments);//带上剩余的参数
        }
    },
    remove:function(key,fn){
        var fns=this.clientList[key];
        if (!fns) {//如果key对应的消息没人订阅,则直接返回
            return false;
        }
        if (!fn) {//如果没有传入具体的回调函数,表示需要取消key对应消息的所有订阅
            fns&&(fns.length=0);
        }else{
            for (var i = fns.length - 1; i >= 0; i--) {//反向遍历订阅的回调函数列表
                var _fn=fns[i];
                if (_fn===fn) {
                    fns.splice(i,1);//删除订阅者的回调函数
                }
            }
        }
    }
};

只有这个事件订阅发布对象没有多大作用,我们要做的是给任意的对象都能添加上发布-订阅的功能:
在ES6中可以使用Object.assign(target,source)方法合并对象功能。如果不支持ES6可以自行设计一个拷贝函数如下:

var installEvent=function(obj){
 for(var i in event){
     if(event.hasOwnProperty(i))
   obj[i]=event[i];
 }
};

上述的函数就能给任意对象添加上事件发布-订阅功能。下面我们测试一下,假如你家里养了一只喵星人,现在它饿了。

var Cat={};
//Object.assign(Cat,event);
installEvent(Cat);
Cat.listen('hungry',function(){
  console.log("铲屎的,快把朕的小鱼干拿来!")
});
Cat.trigger('hungry');//铲屎的,快把朕的小鱼干拿来!

自定义发布-订阅模式介绍完了。
这种方法的优点是比较容易理解,可以绑定多个事件,每个事件可以指定多个回调函数。缺点是整个程序都要变成事件驱动型,运行流程会变得很不清晰。

三、使用Promise对象

ES6标准中实现的Promise是异步编程的一种解决方案,比传统的解决方案——回调函数和事件——更合理和更强大。
所谓Promise,就是一个对象,用来传递异步操作的消息。它代表了某个未来才会知道结果的事件,并且这个事件提供统一的API,各种异步操作都可以用同样的方法进行处理。

Promise对象有以下两个特点。
(1)对象的状态不受外界影响。Promise对象代表一个异步操作,有三种状态:Pending(进行中)、Resolved(已完成,又称Fulfilled)和Rejected(已失败)。只有异步操作的结果,可以决定当前是哪一种状态,任何其他操作都无法改变这个状态
(2)一旦状态改变,就不会再变,任何时候都可以得到这个结果。Promise对象的状态改变,只有两种可能:从Pending变为Resolved和从Pending变为Rejected。只要这两种情况发生,状态就凝固了,不会再变了,会一直保持这个结果。就算改变已经发生了,你再对Promise对象添加回调函数,也会立即得到这个结果。这与事件(Event)完全不同,事件的特点是,如果你错过了它,再去监听,是得不到结果的。
有了Promise对象,就可以将异步操作以同步操作的流程表达出来,避免了层层嵌套的回调函数。
下面以一个Ajax请求为例,Cnode社区的API中有这样一个流程,首先根据accesstoken获取用户名,然后可以根据用户名获取用户收藏的主题,如果我们想得到某个用户收藏的主题数量就要进行两次请求。如果不使用Promise对象,以Jquery的ajax请求为例:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Promise</title>
</head>
<body>    

</body>
<script type="text/javascript" src="http://apps.bdimg.com/libs/jquery/1.7.2/jquery.min.js"></script>
<script type="text/javascript">
    $.post("https://cnodejs.org/api/v1/accesstoken",{
        accesstoken:"XXXXXXXXXXXXXXXXXXXXXXXXXXX"
    },function (res1) {
        $.get("https://cnodejs.org/api/v1/topic_collect/"+res1.loginname,function(res2){
            alert(res2.data.length);
        });
    });
</script>
</html>

从上述代码中可以看出,两次请求相互嵌套,如果改成用Promise对象实现:

function post(url,para){
        return new Promise(function(resolve,reject){
            $.post(url,para,resolve);            
        });
    }

    function get(url,para){
        return new Promise(function(resolve,reject){
            $.get(url,para,resolve);
        });
    } 

    var p1=post("https://cnodejs.org/api/v1/accesstoken",{
         accesstoken:"XXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
    });
    var p2=p1.then(function(res){
        return get("https://cnodejs.org/api/v1/topic_collect/"+res.loginname,{});
    });
    p2.then(function(res){
        alert(res.data.length);
    });

可以看到前面代码中的嵌套被解开了,(也许有人会说,这代码还变长了,坑爹吗这是,请不要在意这些细节,这里仅举例说明)。关于Promise对象的具体用法还有很多知识点,建议查找相关资料深入阅读,这里仅介绍它作为异步编程的一种解决方案。

四、使用Generator函数

关于Generator函数的概念可以参考阮大神的ES6标准入门,Generator可以理解为可在运行中转移控制权给其他代码,并在需要的时候返回继续执行的函数,看下面一个简单的例子:

function* helloWorldGenerator(){
    yield 'hello';
    yield 'world';
    yield 'ending';
}
var hw=helloWorldGenerator();
console.log(hw.next());
console.log(hw.next());
console.log(hw.next());
console.log(hw.next());
// { value: 'hello', done: false }
// { value: 'world', done: false }
// { value: 'ending', done: false }
// { value: undefined, done: true }

Generator函数的调用方法与普通函数一样,也是在函数名后面加上一对圆括号。不同的是,调用Generator函数后,该函数并不执行,返回的也不是函数运行结果,而是一个遍历器对象(Iterator Object)。
下一步,必须调用遍历器对象的next方法,使得指针移向下一个状态。也就是说,每次调用next方法,内部指针就从函数头部或上一次停下来的地方开始执行,直到遇到下一个yield语句(或return语句)为止。换言之,Generator函数是分段执行的,yield语句是暂停执行的标记,而next方法可以恢复执行。
Generator函数的暂停执行的效果,意味着可以把异步操作写在yield语句里面,等到调用next方法时再往后执行。这实际上等同于不需要写回调函数了,因为异步操作的后续操作可以放在yield语句下面,反正要等到调用next方法时再执行。所以,Generator函数的一个重要实际意义就是用来处理异步操作,改写回调函数。
如果有一个多步操作非常耗时,采用回调函数,可能会写成下面这样。

step1(function (value1) {
  step2(value1, function(value2) {
    step3(value2, function(value3) {
      step4(value3, function(value4) {
        // Do something with value4
      });
    });
  });
});

采用Promise改写上面的代码。(下面的代码使用了Promise的函数库Q)

Q.fcall(step1)
  .then(step2)
  .then(step3)
  .then(step4)
  .then(function (value4) {
    // Do something with value4
  }, function (error) {
    // Handle any error from step1 through step4
  })
  .done();

上面代码已经把回调函数,改成了直线执行的形式,但是加入了大量Promise的语法。Generator函数可以进一步改善代码运行流程。

function* longRunningTask() {
  try {
    var value1 = yield step1();
    var value2 = yield step2(value1);
    var value3 = yield step3(value2);
    var value4 = yield step4(value3);
    // Do something with value4
  } catch (e) {
    // Handle any error from step1 through step4
  }
}

如果只有Generator函数,任务并不会自动执行,因此需要再编写一个函数,按次序自动执行所有步骤。

scheduler(longRunningTask());
function scheduler(task) {
  setTimeout(function() {
    var taskObj = task.next(task.value);
    // 如果Generator函数未结束,就继续调用
    if (!taskObj.done) {
      task.value = taskObj.value
      scheduler(task);
    }
  }, 0);
}

五、使用async函数

在ES7(还未正式标准化)中引入了Async函数的概念,async函数的实现就是将Generator函数和自动执行器包装在一个函数中。如果把上面Generator实现异步的操作改成async函数,代码如下:

async function longRunningTask() {
  try {
    var value1 = await step1();
    var value2 = await step2(value1);
    var value3 = await step3(value2);
    var value4 = await step4(value3);
    // Do something with value4
  } catch (e) {
    // Handle any error from step1 through step4
  }
}

正如阮一峰在博客中所述,异步编程的语法目标,就是怎样让它更像同步编程,使用async/await的方法,使得异步编程与同步编程看起来相差无几了。

六、借助流程控制库

随着Node开发的流行,NPM社区中出现了很多流程控制库可以供开发者直接使用,其中很流行的就是async库,该库提供了一些流程控制方法,注意这里所说的async并不是标题五中所述的async函数。而是第三方封装好的库。其官方文档见http://caolan.github.io/async/docs.html
async为流程控制主要提供了waterfall(瀑布式)、series(串行)、parallel(并行)

  • 如果需要执行的任务紧密结合。下一个任务需要上一个任务的结果做输入,应该使用瀑布式
  • 如果多个任务必须依次执行,而且之间没有数据交换,应该使用串行执行
  • 如果多个任务之间没有任何依赖,而且执行顺序没有要求,应该使用并行执行
    关于async控制流程的基本用法可以参考官方文档或者Async详解之一:流程控制
    下面我举一个例子说明:假设我们有个需求,返回100加1再减2再乘3最后除以4的结果,而且每个任务需要分解执行。
    1.使用回调函数
function add(fn) {
    var num=100;
    var result=num+1;
    fn(result)
}
function  minus(num,fn){
    var result=num-2;
    fn(result);
}
function  multiply(num,fn){
    var result=num*3;
    fn(result);
}
function  divide(num,fn){
    var result=num/4;
    fn(result);
}
add(function (value1) {
  minus(value1, function(value2) {
    multiply(value2, function(value3) {
      divide(value3, function(value4) {
        console.log(value4);
      });
    });
  });
});

从上面的结果可以看到回调嵌套很深。
2.使用async库的流程控制
由于后面的任务依赖前面的任务执行的结果,所以这里要使用watefall方式。

var async=require("async");
function add(callback) {
    var num=100;
    var result=num+1;
    callback(null, result);
}
function  minus(num,callback){
    var result=num-2;
    callback(null, result);
}
function  multiply(num,callback){
    var result=num*3;
    callback(null, result);
}
function  divide(num,callback){
    var result=num/4;
    callback(null, result);
}
async.waterfall([
    add,
    minus,
    multiply,
    divide
], function (err, result) {
    console.log(result);
});

可以看到使用流程控制避免了嵌套。

七、使用Web Workers

Web Worker是HTML5新标准中新添加的一个功能,Web Worker的基本原理就是在当前javascript的主线程中,使用Worker类加载一个javascript文件来开辟一个新的线程,起到互不阻塞执行的效果,并且提供主线程和新线程之间数据交换的接口:postMessage,onmessage。其数据交互过程也类似于事件发布/监听模式,异能实现异步操作。下面的示例来自于红宝书,实现了一个数组排序功能。
页面代码:

<!DOCTYPE html>
<html>
<head>
    <title>Web Worker Example</title>
</head>
<body>
    <script>
        (function(){
        
            var data = [23,4,7,9,2,14,6,651,87,41,7798,24],
                worker = new Worker("WebWorkerExample01.js");                              
            worker.onmessage = function(event){
                alert(event.data);
            };         
            worker.postMessage(data);            
        
        })();        
    </script>
</body>
</html>

Web Worker内部代码

self.onmessage = function(event){
    var data = event.data;
    data.sort(function(a, b){
        return a - b;
    });
    
    self.postMessage(data);
};

把比较消耗时间的操作,转交给Worker操作就不会阻塞用户界面了,遗憾的是Web Worker不能进行DOM操作。

参考文献
Javascript异步编程的4种方法-阮一峰
《You Don't Know JS:Async&Performance》
《JavaScript设计模式与开发实践》-曾探
《深入浅出NodeJS》-朴灵
《ES6标准入门-第二版》-阮一峰
《JavaScript Web 应用开发》-Nicolas Bevacqua
《JavaScript高级程序设计第3版》

原文地址:https://www.cnblogs.com/star91/p/5737797.html