js执行本身并不慢,慢的是读写外部数据,比如等待 Ajax 请求返回结果。这个时候,如果对方服务器迟迟没有响应,或者网络不通畅,就会导致脚本的长时间停滞
所以js将执行模式分为两种,第一种同步,可以理解为是按顺序从上往下执行,执行顺序与任务排列顺序是一致同步的,这往往用于一些简单的、快速的、不涉及 IO 读写的操作。
另一种是异步,将代码分为两段,第一段包含对外部数据的请求,第二段代码被写成一个回调函数。第一段代码执行完,将程序的执行权交给其他任务,等到外部数据返回,再由系统通知执行第二段代码。所以程序的执行顺序与任务排列顺序是不一致的、异步的。关于js单线程可以参考另一篇js单线程详解

回调函数

有两个函数fn1和fn2,后者必须等到前者执行完成才执行,可以考虑改写fn1,把fn2写成fn1的回调函数

function fn1(callback){
       ...   // fn1逻辑代码
       callback();   // 调用callback
   }
   fn1(fn2);

回调函数的优点是简单、容易理解和部署,缺点是不利于代码的阅读和维护,各个部分之间高度耦合

事件监听

另一种异步思路是采用事件驱动,任务的执行不取决代码顺序,取决于某个事件是否发生,还是以fn1和fn2为例,这里采用jquery的写法

fn1.on('done', fn2)

当fn1发生done事件,就执行fn2,然后对fn1进行改写

function fn1(){
       ...   // fn1逻辑代码
       setTimeout(function(){
          fn1.trigger('done')
       }, 1000)
   }

这个表示fn1在执行完逻辑代码之后,触发’done’事件,系统监听到fn1的done事件,然后fn2执行,这样和回调函数的效果一致。
这种方法的优点是比较容易理解,可以绑定多个事件,每个事件可以指定多个回调函数,而且可以去耦合,fn1不需要接触fn2,只需要触发done,而done可以绑定fn3,fn4等,切断了fn1和fn2僵硬的联系

发布/订阅

事件完全可以理解为”信号”, 如果存在一个“信号中心”,某个任务执行完成,就向信号中心“发布”(publish)一个信号,其他任务可以向信号中心“订阅”(subscribe)这个信号。这叫做发布/订阅模式,又称“观察者模式”。
首先,fn2向信号中心订阅“done”信号,这里将信号中心设为jQuery

jQuery.subscribe('done', fn2);

然后,对fn1进行改写

function fn1(){
       ...   // fn1的逻辑代码
       setTimeout(function(){
           jQuery.publish('done');
       }, 1000)
   }

fn2完成执行后,可以取消订阅

jQuery.unsubscribe('done', fn2);

这个比监听事件好的一点是,可以有更多的主动操作,比如fn2可以随时取消订阅,而通过信号中心这个中间层,可以了解存在多少信号,每个信号有多少订阅者

异步操作的流程控制

如果有多个异步操作,就存在一个流程控制问题:确定执行的顺序

function async(arg, callback){
       setTimeout(function(){ callback(arg * 2); }, 1000)
    }

上面的async函数模拟一个需要1秒完成的任务,1秒之后才执行回调函数,如果有6个异步任务,每个async都要等上一个async结束之后再执行,最后执行一个final函数

function final(value){
       console.log('完成', value);
   }

就会出现这样的代码

async(1, function(value){
       ...   // 逻辑代码
       async(value, function(value){
       //第一个value是正在执行的函数参数
          async(value, function(value){
              async(value, function(value){
                 async(value, function(value){
                 //第六次的时候把final当做回调函数传入
                    async(value, final);
                 })
              })
          })
       })
   })

用6个回调函数的嵌套,不仅写起来麻烦,容易出错,而且难以维护

串行执行

我们可以编写一个流程控制函数,让它来控制异步任务,一个任务完成以后,再执行另一个

//items数组保存每一个异步任务的参数
   var items = [1, 2, 3, 4, 5, 6];
   //results数组保存每一个异步任务的运行结果
   var results = [];
   function series(item){
       if(item){
           async(item, function(result){
              results.push(result);
              return series(items.shift())
           })
       } else {
          return final(results);
       }
   }
   series(items.shift());

这样的流程控制预设了每一个异步函数的参数值,虽然容易维护, 但割裂了每一次回调之间的关系,适用在回调函数之间没什么联系的情况下

并行执行

在回调函数之间没什么联系的情况下,也可以让所有异步任务同时执行,等到全部完成之后再执行final函数

var items = [1, 2, 3, 4, 5, 6];
   var results = [];
   items.forEach(function(item){
       async(item, function(result){
           results.push(result);
           if(results.length == items.length){
               final(results);
           }
       })
   })

forEach方法会同时发起6个异步任务,等到它们全部完成以后,才会执行final函数。并行执行的好处是效率较高,比起串行执行一次只能执行一个任务,较为节约时间。但是问题在于如果并行的任务较多,很容易耗尽系统资源,拖慢运行速度。因此有了第三种流程控制方式。

并行与串行的结合

所谓并行与串行的结合,就是设置一个门槛,每次最多只能并行执行n个异步任务。这样就避免了过分占用系统资源

var items = [ 1, 2, 3, 4, 5, 6 ];
    var results = [];
    var running = 0;
    var limit = 2;

    function launcher() {
      while(running < limit && items.length > 0) {
        var item = items.shift();
        async(item, function(result) {
          results.push(result);
          running--;
          if(items.length > 0) {
            launcher();
          } else if(running == 0) {
            final();
          }
        });
        running++;
      }
    }

    launcher();

变量running记录当前正在运行的任务数,只要低于门槛值,就再启动一个新的任务,如果等于0,就表示所有任务都执行完了,这时就执行final函数。