【javascript】异步编年史,从“纯回调”到Promise

异步和分块——程序的分块执行

 
一开始学习javascript的时候, 我对异步的概念一脸懵逼, 由于当时百度了不少文章,但不少各类文章不负责任的把笼统的描述混杂在一块儿,让我对这个 JS中的重要概念难以理解, “异步是非阻塞的”, “Ajax执行是异步的”, "异步用来处理耗时操做".... 
 
全部人都再说这个是异步的,那个是异步的,异步会怎么怎样,可我仍是不知道:“异步究竟是什么?”
 
后来我发现,其实理解异步最主要的一点,就是记住: 咱们的程序是分块执行的
 
分红两块, 同步执行的凑一块, 异步执行的凑一块,搞完同步,再搞异步
 
废话很少说, 直接上图:
 
 图1

 

图2 
 

 

 

异步和非阻塞

 

我对异步的另一个难以理解的点是异步/同步和阻塞/非阻塞的关系
 
人们常说: “异步是非阻塞的” , 但为何异步是非阻塞的, 或者说, 异步和非阻塞又有什么关系呢
 
非阻塞是对异步的要求, 异步是在“非阻塞”这一要求下的必然的解决方式 
 
我们看看一个简单的例子吧
ajax("http://XXX.", callback);
doOtherThing()

 

你确定知道ajax这个函数的调用是发出请求取得一些数据回来, 这可能须要至关长的一段时间(相比于其余同步函数的调用)javascript

 
对啊,若是咱们全部代码都是同步的,这就意味着, 在执行完ajax("http://XXX.", callback)这段代码前, doOtherThing这个函数是不会执行的在外表看起来, 咱们的程序不就“阻塞”在ajax("http://XXX.", callback);这个函数里了么? 这就是所谓的阻塞啊
 
让咱们再想想doOtherThing由于“同步”形成“阻塞”的话会有多少麻烦: doOtherThing()里面包含了这些东西: 这个简略的函数表明了它你接下来页面的全部的交互程序, 但你如今在ajax执行结束前,你都没有办法去doOtherThing,去作接下来全部的交互程序了。 在外观上看来, 页面将会处于一个“彻底假死”的状态。
 
由于咱们要保证在大量ajax(或相似的耗时操做)的状况下,交互能正常进行
 
因此同步是不行的
 
由于同步是不行的, 因此这一块的处理, 不就都是异步的嘛
 
若是这样还不太理解的话, 咱们反方向思考一下, 假设一个有趣的乌托邦场景: 假设ajax的执行能像一个同步执行的foreach函数的执行那样迅速, javascript又何苦对它作一些异步处理呢? 就是由于它如此耗时, 因此javascript“审时度势”, 拿出了“异步”的这一把刷子,来解决问题
 
正由于有“非阻塞”的刚需, javascript才会对ajax等一律采用异步处理
 
“由于要非阻塞, 因此要异步”,这就是我我的对异步/同步和阻塞/非阻塞关系的理解
 
可能你没有注意到,回调实际上是存在不少问题的
 
 
没错,接下来的画风是这样子的:
 

 

回调存在的问题

 
回调存在的问题可归纳为两类:
 

信任问题和控制反转

 
可能你比较少意识到的一点是:咱们是没法在主程序中掌控对回调的控制权的。
例如:
 
ajax( "..", function(..){    } );

 

咱们对ajax的调用发生于如今,这在 JavaScript 主程序的直接控制之下。但ajax里的回调会延迟到未来发生,而且是在第三方(而不是咱们的主程序)的控制下——在本例中就是函数 ajax(..) 。这种控制权的转移, 被叫作“控制反转”
 
1.调用函数过早
 
调用函数过早的最值得让人注意的问题, 是你不当心定义了一个函数,使得做为函数参数的回调可能延时调用,也可能当即调用。   也即你使用了一个可能同步调用, 也可能异步调用的回调。 这样一种难以预测的回调。
 
大多数时候,咱们的函数老是同步的,或者老是异步的
 
例如foreach()函数老是同步的
array.foreach(
  x =>  console.log(x)
)
console.log(array)

 

虽然foreach函数的调用须要必定的时间,但array数组的输出必定是在全部的数组元素都被输出以后才输出, 由于foreach是同步的
 
又如setTimeout老是异步的:
setTimeout( () => {  console.log('我是异步的')  }, )
console.log('我是同步的')

 

有经验的JS老司机们一眼就能看出, 必定是输出
 
我是同步的
我是异步的

 

而不是
 
我是异步的
我是同步的

 

但有些时候,咱们仍有可能会写出一个既可能同步, 又可能异步的函数,
 
例以下面这个极简的例子:
我试图用这段代码检查一个输入框内输入的帐号是否为空, 若是不为空就用它发起请求。(注:callback不管帐号是否为空都会被调用)
 
// 注: 这是一个至关乌托邦,且省略诸多内容的函数
function login (callback) {
        // 当取得的帐号变量name的值为空时, 当即调用函数,此时callback同步调用)
       if(!name) {
           callback();
           return   // name为空时在这里结束函数
        }
       // 当取得的帐号变量name的值不为空时, 在请求成功后调用函数(此时callback异步调用)
      request('post', name, callback)
}

 

相信各位机智的园友凭第六感就能知晓:这种函数绝B不是什么好东西。
 
的确,这种函数的编写是公认的须要杜绝的,在英语世界里, 这种可能同步也可能异步调用的回调以及包裹它的函数, 被称做是 “Zalgo” (一种都市传说中的魔鬼), 而编写这种函数的行为, 被称做是"release Zalgo" (将Zalgo释放了出来)
 
为何它如此可怕? 由于函数的调用时间是不肯定的,难以预料的。 我想没有人会喜欢这样难以掌控的代码
例如:
var a =1
zalgoFunction () {
  // 这里还有不少其余代码,使得a = 2可能被异步调用也可能被同步调用
    [  a = 2  ]
  }
console.log(a)

 

结果会输出什么呢?  若是zalgoFunction是同步的, 那么a 显然等于2, 但若是 zalgoFunction是异步的,那么 a显然等于1。因而, 咱们陷入了没法判断调用影响的窘境。
 
这只是一个极为简单的场景, 若是场景变得至关复杂, 结果又会如何呢?
 
你可能想说: 我本身写的函数我怎么会不知道呢?
请看下面:
 
1. 不少时候这个不肯定的函数来源于它人之手,甚至来源于彻底没法核实的第三方代码
2. 在1的基础上,咱们把这种不肯定的状况稍微变得夸张一些: 这个函数中传入的回调, 有99%的概率被异步调用, 有1%的概率被同步调用
 
在1和2的基础上, 你向一个第三方的函数传了一个回调, 而后在通过了一系列不可描述的bug后......
 
 

 

2.调用次数过多
 
这里取《你不知道的javascript(中卷)》的例子给你们看一看:
 
做为一个公司的员工, 你须要开发一个网上商城, payWithYourMoney是你在确认购买后执行的扣费的函数, 因为公司须要对购买的数据作追踪分析, 这里须要用到一个作数据分析的第三方公司提供的analytics对象中的purchase函数。 代码看起来像这样
 
analytics.purchase( purchaseData, function  () {
      payWithYourMoney ()
} );

 

在这状况下,可能咱们会忽略的一个事实是: 咱们已经把payWithYourMoney 的控制权彻底交给了analytics.purchase函数了,这让咱们的回调“任人宰割”
 
而后上线后的一天, 数据分析公司的一个隐蔽的bug终于显露出来, 让其中一个本来只执行一次的payWithYourMoney执行了5次, 这让那个网上商城的客户极为恼怒, 并投诉了大家公司。
 
大家公司也很无奈, 这个时候惊奇的发现:   payWithYourMoney的控制彻底不在本身的手里 !!!!!
 
后来, 为了保证只支付一次, 代码改为了这样:
 
var analysisFlag  = true // 判断是否已经分析(支付)过一次了
analytics.purchase( purchaseData, function(){
     if (!analysisFlag) {
           payWithYourMoney ()
            analysisFlag = false
     }
} );

 

可是, 这种方式虽然巧妙, 但却仍不够简洁优雅(后文提到的Promise将改变这一点)
 
并且, 在回调函数的无数“痛点”中, 它只能规避掉一个, 若是你尝试规避掉全部的“痛点”,代码将比上面更加复杂而混乱。
 
3.太晚调用或根本没有调用
由于你失去了对回调的控制权, 你的回调可能会出现预期以外的过晚调用或者不调用的状况(为了处理这个“痛点”你又将混入一些复杂的代码逻辑)
 
4.吞掉报错
回调内的报错是可能被包裹回调的外部函数捕捉而不报错,(为了处理这个“痛点”你又又又将混入一些复杂的代码逻辑)
 
5.回调根本没有被调用

没办法在复杂的异步场景中很好地表达代码逻辑

 
哎呀这里我就不说废话了: 在异步中若是你老是依赖回调的话,很容易就写出你们都看不懂, 甚至本身过段时间也看不懂的代码来, 嗯, 就这样
 
看个例子,下面的doA到doF都是异步的函数
doA( function(){
    doB();
    doC( function(){
      doD();
          } )
    doE();
} );
doF();
 

 

请问这段代码的调用顺序 ? 固然你知道它确定不是A -> B -> C -> D -> E,但即便你富有经验,通常也得花上一段时间的功夫才能把它理清楚吧。( A → B → C → D → E → F 。)
 
这并非咱们开发人员的锅, 而是由于人脑的思惟方式原本就是线性的, 而回调却打破了这种线性的思惟, 咱们须要强制地抛弃咱们看到的A -> B -> C -> D -> E的顺序,去构建另外一套思惟。
 
因此说,异步编程中有大量回调混杂的时候, 所形成的可读性差的问题,是回调自己的“表达方式“形成的
 
 

 

 
回调的局限性仅仅如此? NO,请看下面:
 
对于一些比较常见的异步场景回调也没办法用足够简洁优雅的方式去处理:
这些场景包括但不限于:链式,门和竞态
 
链式
 
首先你确定知道用回调处理大量存在链式的异步场景的画风是怎样的
例如这样:
setTimeout(function (name) {
  var catList = name + ','
  setTimeout(function (name) {
    catList += name + ',';
    setTimeout(function (name) {
      catList += name + ',';
      setTimeout(function (name) {
        catList += name + ',';
        setTimeout(function (name) {
          catList += name;
          console.log(catList);
        }, 1, 'Lion');
      }, 1, 'Snow Leopard');
    }, 1, 'Lynx');
  }, 1, 'Jaguar');}, 1, 'Panther');

 

让人一脸蒙逼的回调函数地狱
 
很显然,大多数时候你尝试这样作,是由于
你须要经过调用第一层异步函数,取得结果
而后把结果传给第二层异步函数,第二层异步函数也取得结果后
传递结果给第三个异步函数, 。。。。。 N
 
很显然,咱们的代码风格应该是“链式”风格, 但却由于回调的缘由被硬生生折腾成了难懂的“嵌套”风格! (别担忧, 我下面介绍的Promise将改变这一点)
 
 
什么叫“门”?, 你能够大概理解成: 如今有一群人准备进屋,但只有他们全部人都到齐了,才能“进门” ,也就是: 只有全部的异步操做都完成了, 咱们才认为它总体完成了,才能进行下一步操做
 
下面这个例子里, 咱们试图经过两个异步请求操做,分别取得a和b的值并将它们以 a + b的形式
(前提: 咱们但愿当a和b的取值都到达的时候才输出!!)
var a, b;
function foo(x) {
   a = x * 2;
   if (a && b) {
        baz();
    }
}
function bar(y) {
    b = y * 2;
    if (a && b) {
           baz();
    }
}
function baz() {
     console.log( a + b );
}
// ajax(..)是某个库中的某个Ajax函数
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

 

这段代码比前面那段“链式”里的回调地狱好懂多了,可是却依然存在这一些问题:
 
咱们使用了两个  if (a && b) { }  去分别保证baz是在a和b都到达后才执行的,试着思考一下:
两个  if (a && b) { }  的判断条件是否能够合并到一块儿呢,由于这两个判断条件都试图表达同一种语意: a 和 b都到达, 能合并成一条语句的话岂不是更加简洁优雅 ? (一切都在为Promise作铺垫哦~~~~啦啦啦)
 
竞态(可能跟你通常理解的竞态有些不一样)
 
一组异步操做,其中一个完成了, 这组异步操做便算是总体完成了
 
在下面,咱们但愿经过异步请求的方式,取得x的值,而后执行foo或者bar,但但愿只把foo或者bar其中一个函数执行一次
 
var flag = true;
function foo(x) {
    if (flag) {
        x = x + 1
        baz(x);
        flag = false
     }
}
function bar(x) {
     if (flag) {
         x = x*2
         baz(x);
         flag = false
     }
}
function baz( x ) {
       console.log( x );
}
// ajax(..)是某个库中的某个Ajax函数
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

 

在这里,咱们设置了一个flag, 设它的初始值为true, 这时候foo或者bar在第一次执行的时候, 是能够进入if内部的代码块而且执行baz函数的, 但在if内部的代码块结束的时候, 咱们把flag的值置为false,这个时候下一个函数就没法进入代码块执行了, 这就是回调对于竞态的处理
 
正由于回调给咱们带来的麻烦不少,ES6引入了Promise的机制:
 
 
 

一步一步地揭开Promise神秘的面纱

 
首先让咱们回顾一下“回调函数”给咱们带来信任危机的缘由: 咱们没法信任放入回调参数的函数, 由于 它没有强制要求经过一种肯定的(或固定的)形式给咱们回调传递有效的信息参数,例如: 异步操做成功的信息, 异步操做失败的信息,等等。 咱们既然都无从获得这些信息, 又怎么能拥有对回调的控制权呢?
 
没错,咱们急需作的的就是获得这些对咱们的“回调”相当重要的信息(异步操做成功的信息, 异步操做失败的信息), 而且经过一种规则让它们强制地传递给咱们的回调
 
让咱们一步步来看看什么是Promise
 
1.首先Promise是一个能够包含异步操做的对象
new Promise(function() {
      /* 异步操做  */
}

 

2.其次, 这个对象拥有本身的状态(state),能够分别用来表示异步操做的“成功”, “失败”,“正在进行中”。
它们是:
Fulfilled: 成功
Rejected:拒绝
Pending: 进行中
 
3.那怎么控制这三个状态的改变呢?
 
当new 一个Promise对象的时候, 咱们能接收到两个方法参数: resolve和reject, 当调用 resolve方法的时候,会把Promise对象的状态从Pending变为Fulfilled(表示异步操做成功了),当调用 reject方法的时候, 会把Promise对象的状态从Pending变为Rejected,表示异步操做失败了, 而若是这两个函数没有调用,则Promise对象的状态一直是Pending(表示异步操做正在进行)
 
咱们异步执行的函数能够放在Promise对象里, 而后变成这样
var promise = new Promise(function(resolve, reject) {
  // 这里是一堆异步操做的代码
  if (/* 异步操做成功 */){
    resolve(value);
  } else {
    reject(error);
  }
});

 

4. 最重要的一点, 咱们怎么把这个状态信息传递给咱们异步处理后的函数:
 
咱们刚刚说了, Promise有Resolved和Rejected两种状态, 这两种状态分别对应Promise的then方法里的两个回调参数
promise.then(function(value) {
  // 成功
}, function(error) {
  // 失败
});

 

第一个参数方法对应Resolved, 第二个参数方法对应Rejected
 
并且Promise成功的时候(调用resolve), resolve返回的参数能够被第一个回调接收到, 如上面的value参数
而当Promise失败的时候(调用reject), reject返回的错误会被传递给第二个回调, 如上面的error
 
【辩解】: 你可能会说:哎呀咱们绕了一圈不是又回到了回调了吗? Promise好像也不是特别革命性的一个新东西嘛!可是, 咱们就围绕信任问题来讲, Promise的确以一种强制的方式, 将回调的形式固定了下来(两个方法参数),而且传递了必要的数据(异步取得的值或抛出的错误)给咱们的回调。
 
而这样作,咱们已经达到了咱们的目的: 相对来讲,咱们使得回调变得“可控”了, 而不是像单纯使用回调那样, 由于控制反转而陷入信任危机的噩梦。
 
打个比方, 让司机们依据对自身的道德要求让不闯红灯,和经过扣分的机制和法律限制闯红灯的现象, 不管是性质上仍是效果上,这二者之间都是大相径庭的。
 

Promise是怎么一个个地解决回调带来的问题的

 

 

 

 

1.回调过早调用
 
让咱们回到那个回调的痛点:咱们有可能会写出一个既可能同步执行, 又可能异步执行的“zalgo”函数。但Promise能够自动帮咱们避免这个问题:
 
若是对一个 Promise 调用 then(..) 的时候,即便这个 Promise是当即resolve的函数(即Promise内部没有ajax等异步操做,只有同步操做), 提供给then(..) 的回调也是会被异步调用的,这帮助咱们省了很多心
 
2. 回调调用次数过多
 
Promise 的内部机制决定了调用单个Promise的then方法, 回调只会被执行一次,由于Promise的状态变化是单向不可逆的,当这个Promise第一次调用resolve方法, 使得它的状态从pending(正在进行)变成fullfilled(已成功)或者rejected(被拒绝)后, 它的状态就不再能变化了
 
因此你彻底没必要担忧Promise.then( function ) 中的function会被调用屡次的状况
 
3. 回调中的报错被吞掉
 
要说明一点的是Promise中的then方法中的error回调被调用的时机有两种状况:
 
1. Promise中主动调用了reject  (有意识地使得Promise的状态被拒绝), 这时error回调可以接收到reject方法传来的参数(reject(error))
2. 在定义的Promise中, 运行时候报错(未预料到的错误), 也会使得Promise的状态被拒绝,从而使得error回调可以接收到捕捉到的错误
例如:
var p = new Promise( function(resolve,reject){
     foo.bar(); // foo未定义,因此会出错!
     resolve( 42 ); // 永远不会到达这里 :(
} );
p.then(
   function fulfilled(){
       // 永远不会到达这里 :(
    },
    function rejected(err){
        // err将会是一个TypeError异常对象来自foo.bar()这一行
     }
);

 

4. 还有一种状况是回调根本就没有被调用,这是能够用Promise的race方法解决(下文将介绍)
// 用于超时一个Promise的工具
function timeoutPromise(delay) {
   return new Promise( function(resolve,reject){
      setTimeout( function(){
            reject( "Timeout!" );
          }, delay );
      } );
}

// 设置foo()超时 Promise.race( [ foo(), // 试着开始foo() timeoutPromise( 3000 ) // 给它3秒钟 ] ) .then( function(){ // foo(..)及时完成! }, function(err){ // 或者foo()被拒绝,或者只是没能按时完成 // 查看err来了解是哪一种状况 } );

 

 

Promise的完善的API设计使得它可以简洁优雅地处理相对复杂的场景

链式

 
咱们上面说了, 纯回调的一大痛点就是“金字塔回调地狱”, 这种“嵌套风格”的代码丑陋难懂,但Promise就能够把这种“嵌套”风格的代码改装成咱们喜闻乐见的“链式”风格
 
由于then函数是能够链式调用的, 你的代码能够变成这样
Promise.then(
  // 第一个异步操做
).then(
  // 第二个异步操做
).then(
  // 第三个异步操做
)

 

 
并且, 你每个then里面的异步操做能够返回一个值,传递给下一个异步操做
getJSON('/post/1.json').then(function(post) {
  return getJSON(post.commentURL);
}).then(function(comments) {
  // some code
})
 

 

第二个then接收到的comments参数等于都一个then里面接收到的getJSON(post.commentURL);
 
例如咱们上面提到的
 

 
可使用 Promise.all方法:
Promise.all([
  promise1,
  promise2
])
.then(([data1, data2]) =>  getDataAndDoSomething (data1,data2)

 

 
all方法接收一个Promise数组,而且返回一个新的“大Promise”, 只有数组里的所有Promise的状态都转为Fulfilled(成功),这个“大Promise”的状态才会转为Fulfilled(成功), 这时候, then方法里的成功的回调接收的参数也是数组,分别和数组里的子Promise一一对应, 例如promise1对应data1,promise2对应data2
 
而若是任意一个数组里的子Promise失败了, 这个“大Promise”的状态会转为Rejected, 而且将错误参数传递给then的第二个回调
 

竞态

 
能够用Promise.race方法简单地解决
 
romise.race方法一样是将多个Promise实例,包装成一个新的“大Promise”
例如
var p = Promise.race([p1, p2, p3]);

 

上面代码中,只要p一、p二、p3之中有一个Promise率先改变状态,p的状态就跟着改变。那个率先改变的 Promise 实例的返回值,就传递给p的回调函数。
 
 
最后讲个小故事
 
曾经我和小伙伴们搞比赛,合并代码都是经过QQ传代码文件而后手动合并,常常会为代码的管理不胜其烦, 遇到诸多问题。一个学长告诉我能够用git,但我当时却以为:“用QQ传代码合并就很好嘛, 用git的话学起来又麻烦,合并代码辛苦一点也很正常的嘛~~~”,直到有一天我真的用上了git这个可爱的版本控制系统 ——
 
当初劝我用git的学长的温暖的身影就浮现出来了....额...就像这样:
 

 

若是不对新的东西加以学习, 你可能不知道旧的东西会给你带来多少麻烦
若是永远执着于旧的那一套东西, 你可能不知道新的东西能给你带来多少但愿和机遇
 
因此不要老是说:“用原来的就挺好的呀”
 
 
参考资料:《 你不知道的javascript》—— [美] Kyle Simpson
 
相关文章
相关标签/搜索