javascript基础修炼(7)——Promise,异步,可靠性
开发者的javascript造诣取决于对【动态】和【异步】这两个词的理解水平。
我的github主页:https://github.com/dashnowords
我的新书上架啦,3天即登京东计算机编程语言类排行榜Top1!!!精选30+JavaScript库,从使用方式,设计原则,原理源码,周边知识等等多维度详细讲解,带你玩转前端花花世界,欢迎选购~
一. 别人是开发者,你也是
Promise
技术是【javascript
异步编程】这个话题中非常重要的,它一度让我感到熟悉又陌生,我熟悉其所有的API
并能够在编程中相对熟练地运用,却对其中原理和软件设计思想感到陌生,即便我读了很多源码分析和教程也一度很难理解为什么Promise
这样一个普通的类能够实现异步,也曾尝试着去按照Promise/A+规范来编写Promise
,但很快便陷入了一种更大的混乱之中。直到我接触到一些软件设计思想以及软件工程方面的知识后,从代码之外的角度来理解一些细节的必要性时,那些陌生才开始一点点消失。
如果你觉得有些新东西很那理解,有很大的原因是因为你和设计者所拥有的基础知识储备不是一个水平的,导致你无法理解设计者写出某段代码时所基于的指导思想,当你无法理解某些看起来很复杂的东西时,笔者的建议是先了解它希望解决的问题,这个问题或许是具体的业务逻辑需求,或许是抽象的软件设计层面的,然后尝试自己想办法去解决它,请永远记得别人是开发者,你也是,你要做的是面向需求,而不仅仅是跟着别人走。即时最终你没能开发出这个模块而去学习源码时,你也会发现面对需求而进行的主动思考对此带来的帮助。
二. 关于Promise的一些困惑
Promise
的本质,是一个分布式的状态机。而PromiseAPI
的本质,就是一个发布订阅模型。
-
Promise
解决了什么问题?这是一个最基本的问题,
Promise
是一个有关可靠性和状态管理的编程范式,它通常被认为从代码层面将javascript
中著名的回调地狱改变成扁平化的写法,并为指定的业务逻辑打上状态标记,让开发者可以更容易地控制代码执行的流程。但事实上Promise
的设计初衷并不是为了实现异步,而且很多开发者并没有意识到,回调并不意味着异步!!!(你传入另一个函数的回调函数有可能被异步执行,也有可能被同步执行)。想更好地理解Promise
,就必须把【异步】这个标签从中剥离,而围绕【状态管理】,【可靠性】这些关键词进行展开。 -
Promise
只是一个类,为什么就能够实现异步?Promise
本身的确只是一个普通的类,而且在不依赖ES6
的环境中,开发者甚至可以手动实现这样一个类,在没有研究Promise
的代码之前,笔者一直主观地认为其内部是通过类似于事件监听的机制来实现异步的,否则程序本身怎么会知道发出的http
请求什么时候返回结果。这个问题是在笔者学习完
EventLoop
和Generator
函数的相关知识后才理解的,其实Promise
本身并没有实现异步,javascript
语言中的异步都是通过事件循环的机制(《javascript基础修炼(5)——Event Loop(node.js)》)来实现的,简单地说就是说异步事件的响应是会被事件循环不断去主动检测的,当异步动作满足了再次被执行的条件时(比如http
请求返回了结果,或者在另一个线程开启的大运算量的逻辑执行完毕后返回了消息),就会被加入调用栈来执行,Promise
和Generator
只是配合事件循环来进行状态管理和流程控制,它们本身和事件循环的机制是解耦的。 -
Promise
作为构造函数调用而生成实例时到底发生了什么事情?这里所指的是下面这样的代码:
promise = new Promise(function(resolve, reject){ //.... });
面试中经常会问到有关
Promise
执行次序的问题,很多非常熟悉Promise
用法的读者也并没有意识到,实际上传入的匿名函数是会同步执行的。Promise
所做的事情,是为当前这个不知道何时能完成的动作打上一些状态的标记,并传入两个用于回收控制权的方法作为参数来启动执行这个匿名函数,通过then
方法指定的后续执行逻辑会先缓存起来(这里的描述并不严谨),当这个异步动作完成后调用resolve
或者reject
方法后,再继续执行事先被缓存起来的流程。 -
Promise/A+标准看起来很复杂,该如何去实现?
Promise/A+规范的确很复杂,我也不建议你直接就通过这样的方式来了解
Promise
的实现细节,【规范】意味着严谨性,也表示其中有很多容错的机制,这会极大地妨碍你对Promise
核心逻辑的理解,Promise
代码最大的复杂性,在于它对于链式调用的支持(如果不需要支持链式调用,你会发现自己几乎不需要思考就可以分分钟撸一个Promise
库出来)。笔者的建议是先想办法去解决主要问题,再对照Promise/A+规范去检视自己的代码。 -
Promise为什么要实现链式调用?
链式调用的实现,实现了
Promise
的多步骤流程控制功能,对一个多于两个步骤的流程中,即使没有实现链式调用,Promise
实际上依然可以工作,但当你真的那样做时,你会发现它又变成了一个新的回调地狱。 -
Promise的可靠性是指什么?
Promise
的可靠性指它的状态只能被改变一次,之后就不能再修改,且唯一修改它的方法是调用promise
实例中的内部resolve( )
或reject( )
方法,它们是定义在Promise
内部的,从外部无法访问到,只能通过Promise
内部提供的机制来触发判定方法(new Promise(executor)
生成实例时,当还行到executor时,Promise会将内部的resolve
和reject
方法作为实参传入executor,从而暴露修改自身状态的能力),相比之下,普通对象的属性或者thenable
对象(指拥有then
方法的非Promise实例对象)的属性都是可以被直接修改的,所以promise
的状态和结果被认为是更可靠的。
三. 写给小白的Promise短篇故事
假设有一个异步的动作A,还有一个希望在A完成以后执行的动作B,和一个在B完成以后去执行的动作C,我们来看一下Promise
是如何实现流程控制。
第一回 状态标记
A动作开始之前,我们把它丢进Promise
构造函数,Promise
给了A一个控制器(上面有resolve和reject两个按钮)和一个带有两个抽屉的储物柜(onFulfilledCallbacks和onRejectedCallbacks),接着给A交代:我已经登记好信息了,你去执行吧,等你执行完以后,如果你认为执行成功了,就按一下控制器的resolve按钮,如果认为执行失败了就按一下reject按钮,但是你要小心,这个控制器只能用一次,按完它会自动发送消息,储物柜上有接收器,如果收到resolve
信号,onFulfilledCallbacks这个抽屉就会打开,如果收到reject
信号,onRejectedCallbacks这个抽屉就会打开,之后另一个柜子就会锁死,我每隔一段时间会来查看一下你的状态(注意这里是在事件循环中主动轮询来查看promise
实例是否执行结束的),如果我看到你的储物柜有一个抽屉打开了的话的话,就会把里面的东西拿出来依次执行接下来的事情。在这之前,如果有人想关注你的执行情况的话,我会让它留下两张字条,分别写下不同的抽屉打开的时需要做的事情,因为最终只有一个抽屉可以打开,他必须得写两张字条,除非他只关注某个抽屉的动向,然后使用你这个储物柜的then
方法就可以把字条塞到对应的柜子里,之后等抽屉打开时,我只需要根据字条上的信息打电话给他就行了。A觉得这样是比较稳妥的,于是拿着promise给它的控制器去执行了。
第二回 回调注册
代码继续执行,这时候出现了一个B,B说我得先看看A的执行结果,再决定做什么,执行器说你也别在这干等着了,A在我们这里存放了一个智能储物柜,它回头会把结果远程发送回来,你把你的联系方式写在这两张字条上,然后通过A的储物柜的then
方法放进去吧,联系方式也可以写成不一样的,到时候A返回结果的话,对应的抽屉就会打开,我按照你写的联系方式发消息给你就行了。B想了想也是,于是就写下了两个不同的号码放进了A储物柜对应的抽屉里,接着就回家睡觉去了。
第三回 机制缺陷
代码继续执行,这时候又出现了一个C,C说我想等B返回结果以后再执行,这时候执行器犯难了,B还没出发呢,我也没有给它分配回调储物柜,所以没办法用同样的方式对待C,执行器只能对C说,我们这规定如果没有对应标记的储物柜的话,暂时不提供服务,这样吧,你先把你的联系方式写好交给我,等回头如果B出发的话,我会给它分派储物柜,到时候把你的需求放在对应的抽屉里,等B返回对应结果以后我再通知你,C觉得也行,于是就照做了。但是C走后,执行器就想了,要是后面再来DEF都要跟在不同的人后面去执行,那这些事情我都得先保管着,这也太累了,而且容易搞乱,不能这么搞啊。
第四回 流程管理
上一会讲到在现有机制下缺乏多步骤流程管理的机制,当异步任务A执行且没有返回结果时,后续所有的动作都被暂存在了执行器手里,只能随着时间推移,当标志性事件发生时再逐步去分发事件。为了能够实现多步骤的流程管理,执行器想出了一个方法,为每一个来注册后续业务逻辑的人都提供一个智能储物柜,这样在办理登记时就可以直接将后续的方法分发到对应的抽屉里,常见的问题就解决了。
四.链式调用Promise的影响
如果没有链式调用,第三节中的多步骤的伪代码可能是如下的样子:
//为了聚焦核心逻辑,下面的伪代码省略了onReject的回调
promiseA = new Promise(function(resolve, reject){
//A带着控制器开始执行
A(resolve,reject);
});
promiseA.then(function(resA){
//A执行结束以后,开始判断B到底是否要执行
promiseB = new Promise(function(resolveB, rejectB){
//如果B需要执行,则分配两个储物柜,并派发状态控制器,B带着A返回的数据resA开始执行
B(resA,resolveB,rejectB);
});
promiseB.then(function(resB){
//B执行结束以后,开始判断C到底是否要执行
promiseC = new Promise(function(resolveC, rejectC){
//如果C需要执行,则分配两个储物柜,并派发状态控制器,C带着B返回的数据resB开始执行
C(resB, resolveC, rejectC);
});
//...如果有D的话
})
});
在逻辑流程中仅仅有3个步骤的时候,回调地狱的苗头就已经显露无疑了。Promise
被设计用来解决回调嵌套过深的问题,如果只能按上面的方法来使用的话显然是不能满足需求的。如果可以支持链式调用,那么上面代码的编写方式就变成了:
//为了聚焦核心逻辑,下面的伪代码省略了onReject的回调
promiseA = new Promise(function(resolve, reject){
//A带着控制器开始执行
A(resolve,reject);
});
promiseA.then(function(resA){
//在使用then方法向A的储物柜里存放事件的同时,也生成了自己的储物柜
return new Promise(function(resolveB, rejectB){
B(resA, resolveB, rejectB);
});
}).then(function(resB){
return new Promise(function(resolveC, rejectC){
C(resB, resolveC, rejectC);
});
}).then(function(resC){
//如果有D动作,则继续
})
很明显,当流程步骤增多时,支持链式调用的方法具有更好的扩展性。下一节讲一下Promise
最关键的链式调用环节的实现。
五. Promise如何支持链式调用
基本原理
如果需要then
方法支持链式调用,则Promise.prototype.then
这个原型方法就需要返回一个新的promise
。事实上即使在最初的时间节点上来看,后续注册的任务也符合在未来某个不确定的时间会返回结果的特点,只是多了一些前置条件的限制。返回新的promise
实例是非常容易做到的,但从代码编写的逻辑来理解,这里的promise
到底是什么意思呢?先看一下基本实现的伪代码:
//为简化核心逻辑,此处只处理Promise状态为PENDING的情况
//同时也省略了容错相关的代码
Promise.prototype.then = function(onFulfilled, onRejected){
let that = this;
return new Promise(function(resolve, reject){
//对onFulfilled方法的包装和归类
that.onFulfilledCallbacks.push((value) => {
let x = onFulfilled(value);
someCheckMethod(resolve, x, ...args);
});
//对onRejected方法的包装和归类
that.onRejectedCallbacks.push((reason) => {
let x = onRejected(reason);
someCheckMethod(reject, x, ...args);
});
});
};
可以看到在支持链式调用的机制下,最终被添加至待执行队列中的函数并不是通过then
方法添加进去的函数,而是通过Promise
包装为其增加了状态信息,并且将这个状态改变的控制权交到了onFulfilled
函数中,onFulfilled
函数的返回结果,会作为参数传入后续的判定函数,进而影响在执行resolve
的执行逻辑,这样就将新promise
控制权暴露在了最外层。
所以,then方法中返回的promise实例,标记的就是添加进去的
onFulfilled
和onRejected
方法的执行状态。这里的关键点在于,onFulfilled
函数执行并返回结果后,才会启动对于这个promise的决议。
支线故事
在新的链式调用的支持下,上面的故事流程就发生了变化。当B前来登记事件时,执行器说我们这现在推出了一种委托服务,你想知道那个储物柜的最新动态,就把你的电话写在字条上放在对应的抽屉里,之后当这个抽屉打开后,我们就会把它返回的信息发送到你留在字条上的号码上,我们会给你提供一个智能储物柜(带有this._onFulfillCallbacks
抽屉和this._onRejectedCallbacks
抽屉)和一个控制器,这样别人也可以关注你的动态,但你的控制器暂时不能用,我们将某个消息发送到你留的手机号码上时,才会同步激活你的控制器功能,但它也只能作用一次。
六. resolve(promise)
再来考虑一种特殊的场景,就是当A动作调用resolve(value )
方法来改变状态机的状态时,传入的参数仍然是一个PENDING
状态的promise
,这相当于A说自己已经完成了,但是此时却无法得到执行结果,也就不可能将结果作为参数来启动对应的apromise._onFulfilledCallbacks
队列或者apromise_onRejectedCallbacks
队列,此时只能先等着这个promise
改变状态,然后才能执行对A动作的决议。也就是说A的决议动作要延迟到这个新的promise
被决议以后。用伪代码来表示这种情况的处理策略就是如下的样子:
//内部方法
let that = this;//这里的this指向了promise实例
function resolve(result){
if(result instanceof Promise){
return result.then(resolve, reject);
}
//执行相应的缓存队列里的函数
setTimeout(() => {
if (that.status === PENDING) {
that.status = FULFILLED;
that.value = result;
that.onFulfilledCallbacks.forEach(cb => cb(that.result));
}
});
}
当前promise
实例的决议通过result.then(resolve,reject)被推迟到result返回结果之后,而真正执行时所需要操作的对象和属性,已经通过let that = this与实例进行了绑定 。
很多开发者在这里会觉得非常混乱,很可能是没有意识到每一个promise
实例都会生成内部方法resolve( )
和reject( )
,即时当Promise
类实例化的过程结束后,它们依然会被保持在自己的闭包作用域中,在执行栈中涉及到多个处于PENDING
状态的promise
时,它们的内部方法都是存活的。如果还是觉得抽象,可以利用Chrome的调试工具,将下面的代码逐步执行,并观察右侧调用栈,就可以看到当传入决议函数的是另一个promise
时,外层的决议函数都会以闭包的形式继续存在。
let promise1 = new Promise(function(resolve, reject){
setTimeout(function fn1(){
let subpromise = new Promise(function (resolvesub,rejectsub) {
setTimeout(function fn2() {
resolvesub('value from fn2');
},2000);
});
resolve(subpromise);
},2000);
});
promise1.then(function fn3(res) {
console.log(res);
});
七.Promise/A+规范与造车轮指南
【Promise/A+规范】:https://github.com/promises-aplus/promises-spec
理清了上面各种情况的基本策略后,我们已经具备了构建一个相对完备的Promise
模块的能力。我强烈建议你按照Promise/A+规范来亲自动手实现一下这个模块,你会发现在实现的过程中仍然有大量的代码层面的问题需要解决,但你一定会受益于此。网上有非常多的文章讲述如何根据Promise/A+标准来实现这个库,可是在笔者看来这并不是什么值得炫耀的事情,就好像对照着攻略在打游戏一样。
作为工程师,你既要能够一行一行写出这样一个模块,更要关注规范为什么要那样规定。
【Promise/A+测试套件】: https://github.com/promises-aplus/promises-tests
如果你对照规范的要求写出了这个模块,可以利用官方提供的测试套件(包含800多个测试用例来测试规范中规定的各个细节)来测试自己编写的模块并完善它。javascript语言中都是通过鸭式辩型来检测接口的,无论你是怎样实现规范的各个要求,只要最终通过测试套件的要求即可。如果你依旧觉得心里没谱,也可以参考别人的博文来学习Promise
的细节,例如这篇《Promise详解与实现》就给了笔者很大帮助。
八.API以外的视角
当越过了语言层面的难点后,推荐你阅读《深入理解Promise五部曲》这个系列的文章。大多数开发者对于Promise
的理解和应用都是用来解决回调地狱问题的,而这个系列的文章会让你从另一个角度重新认识Promise,不得不说文章中用发布订阅模式来类比解释Promise
的实现机制对于笔者理解Promise提供了巨大的帮助,同时它也能够引发一些通过学习promise/A+规范很难意识到的关于精髓和本质的思考。