首先,我们要清楚的知道 JavaScript语言的一大特点 —- 单线程 。
至于为啥主要是其它主要用途在于:作为浏览器脚本语言,提供给用户交互体验以及操作DOM元素。
浏览器的应用场景下,必然使得JavaScript只能是一门单线程的语言,因为多线程会带来复杂的同步问题(比如多个线程同时操作DOM时候的响应问题)。
但JavaScript还是提供了许多异步的方案来实现异步功能的需求,比如:回调函数,promise等,接下来我们一一来分析一下。
一 事件循环(Event Loop)
讲到JavaScript的异步操作肯定离不开一个关键词 - 事件循环(Event Loop)。
首先我们先了解一下什么是事件循环,先写一段伪代码来模拟一下:
// Event Loop是一个用作队列的数组(先进先出)var eventLoop = []var event;// Event Loop会一直循环while(true) {if (eventLoop.length > 0) {// 取出下一个event事件event = eventLoop.shift();// 执行事件try {event();} catch(e) {reportError(e);}}}
这只是一个模拟的伪代码,其event loop底层实现远比这要复杂的多,这里只是通过这段伪代码来更好理解。
循环的每一轮被称为tick,对于每个tick来说当队列中有等待的事件时候,就会取出下一个事件进行执行,而这些事件就是你些的一些回调函数。
注意:你使用setTimeout(…)时,并不是将其回调函数直接放入事件循环中,而是它将其设置了定时器,但定时器到时间后环境会将回调函数放入事件循环中,这样在后面的某个tick中将会从事件队列中取出并运行。所以这也是为什么setTimeout(…)的定时器时间有时候会不准的原因,因为你压根不知道当前正在执行的代码还要用多久才能到下个tick来取出事件执行。
由于JavaScript的单线程特性,其不同事件方法间并不影响。一旦事件方法取出运行一定会运行完之后再运行下一个事件方法,这称为 完整运行(run-to-completion)。
var one = 20;function a() {one = one + 1;}function b() {one = one * 2;}// 这里a运行或b运行,并不会影响到另一个函数的运行// 唯一造成影响的是,请求响应速度,决定了是a先运行还是b先运行ajax("http://some.url1.com",a);ajax("http://some.url2.com",b);
所以在JavaScript中这种异步操作唯一需要考虑的就是回调函数运行的顺序。而如果是多线程一起运行的话,可能出现的情况就多的多的多。(比如多线程中的语句的运行顺序也会导致多种运行结果。)
所以JavaScript相对于多线程语言来说,不确定性大大减少。其主要不确定性是在其方法运行的先后顺序,即通常所说的 竞态条件(race condition)。其上例中a函数与b函数竞争看谁先运行。
二 回调
在JavaScript中,其最常用的异步处理方式就是 回调(callback)。也是这么这门语言基础的异步处理方式,甚至现在很多高深、复杂的JavaScript应用程序所以依赖的异步基础也就是回调。上述第一节实例中最经典的回调例子就是使用ajax发起请求
// Aajax("http://some.url1.com",function() {// Cconsole.log("this is a callback function!");});// B
大部分开发者都知道这段代码运行顺序是:A -> B -> C。这种经典的回调模式也是目前为止在开发中广为使用。当然这种模式下也是有缺陷的,比如下段代可能是很经常在开发中出现的:
listen("click",function() {setTimeout(function() {ajax("http://some.url1.com",function(v) {console.log(v);});}, 1000);});
这种嵌套写法肯定见过甚至自己也写过,这种嵌套形式的回调函数通常会被称作回调地狱(callback hell)。甚至网上还有一些回调地狱的趣图。
戏称回调啊都给。
这种代码书写方式实在是非常的不友好,但回调带来的不仅仅是这一种弊端。我们将上述例子中的代码重写
listen("click", doSomeThings);function doSomeThings() {setTimeout(ajaxDo, 1000);}function ajaxDo() {ajax("http://some.url1.com", consoleResult);}function consoleResult(v) {console.log(v);}
虽然这样写法解决了啊都给式(我喜欢这么称呼)的代码结构,而且看起来就和我们平时编码格式差不多,但这样写法依旧脆弱。我们正常的编码逻辑思维式链式的。这种一会看一下下面一会看一下上面的,这种跳跃式的编码查看在开发过程中式很不友好的。且当代码量达到一定程度后,就算可能对你判断代码先后执行顺序也会造成不小的影响,这也增加了开发者的开发负担。
信任问题
什么是信任问题?很多开发者应该都碰到过只是没意思到是信任问题,这里就假设个例子:
一天,你在时候使用了用于最终功能的异步工具,代码可能是这样的(这里使用伪代码表示):
analytics.aboutResult(function(result) {// 假定要输出这个结果里的idwriteResultOnPage(result.id);})
然后经过自测,体测,预发等等顺利的一系列测试,你也很满意这段代码,简单高效!然后部署发布,一切皆大欢喜!这段时间内线上功能运行稳定一切正常,甚至你都忘了这段代码想着如何升职加薪。有一天,你下午悠闲地喝着奶茶,突然钉钉响起消息,点击一看 - 线上页面炸了!正好是这个方法涉及到的页面,你一脸懵逼甚至都记不起当初你写这段代码时候的逻辑。经过debug你发现,因为第三方库某些原因导致返回给你的result是null,所以代码抛出TypeError错误导致页面加载失败。为了解决这种情况,你加了下面这段代码:
analytics.aboutResult(function(result) {// 判断result返回时候是真的,因为null是假值,参考 值 章节if (result) {// 假定要输出这个结果里的idwriteResultOnPage(result.id);}})
急急忙忙的发布紧急修复分支将生产bug修复,并要和客户做出解释以及善后。
上述场景估计很多开发者应该都有遇见过,可能只是遇见的bug类型不一样(比如不反悔,第三方库方法直接因不明原因炸了等),这就是典型的回调使用中会碰到的 信任问题。我们拓展一下上面这段代码剧情:
- 回调调用顺序过早
- 回调调用顺序过晚
- 回调调用过多或过少
- 未传给回调函数内的参数
- 其第三方库自行吞掉了异常
……………..等等
这稍微一分析感觉就像是列出了一长串的麻烦列表,需要特殊处理的情况非常多且这也会导致你的代码很臃肿。是不是突然觉得:啊,这,第三方库一点都不值得信任!
其实不止第三方库,连你自身工程内的工作也会有这些信任问题:
function sumTwo(a, b) {return a + b;}// 正常使用sumTwo(1,2); // 3// 混入了字符串sumTwo(1,"2"); // "12"// 所以通常会对参数类型进行判断function sumTwoPlus(a, b) {if (typeof a !== "number" || typeof b !== "number") {throw Error("parameter is not a number.")}return a + b;}
相对回调来说,信任问题都只是表象,其最本质的问题在于:控制反转(inversion of control)。这才是导致了回调方式中信任链完全锻炼的根本原因。
虽然也有一些常见设计试图去解决这种信任问题,比如:”error-first”风格(也被称为Node风格,因为几乎所有的Nodejs的API都采用这种设计),成功的时候会清楚error参数值,失败则第一个error参数会被置真
function errorFirst(error, data) {if(error) {console.log(error);} else {console.log(data);}}
看上去好像解决了参数错误的问题,其实也并没有根本上解决上述提到的所有信任问题(看起来更像是仅仅保证一定会返回参数),虽然你也可以用但也避免导致代码冗余且复用性也不高。
综上所述,虽然回调确实是个非常棒的,在JavaScript单线程语言中解决异步编程的优秀方式,但我们也不能忽视其它所带来的一系列的问题。
三 Promise
什么是Promis?来用个场景打比方:
我去奶茶店买奶茶,点了一杯杨枝甘露,去冰,正常糖,多加芒果,并且给店员付了15块钱完成了这次交易。
这时候我不会立马得到这杯奶茶,因为要现做,所以店员结算完后会给我一个等待的排号单子,单子上会有这次交易的特殊编码,保证我能获得我点的那杯奶茶。
在等待过程中我可以刷刷手机,甚至去隔壁点买其他小吃。尽管我还没拿到奶茶但这个带有特殊编码的单子被当作了那杯奶茶的占位符,这个占位符并不在依赖于时间,其是一个未来值。
当店员说这个特殊编号的单子里奶茶做好后,我只需要出示一下单子就可以高高兴兴领取我的那杯奶茶。当然也有可能我被店员叫过去时候,店员说已经没有材料做杨枝甘露了,这笔交易单子失败了,之后退钱啥的。但这个单子无论是失败还是成功,其最后的结果在店员和你说的时候就不会改变了。
这个例子大家可能都有经历过,但为啥要用这来类比Promise呢?
我们来看Promise使用方式,结合一起来理解什么是Promise
// 如何创建promise// 创建个你要买啥的函数function buyWhat(resolve,reject) {// 你实际要买啥的的代码}// 通过Promise创建奶茶店的"单子"var teaPromise = new Promise(buyWhat);// 制作过程完成teaPromise.then(// 店员喊你取奶茶function success() { console.log("喝到奶茶很高兴!"); },// 店员和你说奶茶没了function fail() { console.log("居然没奶茶!生气!"); })
这样理解起来是不是会接收更快(这里也只是本人自己理解与看法,如果你有自己想法啥欢迎讨论)。
- 对于Promise对象使用,被传入的 决议回调(即上例中的buyWhat函数)会被立即执行然后返回一个Promis对象(就像你买奶茶和店员说我要啥啥啥店员都会立即的记下并生单子)。
- 其在决议回调中参数有两个:resolve,reject 被通常为 决议函数。resolve(…)通常用于将Promise对象标识为完成,反之reject (…)通常用于将Promise对象标识为拒绝(有奶茶并制作好触发reject (…),没材料做奶茶了触发reject (…))。
- 返回的Promise的then方法中传入的两个函数分别是:完成处理函数,拒绝处理函数。分别对应当Promise对象被标记为 完成 或 拒绝时候的处理函数,它将会在未来某个时刻中运行(当店员喊我的时候,就触发了我对不同结果的反应)。
- 当这个Promise一旦被决议了(成功或拒绝),则该Promise对象就是一个外部不可变的值,所以我们可以认为这个对象是安全的,可以传递给任何第三方(好比你最后奶茶单子,无论可取不可取,这个单子结果是无法改变的。)。
从外部来看,其Promise内部封装了依赖于时间的状态(其等待底层值的完成或拒绝),所以Promise本身是和时间无关的。故在开发中Promise可以按照可以预测的方式来组合,而不用关心其时序的结果。且Promise一旦决议它就是一个不变值,无论何时何地查看都是一样的,非常的安全。
如果这样理解起来还是有点难度,也可以换个思维来理解:
我们可以将Promise当成一个事件来看待,和你在JavaScript中使用到的事件一样。其then()里两个处理函数分别是对这个Promise对象状态 完成/拒绝 进行监听。一旦状态决议后,立马执行其注册的相关方法。
严格意义上来讲,Promise并不是事件,这样理解其实也有缺陷。但对于刚刚接触的开发者来说,这种说法会稍微好理解一些(其后续还是需要自己不断学习来完善自己对Promise的理解)。
信任问题
通过上述的代码以及个人一些讲解,你应该对Promise也有了一定的了解。
现在我们就来了解Promise模式相对于传统回调模式在信任问题上是如何设计实现的。
回顾一下在 回调 中讲过可能会出现的一些问题:
- 回调调用顺序过早
- 回调调用顺序过晚
- 回调调用过多或过少
- 未传给回调函数内的参数
- 其第三方库自行吞掉了异常
我们来一个一个的看Promise是如何解决的。
1.调用过早/过晚
在回调中,一个任务有时同步完成或有时是异步完成,从而导致调用时间出错。
// 例如之前讲的"error-first"模式可能因为内部直接报错导致变为同步方法function errorFirst(error, data) {// 若设计中要是报错时候直接抛出,则就立马显示了error变成了同步模式if(error) {console.log(error);} else {// 又或因为要处理正确返回的参数,调用该回调函数时间过于延后console.log(data);}}
而Promise的设计上就杜绝了这种情况发生
// 决议函数立即被执行var p = new Promise(function(resolve,reject) { //成功或拒绝 });// then(...)里的函数被注册到未来某个时间被执行p.then(function success() { console.log("success"); },function fail() { console.log("fail"); })
其无论决议函数是同步执行还是异步直接,其then()里注册的函数都会在下一个异步时机点上被依次调用,且任意一个都无法影响或延误其他的回调方法的调用。
// 1 2 3 其3方法打断或占用2方法,这也是Promise一种特性。p.then(function success() {p.then(function success() {console.log("3");})console.log("1");});p.then(function success() {console.log("2");});
但不同的两个Promise的then(…)注册方法的调用顺序还是无法确定的,所以在开发中也不会依赖不同Promise间回调的顺序与调度。
2.回调未调用
首先,Promise在设计上是没有任何东西可以阻止它像你发出决议通知(哪怕是JavaScript的错误)。当然这是建立在被决议的基础上,如果Promise本身永远不决议呢?
当然Promise设计者已经考虑到这个问题,Promise也提供解决方法:其使用了一种称为 竞态 的高级抽象机制:
// 用于超时一个Promise的工具function timeout(delay) {return new Promise(function(resolve,reject) {setTimeout(function() {reject("Timeout!");}, delay)});}// 给方法注册超市Promise.race([foo(), // 运行foo方法timeout(3000) // 给它三秒]).then(function success() { console.log("foo 完成了!"); },function fail() {// 超时或失败都会调用这个console.log("foo 失败了。");})
关于Promise的超时模式,后面我们会专门开文章讲解,这里就不多赘述了。
3.调用过多/过少
Promise其定义的方式使得它只能被决议一次。因为在决议回调里 ,其决议函数仅有一种且一次被调用,当Promise被决议后其状态无法被改变,故想多次调用决议函数是无效的。
所以任何通过then(…)方法注册的回调函数都只会被调用一次(或成功或拒绝)。当然如果你将同一个方法多次注册,那么调用的次数就和你注册的次数一致,但这就是开发者个人的行为了,如果出错那你要为自己的行为买单。
4.没传递参数
Promise至多只能有一个决议值(完成或拒绝)。
如果你没有用任何值显示决议,则这个值就为undefined(这是JavaScript中常见的处理方式)。
无论当前还是未来,该值都会被传入所有被注册(且适当的完成或拒绝)的回调函数中。
注意在使用决议函数resolve(…)/reject(…)的时候,除了第一个传入的参数以外其他参数将会被自动忽略。如果想传多个值可以将它们封装到对象中传入。
5.吞掉错误异常
Promise在决议过程中,无论发生任何一个JavaScript的错误(比如TypeError、ReferenceError),则这个异常都会被捕获且这个Promise对象会被标记为拒绝。
// 决议函数出错了var p = new Promise(function(resolve) {foo(); // 出错了resolve(); // 无法运行到});// 照样会被捕获触发注册函数p.then(function success() { // 这里不会触发 },function fail(error) {// 会触发这里,错误可以查看errorconsole.log(error);})
这里Promise甚至把JavaScript异常也变成了一种异步行为,进而大大降低了竞态条件出现的可能。
说到这,肯定有聪明的小伙伴问:如果我then(…)注册的方法出错会怎么样呢?
Good Question!这里就要提到Promise的then(…)方法的返回值了 。其then(…)方法的返回值是一个全新的Promise对象,如果then(…)注册方法抛出异常,则返回的Promise对象也会因此标识变为拒绝。
// 决议函数出错了var p = new Promise(function(resolve) {resolve(); // 无法运行到});// 照样会被捕获触发注册函数p.then(function success() {foo(); // 报错one(); // 这里不会触发},function fail() { // 这里不会触发 },).then(function success() { // 这里不会触发 },function fail(error) {// 会触发这里,错误可以查看errorconsole.log(error);})
又有小伙伴会问:为啥不直接调用Promise本身的fail方法呢?
Very Good Question!但如果这样设计的话,它就违背了Promise的一条基本原则:即Promise决议后其状态不可变。如果直接调用Promise本身的fail导致其状态改变,也会接连导致有关于这个Promise对象所注册的函数的调用,这种情况是非常的糟糕的。
总结
通过一系列的讲解和分析,你应该对Promise有了许多基本的认识,且对Promise对象也建立了信任感。
从本质来看,Promise也并没有完全摆脱回调函数的使用,但其模式通过可以信任的语义将回调函数作为参数传递,使得这种行为变得可靠。
且通过把回到的控制反转再反转回来(其传入一个回调函数且后续的事件对象接收另一个回调函数,实际上其是对反转的反转),将控制器又拿了回来并放到了这个可信任的系统中(Promise)。这是一种非常棒的异步编码方式。
链式流
之前我们也文章说暗示过Promise不是一个单一执行this-then-that操作的机制。首先我们要知道Promise所拥有的两个固有行为特性:
- 每次你调用then(…)的时候,其都会创建返回一个全新的Promise对象。
- 不管then(…)调用的完成回调(第一个参数)返回的值是什么,它都会被自动设置为被链接Promise(第一个点中的)的完成。
我们可以用代码来解释一下这两点说的究竟是啥意思。
// Promise.resolve方法是将传入参数转换为Promise对象var p = Promise.resolve(2);// 注册then(...)里的处理方法var p2 = p.then(function (v) {console.log(v); // 2// 将 2 * 2 的值填充给p2return v * 2;});// 注册p2的then(...)里的处理方法p2.then(function (v) {console.log(v); // 4});
可以看到,p在调用then(…)里注册函数后将返回的值装进一个全新的Promise对象p2中,并在p2的then(…)注册函数调用时获取到了 v * 2 的值。当然p2也会创建一个全新的Promise对象(没有return任何值所以这个Promise里里包装的是一个undefined),全新的Promise对象调用then(…)注册的函数后一样会返回新的Promise对象,周而复始。
当然要是像这样一个个声明对象去写太麻烦了,我们可以将它给串联起来
var p = Promise.resolve(2);p.then(function (v) {console.log(v); // 2// 将 2 * 2 的值填充给p2return v * 2;}).then(function (v) {console.log(v); // 4});
这样看起来是不是非常的清楚,其Promise对象在完成决议后一步一个then(…)注册函数来执行所要进行的操作。
当然这里返回的 v * 2 是同步代码会直接进行一连串的操作,其在返回值也可以是Promise对象。
var p = Promise.resolve(2);p.then(function (v) {console.log(v); // 2// 将 2 * 2 的值填充给p2return new Promise(function(resolve) {setTimeout(function() {resolve(v * 2);}, 1000)});}).then(function (v) {console.log(v); // 4});
运行这段代码会发现在第一个注册函数里返回的Promise对象在一秒后触发下一个注册函数且值也为4。
这种方式实在是强大的不可思议!现在我们可以构建一个序列:不管需要多少个异步步骤,每一步都能根据需要等待或直接运行下一步。当then(…)中完成处理函数没有定义时候,会有个默认的函数顶上去将上一层的值继续往下传。同理当拒绝处理函数没有定义时候,也会有个默认的函数顶上去将错误继续往下传。
所以我们来简单总结一下使链式流程控制可选的Promise固有特性:
- 每次调用then(…)的时候,其都会创建返回一个全新的Promise对象。
- 在完成或拒绝处理函数内,如果返回一个值或一个错误,则新返回的Promise就相应的决议。
- 如果完成或拒绝处理函数内返回一个Promise,它将会被展开,不管其决议值是什么,都会称为当前then(…)函数返回的Promise的决议值。
相对于之前所讲的回调地狱来说,这种链式流的代码结构已经是一种巨大的进步。虽然其任含有重复的代码(如then,function等),但相比于回调来说,其方式更符合我们日常开发中编写编码的顺序习惯。
错误处理
一说到错误处理,大家肯定第一时间想到的是 try {} catch(e) {} 语句。遗憾的是在异步编程中,它们是无法使用的
try {setTimeout(function() {null.id; // TypeError}, 1000);} catch(e) {// 永远都不会运行}
但”error-first”回调模式中,其保留了第一个error参数来接收出错信号。
function foo(ef) {setTimeout(function() {try {null.id; // TypeErroref(null, 1);} catch(e) {// 永远都不会运行ef(error);}}, 1000);}foo(function(error, value) {if (error) {console.log(error);} else {console.log(value);}});
这样方式可以使得错误处理支持异步,但无法很好的组合使用,还是不可避免的造成回调地狱问题。
Promise是否能解决这个异步中错误处理的问题呢?答案是肯定的!
var p = Promise.resolve(2);p.then(function (v) {console.log(null.id);}).catch(function (){});
其实可以把catch(…)看出是.then(null, rejection)的别名,其基本的特性和之前链式流里所讲的特性一致。
常用API
1.Promise.all([…])
Promise.all([…])参数是一个数组,通常由Promise对象所组成。如果数组中含有其他值,都会被规范化为这个值所构建的Promise对象,以确保要等待的是一个真正的Promise。
其方法会返回一个全新的主Promise对象,当且仅当所有传入的Promise对象完成后才会完成。如果其中一个被拒绝,则主Promise对象也会立马被拒绝并丢弃来自其他所有Promise对象的全部结果。
所以切记为每个关联一个拒绝/错误处理函数,特别是Promise.all([…])所返回的那个。
var p1 = Promise.resolve(22);var p2 = Promise.resolve(33);Promise.all([p1,p2]).then(function (v) {console.log(v); // [22, 33]})
2.Promise.race([…])
Promise.race([…])参数也是一个数组,但不同的是Promise.all([…])是要所有Promise对象都完成处理而相反的Promise.race([…])只需要其中一个先完成即可,这种模式传统上称为门闩,在Promise中称为 竞态。
var p1 = Promise.resolve(22);var p2 = Promise.resolve(33);Promise.race([p1,p2]).then(function (v) {console.log(v); // 22})
3.Promise.any([…])
上述情况的变种,会忽略拒绝值,所以只需要其中一个完成即可。
缺陷
1.错误顺序处理
在Promise的设计中会有一个很容易被人忽略掉的缺陷 - Promise中的错误在开发中会无意的被默认忽略掉。
假如构建了一条完全没有拒绝处理函数的Promise链,则要是抛出错误,这个错误就会在链中一直传播下去直到被查看。虽说有catch(…)方法,但其实它本质还是个then(…)函数,到最后还是会返回一个没人处理的Promise对象。所以总是有可能在代码中存在并未处理过错误信息的Promise。
其次,在这条链上,其中一个拒绝处理函数已经处理过这个错误了。但链上其他的错误处理函数并不会收到任何的通知,甚至传到这里时候你连这个错误被怎么处理过都不知道。(毕竟Promise链本质又不是一个完整的实体)
这基本等同于try…catch存在的局限。很遗憾的是好像目前并由为Promise链序列的中间步骤保留含有历史记录的引用,所以还无法关联各个拒绝处理函数。
2.单一值
根据Promise定义,其只能有一个完成值或者一个拒绝理由。如果只是简单的应用场景,这可能没有什么问题。但要是应用场景复杂这就有局限性了。
一般的建议是将其构建一个值的封装(对象或者是数组)来保持多个信息值。这个方法是可以起到作用,但在多个参数计算下每一步都进行封装显得过于笨重。
function getY(x){return new Promise(function (resolve,reject){resolve(3*x-1);})}function foo(a,b){var x= a * b;return [Promise.resolve(x),getY(x)];}Promise.all(foo(1, 2)).then(function(msgs){var x=msgs[0];var y=msgs[1];console.log(x,y);// 2 5});
到最后其参数值还是只有一个,只不过它是数组罢了。
3.单决议
Promise最本质的一个特性就是:Promise只能被决议一次且决议后无法更改。
这是它最大特点,在某些场景下也算很好的优点,但也体现了Promise的局限性:当某些场景下,需要多次继续触发时候,由于Promise决议后会忽略之后做的决议导致方法无响应。
var p =new Promise(function(resolve,reject){// 页面上的按钮的DOM元素var btn=document.getElementById("btn");btn.addEventListener("click",()=>{resolve("has clicked");})});pro.then(function resolved(){console.log("has clicked");})
比如在按钮点击场景下,但点击一次后,后续再点击就不会调用then(…)里的注册函数。
当然正常开发下也不会这样用,这里只是举个简单好理解的方式让大家知道其Promise的单决议性的局限。
4.惯性
在《你不知道的JavaScript》(中卷)的第二部分第一章3.8.4节里提到的(当然这个JavaScript学习笔记很多都是基于这本书,所以有兴趣的小伙伴强烈推荐看看)。
其大致意思是:在原有的包含大量回调函数的代码去引入Promise是件非常困难的事。因为ES6中也并有没有提供辅助创建Promise工厂的API。所以这些事情基本都要你自己去亲手完成。
当就个人看法,无论改造什么代码样式的老工程(重点是老!),这种代码上的”惯性”都是很重的甚至是致命的(所以基本上在开发大型工程中,老手都会提醒新手 — 不要动旧代码!)。所以个人建议是:在旧的工程中要使用Promise可以但别引入到旧的工具类方法中,可以创建新的Promise工厂(或工具)为后续新的功能开发提供便利。
5.无法取消
当创建一个Promise并注册了完成/拒绝处理函数,一旦出现某种情况使得这个任务悬而未决的话,你是无法从外部去停止它。
当然有也一些库会提供类似的工具,甚至很多开发者提议给Promise添加原生方法来终止这种情况的发生,但这样做是很危险的!因为Promise其最重要也是最根本的特性就是:外部不变性,它保证了Promise这个对象是一个完全可信任的存在。
有一种入侵式的定义自己的决回调
var isOK=true;var p=foo(42);//返回一个promisePromise.race([p,timeoutPromise(3000).catch(function(err){isOK = false;thorw err;})]).then(doSomething,handleError);p.then(function(){if(isOK){//...只在没有超时情况下才会发生}});
当然这种代码还是很丑陋的,虽说可以工作,但和实际想要的效果还是相去甚远,尽量别这样这么写。
如果对其真的有需求,建议上网找找其他dalao的copy过来改改,但个人还是觉得取消Promise这种操作有点违背了使用Promise的初心,尽量在代码功能设计上去规避这种无法取消的错误。
6.性能
说到缺陷肯定避不开说到性能问题。
说了这么多,很明显Promise在程序中的”动作”比一般回调函数的动作多的多,自然以为这Promise肯定比传统回调稍慢一些。
但这就好比做生意,你花费了一点的性能(可能相对于现在的硬件条件下这点性能损耗根本不值得一提)换取了大量内建的可信任性,以及优越的可组合性。避免了传统回调模式的回调地狱问题,提高了对开发者的友好性也使得代码的维护性挺高了非常多。这么算下来,Promise的使用是一笔非常划得来的买卖,难道不是吗?
四 Generator
回调模式的异步控制流程除了在Promise中所解决的“可信任性/可组合性”的缺陷,其还有一个很关键的缺陷:其基于回调模式的异步不符合我们大脑对任务步骤的规划方式。
所以我们需要一种可以将异步流程变成顺序、看似同步的流程控制表达风格。而将这种风格变成可能的就是在ES6中的 生成器(generator)。
首先我们先看一段传统的JavaScript代码
var a = 1;function foo() {a++;bar();console.log("a",a);}function bar() {a++;}foo(); // a 3
很明显结果会等于3,因为我们很明确bar会在a++和console.log(a)之间运行,如果bar不在的话明显结果会变成2。
现在天马行空一下:有没有啥办法让foo方法在运行到bar()位置时候挺住?在抢占式多线程语言中(比如耳熟能详的Java),这种情况是可以发生的,bar()可以在两个语句间打断并运行。但我们都知道JavaScript并不是抢占式的,更不是多线程(开局就说了JavaScript是单线程语言,应该没有人忘了吧),然而这种运行情况可以在JavaScript中实现吗?
答案是肯定的!有请我们的ES6代码中提示暂停点的语法 - yield。
var a = 1;// *代表这个声明的是一个生成器并不是普通函数。// 这里*加法没啥大的讲究,function*foo也行,function* foo也行。function *foo() {a++;yield; // stop!!console.log("a", a);}function bar() {a++;}// 构造一个迭代器it来控制这个生成器var it = foo();it.next();a; // 2bar();a; // 3it.next(); // a 3
神奇吧!我用JavaScript语言完成了只有在抢占式多线程的语言才能完成的代码暂停功能,且不仅在yield处暂停还能使得代码继续往下执行。
由此可见,生成器就是一类特殊的函数,它实现了一次或多次的代码暂停/启动。尽管很多人看到这还不知道这个生成器到底有啥牛逼的地方,接下来我们就慢慢来分析它的牛逼之处。
生成器函数
前面例子中加上符号的函数被称为* 生成器函数。
生成器函数是一个特殊的函数,具有我们前面演示的新特性,但它仍然是一个函数,所以它也拥有函数的基本特性,比如可以接收参数,也可以返回值
function *foo(a, b) {return a *b;}var it = foo(2,3);var result = it.next();result.value; // 6
我们可以向函数foo()里传入参数2和3,但其并没有像正常函数一样直接返回结果6,而是调用了next()后才返回一个结果对象,其对象的value值为计算的返回结果。整个过程更像是该函数调用后创建了一个迭代器对象,其对象在调用了next()后使得*foo()生成器继续运行,直到代码到下一个yield或生成器函数结束为止。
function *foo(a, b) {yield;return a *b;}var it = foo(2,3);it.next(); // {value: undefined, done: false}it.next(); // {value: 6, done: true}
这个迭代器对象在调用next()方法后会返回一个包含value和done属性的对象,可以理解为每次运行到yield关键字时候会暂停并使得返回一个值,这点类似于return一样。
生成器函数除了入参和返回值的基本功能外,还提供了内建消息输入输出能力,其通过yield和next()来实现
function *foo(a) {var b = a * (yield);return b;}var it = foo(2);it.next();it.next(3); // {value: 6, done: true}
这里在第二次next()中传入参数被带到了第一次暂停处的yield并用参数将其替换带入计算,所以获得的结果就是2 * 3 的结果值 6 .是不是很神奇!但要注意的是,next()的调用和yield是不匹配的,其第一个yield的值想带入必须是在第二次调用next()时候传入。当然这点也很好理解,生成器函数运行后其第一个next()是启动整个生成器函数的运行,所以必然会造成这种位置上的不匹配。<br />其次next()只会接收第一个传入的参数,后面的参数都会被遗忘。
function *foo() {console.log(yield);};var it = foo();it.next();it.next(3, 4);// 3// {value: undefined, done: true}
这是向生成器函数输入信息能力,那输出的能力呢?看下面例子
function *foo() {console.log(yield 2 * 3);};var it = foo();it.next(); // {value: 6, done: false}
但next()调用后,其函数运行到第一个yield关键字并把yield后面的 2 * 3 结果给返回了。是不是很神奇!这样yield和next()就构建了一个双向信息传递系统!这个特性非常的重要,在后续我们会讲解该机制如何与异步流程控制联系到一起的。
模拟多线程竞态环境
开篇讲过,在正常的JavaScript开发中是无法像类型Java这种多线程语言去做竞态模拟的,但拥有了生成器函数这种实现得到了可能!
var a = 1;var b = 1;function foo() {a++;b = b * a;}function bar() {b++;a = a * b;}
其上述正常的JavaScript代码,其实无非就两种结果:foo先行(结果a为6,b为3)或bar先行(结果a为3,b为6)。但假如加入了生成器函数呢?那结果就会变的多种多样了。
var a = 1;var b = 1;function *foo() {a++;yield;b = b * a;}function *bar() {b++;yield;a = a * b;}var one = foo();var two = bar();one.next(); // a: 2two.next(); // b: 2one.next(); // b: 4two.next(); // a: 8
按上述排列组合运行后,居然得出了a为8,b为4的完全不同于普通JavaScript代码逻辑的结果,这是不是和我们所说的多线程竞态环境下的结果很像呢?(有兴趣可以多试试不同的组合顺序排列结果也是多种多样)。<br />当然,虽然这种方式很有趣但实际开发中并不会这样用。这种用法造成了开发者阅读理解上很大的障碍,但对于理解多个生成器如何在共享的作用域上并发运行有着指导意义,这个功能在其他地方也很有用武之地。
生产者与迭代器
假定你需要产生一些列的值,每个值与它前面一个值有某种特定关系。要实现这种功能需要有一个带有状态的生产者能够记住其每次生产的最后一个值。我们可以在JavaScript中使用函数闭包来完成。
var getMeSomething() = (function (){var nextValue;return function (){if (nextValue === undefined) {nextValue = 1;} else {nextValue = nextValue * 2;}return nextValue}})();getMeSomething(); // 1getMeSomething(); // 2getMeSomething(); // 4getMeSomething(); // 8
实际上这个任务是一种非常通用的设计模式,通常是通过迭代器来解决。迭代器是一个定义良好的接口,用于从一个生产者一步步得到一些列的值。在JavaScript迭代器的接口和大都数语言类似,每次想要从生产者获取下一个值的时候就调用next();
var somethingIterator = (function (){var nextValue;return {// for...of循环所需要的[Symbol.iterator]: function() { return this; },// 这是个标准的迭代器接口方法next: function (){if (nextValue === undefined) {nextValue = 1;} else {nextValue = nextValue * 2;}return { done: false, value: nextValue };},}})();somethingIterator.next().value; // 1somethingIterator.next().value; // 2somethingIterator.next().value; // 4somethingIterator.next().value; // 8
上述代码定义了:next()返回一个对象,其对象有两个属性:一个是boolean值done,用于标识迭代器完成的状态;另一个是value,用于存放迭代值。这样是不是很像生成器函数next()调用返回的值啊。<br />而[Symbol.iterator]是用于ES6的新增循环:for...of使用的。这里的[...]语法被称为计算属性名,常用的语法这里不多做赘述。Symbol.iterator是ES6预定义的一个特殊值,其用于在for...of循环中识别。有兴趣的可以去理解一下,这里就不做讲述了。<br />在使用for...of循环时候每次迭代都会自动调用next(),它不会向next()中传入任何的值,并且会在接收到done:true时候停止循环。这样上面的迭代循环可以简写成下面的方式
for(var v of somethingIterator) {console.log(v);// 不要死循环了if (v >= 8) break;}// 1 2 4 8
初步了解了迭代器后,我们把目光放回到生成器上。可以把生成器看作一个值的生产者,我们通过迭代器接口next()调用来一次获取一个值。所以我们换句话说 —— 当你执行了一个生成器后,就得到了一个迭代器。
// 用生成器来实现somethingIteratorfunction *somethingIterator() {var nextValue;while(true) {if (nextValue === undefined) {nextValue = 1;} else {nextValue = nextValue * 2;}yield nextValue;}}
这样看起来是不是更简单明确的多了?
这段代码不仅更加简洁,且我们也不需要二外构造自己的迭代器接口(事实上这样也更加的合理,因为它能更清晰的表带了代码意图)。现在我们使用for…of来遍历它,可以发现其工作原理以及显示结果是一样的。
for(var v of somethingIterator()) {console.log(v);// 不要死循环了if (v >= 8) break;}// 1 2 4 8
那如何停止生成器呢?主要有以下两种方式
function *somethingIterator() {var nextValue;while(true) {if (nextValue === undefined) {nextValue = 1;} else {nextValue = nextValue * 2;}yield nextValue;}}// 首先就是在for...of中使用 break,未捕获的异常抛出,都会向生成器发送终止的信号var it = somethingIterator();for(var v of it) {console.log(v);// 不要死循环了if (v >= 8) break;}it.next(); // // {value: undefined, done: true} 这里done变为true表示这个生成器函数已经执行完了// 这种情况下,如果生成器函数中有finally也会触发finally函数,这个能用来清理数据的场景下使用function *finallyTest() {var nextValue;try {while(true) {if (nextValue === undefined) {nextValue = 1;} else {nextValue = nextValue * 2;}yield nextValue;}} finally {console.log("clean up!")}}for(var v of it) {console.log(v);// 不要死循环了if (v >= 8) break;}// 1 2 4 6 clean up!// 其次就是使用生成器函数return,当然这样会触发生成器函数里的finallyvar itReturn = finallyTest();for(var v of itReturn) {console.log(v);// 不要死循环了if (v >= 8) itReturn.return();}// 1 2 4 8 clean up!
异步迭代生成器
七七八八写了这么多,就是为了让大家对生成器有了基础的认识。但这些又和我们要说的异步编码有什么关系呢?来,让我们重新回想一下之前的回调函数
function foo(bar) {ajax("http://some.url", bar);}// 这里使用"error-first"模式,小伙伴们应该还没忘吧foo(function (error, data){if (error) {console.log(error);} else {console.log(data);}})
现在,我们可以将它改造成生成器函数的模式
function foo() {ajax("http://some.url", function (error, data){if (error) {it.throw(error);} else {it.next(data);}});};function *main() {try {var data = yield foo();} catch(e) {console.log(e)}}var it = main();// 启动!it.next();
这样看起来好像代码更加的长了也更加复杂,但不要光看外表。实际上这样的风格更加适合我们日常的编码逻辑。我们思考一下,如果使用普通方式直接调用foo会得到什么呢?
var data = foo();data; // undefined
显而易见是undefined。生成器函数中也有类似 var data = yield foo(); 的语句,只是多了个yield。实际转换完这句语句做的是 yield undefined,但这并不重要。重要的是运行到foo函数后,其函数发出了一个异步的请求后,这边的代码就暂停了。<br />而当ajax请求返回成功后,代码调用了next()函数并将返回的data数据传入到yield暂提出赋值给了data,且生成器有开始继续运行下面的代码。<br />这非常的酷吧!!!<br />这样我们在生产器函数内写的看似同步的代码,且可以真实的运行foo这样完全异步的代码。这是飞一样的进步!这样的编码方式完全符合我们正常大脑的思考模式,这是一个近乎完美的方案!从本质上而言,我们把异步作为实现细节抽象出去,使得我们可以以同步顺序的形式来追踪流程控制。<br />这样甚至可以在异步流程中使用了try...catch来处理异常
function foo() {ajax("http://some.url", function (error, data){if (error) {// 这里就是在生成器迭代器中抛出的异常,结合上述那段同步代码,完成可以用try...catch来捕获it.throw(error);} else {it.next(data);}});};
五 强强联合 - Promise + Generator
现在我们拥有了两个非常重要的工具
- Promise(解决了异步编程中的可信任性问题)
- Generator(解决了异步编程中的代码顺序性)
那现在我们只要把这两个工具合并一起就可以解决之前回调函数中所出现的问题了,我们来实现一下
function foo() {// 假定request是基于Promise所实现的ajax库,返回值为Promise对象return request("http://some.url");};// 其main函数完全不需要动function *main() {try {var data = yield foo();} catch(e) {console.log(e)}}var it = main();var p = it.next().value;p.then(function success() { console.log("success"); },function fail() { console.log("fail"); });
实际实现起来非常的简单。当然这个方法里只有一个需要支持的Promise对象,那如果能实现Promise驱动的生成器,就可以不管其内部有多少这种步骤呢?当然我们肯定在开发中不希望每个生成器手工编写不同的Promise链,如果有一种方法可以实现重复(即循环)迭代控制,每次会生成一个Promise等决议后再继续,那该多好。
网上有很多大佬的例子来实现这种构想,下面是摘抄的其中一个run方法:
function run(gen) {var args = [].slice.call(arguments, 1), it;// 在当前上下文中初始化生成器it = gen.apply( this, args );// 返回一个promise用于生成器完成return Promise.resolve().then( function handleNext(value){// 对下一个yield出的值运行var next = it.next( value );return (function handleResult(next){// 生成器运行完毕了吗?if (next.done) {return next.value;} else { // 否则继续运行return Promise.resolve( next.value ).then(// 成功就恢复异步循环,把决议的值发回生成器handleNext,// 如果value是被拒绝的 promise,就把错误传回生成器进行出错处理function handleErr(err) {return Promise.resolve(it.throw( err )).then( handleResult );});}})(next);});}// 只需要把main放入就行run(main);
这种运行run()的方式它会自动异步运行你传给它的生成器,直到结束。
并发能力
当一个方法要多次请求时候,结合上述所说的方式,可能大家会这么去实现。
function *foo() {// 假定request是基于Promise所实现的ajax库,返回值为Promise对象var p1 = yield request("http://some.url1");var p2 = yield request("http://some.url2");var p3 = yield request("http://some.url3");};run(foo);
当其实这种方式并不是最优解 - 因为这里p1,p2,p3并不是同时发出的。根据yield语法,运行到p1是会暂停直到这个ajax请求完成才会请求p2,两个请求是完全独立分开运行的。我们开发者最希望它们是一起发送请求(这样能大大缩短总的请求时间),所以这时候基于Promise以时间无关的方式管理状态能力得到了体些。
function *foo() {// 假定request是基于Promise所实现的ajax库,返回值为Promise对象var p1 = request("http://some.url1");var p2 = request("http://some.url2");var p3 = request("http://some.url3");// 等待所以决议完var r1 = yield p1;var r2 = yield p2;var r3 = yield p3;};
这种方式使得p1,p2,p3都同时发送请求并后续一起等待决议。
function *foo() {// 假定request是基于Promise所实现的ajax库,返回值为Promise对象var p1 = request("http://some.url1");var p2 = request("http://some.url2");var p3 = request("http://some.url3");// 也可以灵活使用Promise所提供的APIvar result = yield Promise.all([p1,p2,p3]);var r1 = result[0];var r2 = result[1];var r3 = result[2];};
也可以结合Promise所提供的API能力在不同的场景下灵活使用。<br />上面代码我们还可以继续优化,将Promise给抽象出去:
function mutilRequest(url1,url2,url3) {return Promise.all([request(url1),request(url2),request(url3),]);}function *foo() {// 将Promise的操作从生成器里给抽象出去,隐藏了Promise。var result = yield mutilRequest("http://some.url1","http://some.url2","http://some.url3");var r1 = result[0];var r2 = result[1];var r3 = result[2];};
这种抽象是非常有意义的。它将如创建Promise等细节从生成器的代码中从抽离出变成一个调用的函数,将无关于流程控制的代码分离走,让你使用生成器时候更能体现其流程控制的优越性,也让代码更容易被人读懂并且也更容易后期维护。
六 Generator委托
上面例子中我们展示了在生成器内部调用常规函数,但大家有没有想过一个场景:如果我要在生成器内部调用生成器函数呢?从函数的角度来说,肯定是可以的语法上是通过的。但这里就有个问题:生成器函数返回的是一个类迭代器的对象,那这个对象会自动执行吗?明显是不可能的,那我们怎么让它自动执行呢?
其实这个问题我们现有的知识也可以完全解决,就是使用之前定义的辅助函数run()
function *bar() {// 这里直接应用上面定义的 就不多写了var f = yield run(foo);}run(bar);
其实有一种更好的方式来实现这种功能 - 使用 **yield委托 **。
function *foo() {console.log("foo 启动");yield 3;yield 4;console.log("foo 结束");}function *bar() {yield 1;yield 2;yield *foo();yield 5;}var it = bar();it.next().value; // 1it.next().value; // 2it.next().value; // foo 启动 3it.next().value; // 4it.next().value; // foo 结束 5
这里当运行到yield *foo();时候,it将控制权委托给了foo()的迭代器,当foo()运行完后,又把控制权还给了bar()的迭代器。这样让这里的代码运行起来和普通函数没啥区别,让其在生成器中调用生成器方法时候和普通函数调用所对策。说人话就是:让生成器中使用生成器不用那么麻烦,摆脱了还需要第三方库的依赖,且代码看上去就和普通函数调用一样。
不单是这一点优点,该委托语法也能进行:消息委托,异步委托,异常委托以及递归委托。委托的功能非常的强大有兴趣的小伙伴可以自行去钻研学习,这边就不做多余的赘述了。
七 async/await
async/await估计大家都耳熟能详,其正式纳入标准的是在ES7。很多人觉得async/await是异步编程终极解决方案,所以越来越多人开始研究它。本文章就对其大致讲解一下
首先说一下,async/await不是JavaScript独有的,很多编程语言都有这种语法,简单描述一下几个特性:
- async/await是一种编写异步代码的新方式。
- async/await其是建立在Promise的基础之上。
- async/await和promise一样,也是非阻塞式。
- async/await用起来让异步代码看起来更加的像同步代码。(这也是威力所在吖!)
我们可以写段代码试试:
async function foo() {return 1;}var result = foo();console.log(result); // Promise {<fulfilled>: 1}
从上面代码很明显看出,其async函数最后返回值就是一个Promise(证实了第二点)。其async函数返回的值会通过类似Promise.resolve(...)的函数行为转换成Promise对象返回。<br />await又是做社么用的呢?await字面意思是等待,其等待一个可以用于async的返回值 - 也可以说 await是在等待async函数啦。其后面不仅仅可以等Promise对象,也可以等任何普通函数甚至直接量。
function getSomething() {return "something";}async function testAsync() {return Promise.resolve("hello async");}async function test() {const v1 = await getSomething();const v2 = await testAsync();console.log(v1, v2);}test(); // something hello async
很多人以为await是会一直等待后面的表达式执行完返回值之后继续执行后面代码。实际上await是将线程让出来的标志。<br />当在async函数中碰到await后,会立马执行后面的函数一边。之后跳出整个async函数,将线程让给js调用栈中后续的代码。等到本轮的事件循环tick结束后,又会跳回到该async函数中等待await。
async function async1() {console.log('async1 start');await 1;console.log('async1 end');}console.log('script start');async1();console.log('script end');// script start// async1 start// script end// async1 end
从上面运行代码的显示顺序就可以看出,其async/await函数的代码设置。<br />如果要是不好理解,可以换种思路来:将async/await函数理解为一种基于Promise的封装,其在async函数中,await之前的代码都算在Promise的 **决议回调**(Promise的决议回调会立刻执行)中,而await后面的代码可以理解为都是Promise.then(...)里注册的处理函数。这样一说是不是就很好理解了。<br />这种语法让异步操作看起来就更像是同步代码了!既然都说像是同步代码,它也支持try...catch的使用
async function async1() {try {await 1;} catch(e) {console.log(e);}}
