【javascript】异步编年史,从“纯回调”到Promise
异步和分块——程序的分块执行
一开始学习javascript的时候, 我对异步的概念一脸懵逼, 因为当时百度了很多文章,但很多各种文章不负责任的把笼统的描述混杂在一起,让我对这个 JS中的重要概念难以理解, “异步是非阻塞的”, “Ajax执行是异步的”, "异步用来处理耗时操作"....
所有人都再说这个是异步的,那个是异步的,异步会怎么怎样,可我还是不知道:“异步到底是什么?”
后来我发现,其实理解异步最主要的一点,就是记住: 我们的程序是分块执行的。
分成两块, 同步执行的凑一块, 异步执行的凑一块,搞完同步,再搞异步
废话不多说, 直接上图:
图1
图2
异步和非阻塞
我对异步的另外一个难以理解的点是异步/同步和阻塞/非阻塞的关系
人们常说: “异步是非阻塞的” , 但为什么异步是非阻塞的, 或者说, 异步和非阻塞又有什么关系呢
非阻塞是对异步的要求, 异步是在“非阻塞”这一要求下的必然的解决方式
咱们看看一个简单的例子吧
ajax("http://XXX.", callback); doOtherThing()
你肯定知道ajax这个函数的调用是发出请求取得一些数据回来, 这可能需要相当长的一段时间(相比于其他同步函数的调用)
对啊,如果我们所有代码都是同步的,这就意味着, 在执行完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('我是异步的') },0 ) 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]);
上面代码中,只要p1、p2、p3之中有一个Promise率先改变状态,p的状态就跟着改变。那个率先改变的 Promise 实例的返回值,就传递给p的回调函数。
最后讲个小故事
曾经我和小伙伴们搞比赛,合并代码都是通过QQ传代码文件然后手动合并,经常会为代码的管理不胜其烦, 遇到诸多问题。一个学长告诉我可以用git,但我当时却觉得:“用QQ传代码合并就很好嘛, 用git的话学起来又麻烦,合并代码辛苦一点也很正常的嘛~~~”,直到有一天我真的用上了git这个可爱的版本控制系统 ——
当初劝我用git的学长的温暖的身影就浮现出来了....额...就像这样:
如果不对新的东西加以学习, 你可能不知道旧的东西会给你带来多少麻烦
如果永远执着于旧的那一套东西, 你可能不知道新的东西能给你带来多少希望和机遇
所以不要总是说:“用原来的就挺好的呀”
参考资料:《 你不知道的javascript》—— [美] Kyle Simpson
我叫彭湖湾,请叫我胖湾