• 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版》

  • 相关阅读:
    HTML DOM教程 14HTML DOM Document 对象
    HTML DOM教程 19HTML DOM Button 对象
    HTML DOM教程 22HTML DOM Form 对象
    HTML DOM教程 16HTML DOM Area 对象
    ubuntu 11.04 问题 小结
    VC6.0的 错误解决办法 小结
    boot.img的解包与打包
    shell里 截取字符串
    从零 使用vc
    Imagemagick 对图片 大小 和 格式的 调整
  • 原文地址:https://www.cnblogs.com/star91/p/5737797.html
Copyright © 2020-2023  润新知