上一章讲到,用回调来实现异步的两大问题:代码缺乏顺序性;控制权交出,缺乏可信任性。
先说可信任性:传递回调的代码,是把控制权交给第三方,因而难以信任。
假如让第三方告诉我们其任务何时结束,然后由我们自己决定下一步操作呢?
这种范式就叫做Promise。
实际上,绝大多数JavaScript/DOM平台新增的异步API都是基于Promise的。
1.什么是Promise
在展示 Promise 代码之前,先从概念上完整地解释 Promise 到底是什么。
1.1 未来值
可以不关心一个值现在还是将来会得到,用一个占位符来表示它,这个占位符使得这个值不再依赖时间,我们可以在还没拿到值(未来才能拿到,当然也可能拿失败)的时候就编写整个事情的代码。
①现在值和将来值
不管一个值是现在就能拿到,还是将来才能拿到,我们可以用同样的方式去对待它,不管它是不是异步,都可以不关心,都不影响代码逻辑和写法。
②Promise值
作者举了个Promise的使用例子(用Promise来写“计算未来值x和未来值y的和”),并讲解了一下例子中语句的含义和特性。
从外部看,由于Promise封装了依赖于时间的状态——等待底层值的完成或拒绝,所以Promise本身是与时间无关的。因此,Promise可以按照可预测的方式组成(组合),而不用关心时序或底层的结果。
另外,一旦 Promise 决议,它就永远保持在这个状态,可以根据需求多次查看。
Promise决议后就是外部不可变的值(immutable value),我们可以安全地把这个值传递给第三方,并确信它不会被有意无意地修改。
Promise是一种封装和组合未来值的易于复用的机制。
1.2 完成事件
Promise除了可以看作是表示“未来值”的东西,也可以用来表示一种在异步任务中的多个步骤的流程控制机制,它控制多个异步步骤的时序。
假如有个异步步骤foo,我们不关心其细节,只想在该步骤结束以后得到通知,并做一些处理。
典型的写法是给foo传入回调方法,通知就是foo调用的回调。
而使用Promise的话,Promise侦听foo的事件(成功事件和失败事件),侦听到事件以后,就算得到了通知,然后再根据通知做一些处理。
回调是对控制关系的反转,Promise这种范式则是控制关系的反转的反转。
这样做的好处是,foo不用关心要调哪些回调,只用把结果告诉Promise,关心任务结果的人自然会去监听Promise的。这样就实现了关注点的分离。
从本质上说,Promise对象就是分离的关注点之间一个中立的第三方协商机制。
2.具有then方法的鸭子类型
如何确定一个值是Promise?
instanceof Promise是不够用的,因为Promise值可能是从其它iframe拿到的,也有可能是其它库或框架自己实现的Promise,而不是用原生ES6实现的(在不支持ES6的浏览器中就可能会有)。这些情况都没法用instanceof Promise来判断。
我们根据一个值具有哪些属性来对这个值的类型做出一些假定。这种类型检查一般称为鸭子类型(duck typing)——“如果它看起来像只鸭子,叫起来像只鸭子,那它一定就是只鸭子。”
所以对一个值是否是Promise的检测,就会是类似这样:判读它 是一个对象 且 有then属性 且 then属性是一个function
但这样的判断是有风险的,比如很多类库提供了具有then方法的对象/委托,就会被鸭子类型判断为Promise。这些类库要么不得不把自己的方法重新命名了以避免冲突,要么被打为“与含有Promise的代码不兼容”。
如果有其它代码无意或恶意地给Object.prototype、Array.prototype或者其它原生原型添加了then方法,也会造成灾难。
不过鸭子类型有时候还是有用的,只是要小心鸭子类型把不是Promise的值误判为Promise的情况。
3.Promise信任问题
前面说到,Promise在异步代码中做的事情,一是作为“未来值”;二是反控制反转,接收“完成事件”。
但是我们还没讨论,Promise怎么应对上一章讲到的“回调模式中存在的信任问题”。
先回顾下回调中要面对的信任问题:回调调太早、回调调太晚或者根本不回调、回调次数过多或过少、没传递该传入的环境和参数、吞掉应该报出的错误和异常。
3.1 调用过早
如果回调函数可能被同步地调用,而使用者却没预料到,就可能出现竞态条件,搞出bug。
但Promise的定义,规定了即使是已经决议的Promise,也无法被同步观察到。也就是说,对一个Promise调用then(..)的时候,即使这个Promise已经决议,提供给then(..)的回调也总会被异步调用。
3.2 调用过晚
Promise作为第三方中立的协商机制,会在 其创建对象 调用resolve或reject的时候,调度Promise对象上注册的回调函数。所以这个层面上讲,不用担心异步操作结束后对方漏了哪个回调,因为回调这件事交给Promise去做了,我们可以信任它。
另外,也不用担心同一个Promise对象上注册的多个回调函数之间会互相延误或阻止。一个Promise决议后,这个Promise上注册的回调都会在下一个异步时机点上依次被立即调用,中间哪个回调要执行异步操作,或者要抛异常,都不会延误后面的回调被调用。
3.3 回调未调用
如果你对一个Promise注册了一个完成回调和一个拒绝回调,那么Promise在决议时总是会调用其中的一个。
那如果Promise本身永远不被决议呢?
可以利用Promise提供的race,Promise.race([p1, p2])返回一个新的Promise对象,p1和p2任意一个决议,新Promise对象就决议。我们可以设置p2的内容为:设一个定时器,定时器到期后调用reject。这样,p1如果一直不决议,Promise.race([p1, p2])也会在p2的定时器到期后决议。
3.4 调用次数过多或过少
“过少”已经在前面讲过可利用Promise.race防范。
“过多”,由于Promise的机制,Promise对象只能决议一次,不可以既调用resolve又调用reject,也不能多次调用,Promise对象只会接受第一次决议,后续的调用都会被忽略。
3.5 未能传递参数/环境值
Promise的机制:调用resolve或reject时传入的参数(只接收第一个参数,多的会被忽略)就会成为决议值,不传的话,决议值就是undefined。
3.6 吞掉错误或异常
我们已经知道,直接调用reject可以使Promise决议到“拒绝”态。
但如果Promise在创建或在获得结果的过程中出现了JavaScript异常,这个异常就会被捕获,并使Promise对象决议到“拒绝”。
例:
let test = new Promise((resolve, reject) => {let a = b}) // b是未声明的变量
test // Promise {<rejected>: ReferenceError: b is not defined
Promise的这个细节,避免了“任务出错引起同步响应,不出错则会是异步的”的风险,使我们不会遇到料想不到的竞态条件。
Promise甚至把JavaScript异常也变成了异步行为。
凡是Promise的决议都会异步执行。
3.7 是可信任的Promise吗
Promise并没有完全摆脱回调,只是我们之前是直接把回调丢给异步任务,现在则是把回调丢给 根据异步任务得到的一个东西。
为什么这样比回调更可信?怎么确定返回的东西就是个可信任的Promise呢?
在原生ES6 Promise实现中的解决方案就是Promise.resolve。
Promise.resolve(一个非Promise、非thenable的立即值) → 就会得到一个已经决议到“完成”的Promise对象。
Promise.resolve(一个Promise值) → 就会得到里面那个Promise对象(新的Promise会替代掉原来那个,等它决议才能得到结果。)
Promise.resolve(一个thenable值,它不是真的Promise,但有then方法) → Promise就会试图展开这个thenable,直到最后提取出一个非thenable的最终值。
所谓的展开:
Promise调用这个thenable的then方法,给它传入Promise自己的完成回调和失败回调。
如果它调失败回调,那就决议到“拒绝”
如果它调成功回调,那就决议到“完成”。
如果它调成功回调,并且还传参传了个新的thenable,那就继续调新的thenable的then方法。
假如我们要调一个工具foo(..),但不确定得到的返回值是不是一个可信任的规范的promise,我们可以把它丢给Promise.resolve,Promise.resolve会替你过滤掉thenable值。
从语义上来讲,Promise不会直接把一个thenable当作决议值丢出去,而是把它看作一个“未来值”,等它决议(也就是等它调了传入的成功回调或失败回调),一直到它不会再决议出“未来值”而是决议出“普通值”,这个普通值才被视作决议结果。
比如:foo(42).then(回调)
,要是不确定foo会不会返回盗版Promise,可以这样:Promise.resolve(foo(42)).then(回调)
3.8 建立信任
前面的讨论已经讲解了为什么Promise是可信任的,以及为什么对于构建健壮可维护的应用来说,建立信任很重要。
Promise模式通过可信任的语义把回调的控制反转 反转回来,我们把控制权放在了可信任的系统(Promise)中,这个系统设计的目的就是为了使异步编码更清晰。
4.链式流
Promise并不只是一个单步执行this-then-that操作的机制,还可以把多个Promise连接到一起来表示一系列的异步步骤。
Promise怎么做到串联一系列异步步骤呢?
1.一个Promise的then方法不仅接收成功、失败回调,而且还会返回一个新的Promise,新的Promise表示then接收的回调的完成情况
2.如果then接收的回调返回的不是一个普通值而是一个“未来值”(Promise、thenable),未来值会被等待(展开),等到出结果,这个结果才会被当作异步任务链中当前环节的执行结果,并被传递给这个“环节”的then方法所接收的回调。
3.then链中任务如果失败(reject)或者抛出异常,那么其后续环节都会reject,当然,也可以在出错环节的后面任一环节进行捕捉,捕捉掉错误之后,这一环节就算“正常”结束,进行了捕捉的环节的后续环节不会因为前面没捕捉的错误而eject了。
比起用回调写出来的异步代码,Promise链接异步任务的顺序表达是个很大的进步。
最后作者从语义上分析了一下Promise相关的几个术语:
1.为什么Promise构造器传递给被包裹的任务的两个回调函数,通常被分别称为resolve和reject呢?
答:reject没什么说的,是失败的时候调的。resolve被叫做resolve而不叫做fllfilled,是因为resolve方法可能接收到一个未来值,未来值的展开结果可能是reject的。所以用"resolve"这个名称更恰当。
2.then方法接收的回调呢?叫什么名字合适?
答:因为它俩永远是分别处理任务的“完成”和“失败”,所以叫做fulfilled和rejected就很合适了。
而且在ES6规范里,它们也被叫做onFulfilled和onRejected,实至名归。
5.错误处理
Promise的错误处理对初接触的人来说不是很直观,一个任务决议后,其fulfilled回调执行失败,不是由注册在该任务上的rejected回调去接收错误信息,而是该任务的下一环任务上注册的rejected回调去接收。
虽然这样其实是合理的,但是乍一看,有点令人难以理解,然后一不小心会因为理解有误,写出bug。
有些开发者提出,在实际开发中,可以在then后面加catch,以保证then接收的回调函数如果出错了也能被后面那个catch抓到。
但假如catch接收的错误处理方法自身出错了呢?
这种写法还是不能保证整条链任意环节出的错都能被捕获。
还有些办法可以判断Promise任务链中是否有错误“从始至终未被捕获”。
如:设置定时器,一定时间内拒绝态的Promise没有被注册错误处理函数,就当它是“从始至终未被捕获”,当然,这样肯定有点问题。
还有让浏览器对Promise变量回收时判断它是否处于拒绝态,如果是说明它“从始至终未被处理”,但这样不能兼顾到因为各种原因变量没释放的情况。
接下来作者讲了理论上“更好的Promise”该拥有的特性,“理想中的Promise”在决议到拒绝后,要默认报告未被处理的拒绝(在决议后的下一个异步时间点),除非它被显式调用了defer,或者它已经注册了一个错误处理函数。
(我没懂什么叫“报告”,控制台报错算“报告”吗?如果是的话现在不就已经是了吗,没catch的错误会报到控制台。可能现在我接触到的就是优化过的Promise。)
6.Promise模式
除了Promise链这样的顺序模式,基于Promise构建的异步模式抽象还有很多变体。
Promise.all([...])
在经典的编程术语中,门(gate)是这样一种机制:要等待两个或更多并行 / 并发的任务都完成才能继续。它们的完成顺序并不重要,但是必须都要完成,门才能打开并让流程控制继续。
在Promise API中,这种模式被称为all([...])
Promise.all([...])返回的主promise在且仅在所有成员promise都完成后才会完成。任一成员promise被拒绝,主promise就会被拒绝,并丢弃其它成员promise的决议结果。
Promise.race([...])
多个任务并发运行,只响应“第一个跨过终点线的任务”,这种模式传统上称为门闩,但在Promise中称为竞态。(注意不要混淆这里的“竞态”,和相当于bug的“竞态条件”)
一旦有任何一个成员Promise决议为完成,Promise.race([...])就会完成。
那么从行为上看,那些被丢弃的成员promise会发生什么呢?
假如一个成员任务内部保留着一些资源,然后这个成员任务被主任务忽略了,那么是不是应该有什么api可以用于主动释放这些资源或者取消可能产生的副作用?
(这段话看不懂,首先,“一个Promise内保留了一些资源”是什么意思,指的是Promise里边包裹的任务所用到的资源吗?这些资源会在决议之后释放吧?而且这个时候主动释放资源,是不打算让这个任务走下去了吗?)
然后作者提到一种未来的Promise可以有的api:finally,用于接收在主promise任务决议后进行清理的回调。
all([...])和race([...])的变体
除了原生Promise提供的all和race,还有几种其它的变体。
none([...])
any([...])
first([...])
(其实我看不懂any和first的逻辑有什么区别)
有些Promise抽象库提供了这些支持,你也可以自己实现。
小练习:自己实现一下first()
并发迭代
假如对一堆异步任务要做同样的处理,而且这些处理不是同步操作,就需要写个类似forEach(但forEach是用于同步操作的)的迭代工具方法。
书里写了个实现的示例,不抄录了。
7.Promise API概述
Promise构造器接收一个函数参数,这个函数表示Promise对象的内容,函数会在new Promise语句执行到的时候被立即同步调用。
其它略。
8 Promise局限性
8.1 顺序错误处理
作者说,一个Promise链,如果其中一个环节出了错误并且被错误处理函数捉掉了,那么在链底你就觉察不到链中其实有环节失败。类似try catch,error被catch了以后就不会再往上抛,你就不知道底下其实发生过错误。
(我看不出来为什么这也算缺陷。。不过作者说,这算是一个限制)
8.2 单一值
如果一个Promise需要返回多个值,只能把它做成个对象或者数组,不过也可以思考下是不是里边的逻辑应该拆分了。
8.3 单决议
Promise适用于只决议一次的异步。像事件、数据流这样的模式,不适合直接使用Promise,起码得对Promise再包一层逻辑才能用。
8.4 惯性
讨论了一些把原有的回调风格的代码修改成Promise风格的代码所要做的事,介绍了一些起转换作用的工具函数
8.5 无法取消的Promise
Promise一旦创建就无法取消。如果允许Promise的取消,那么Promise的一个消费者就可以影响其它消费者查看这个Promise,这违背了未来值的可信任性(外部不变性)。
单独的一个Promise并不是一个真正的流程控制机制,这就是为什么Promise取消总是让人感觉很别扭。相比之下,集合在一起的Promise构成的链(可以称之为一个“序列”),就是一个流程控制的表达,将取消定义在这个抽象层次上是合适的。
8.6 Promise性能
Promise与不可信任的裸回调相比会稍慢一些。