Java自学者论坛

 找回密码
 立即注册

手机号码,快捷登录

恭喜Java自学者论坛(https://www.javazxz.com)已经为数万Java学习者服务超过8年了!积累会员资料超过10000G+
成为本站VIP会员,下载本站10000G+会员资源,会员资料板块,购买链接:点击进入购买VIP会员

JAVA高级面试进阶训练营视频教程

Java架构师系统进阶VIP课程

分布式高可用全栈开发微服务教程Go语言视频零基础入门到精通Java架构师3期(课件+源码)
Java开发全终端实战租房项目视频教程SpringBoot2.X入门到高级使用教程大数据培训第六期全套视频教程深度学习(CNN RNN GAN)算法原理Java亿级流量电商系统视频教程
互联网架构师视频教程年薪50万Spark2.0从入门到精通年薪50万!人工智能学习路线教程年薪50万大数据入门到精通学习路线年薪50万机器学习入门到精通教程
仿小米商城类app和小程序视频教程深度学习数据分析基础到实战最新黑马javaEE2.1就业课程从 0到JVM实战高手教程MySQL入门到精通教程
查看: 474|回复: 0

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

[复制链接]
  • TA的每日心情
    奋斗
    2024-11-24 15:47
  • 签到天数: 804 天

    [LV.10]以坛为家III

    2053

    主题

    2111

    帖子

    72万

    积分

    管理员

    Rank: 9Rank: 9Rank: 9

    积分
    726782
    发表于 2021-8-29 11:39:17 | 显示全部楼层 |阅读模式

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

    一、回调函数

    这是最古老的一种异步解决方案:通过参数传入回调,未来调用回调时让函数的调用者判断发生了什么。 直接偷懒上阮大神的例子: 假定有两个函数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;
                    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=event;
     }
    };
    

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

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

    哎...今天够累的,签到来了1...
    回复

    使用道具 举报

    您需要登录后才可以回帖 登录 | 立即注册

    本版积分规则

    QQ|手机版|小黑屋|Java自学者论坛 ( 声明:本站文章及资料整理自互联网,用于Java自学者交流学习使用,对资料版权不负任何法律责任,若有侵权请及时联系客服屏蔽删除 )

    GMT+8, 2024-12-23 04:44 , Processed in 0.060438 second(s), 28 queries .

    Powered by Discuz! X3.4

    Copyright © 2001-2021, Tencent Cloud.

    快速回复 返回顶部 返回列表