聊一聊promise的前世今生
Posted tarol
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了聊一聊promise的前世今生相关的知识,希望对你有一定的参考价值。
promise的概念已经出现很久了,浏览器、nodejs都已经全部实现promise了。现在来聊,是不是有点过时了?
确实,如果不扯淡,这篇随笔根本不会有太多内容。所以,我就尽可能的,多扯一扯,聊一聊promise的另一面。
大家应该都知道怎么创建一个promise
var promise = new Promise(resolve => { setTimeout(() => resolve(‘tarol‘), 3000) });
如果从业时间长一点,会知道以前的promise不是这么创建的。比如如果你用过jquery,jquery在1.5引入deferred的概念,里面是这样创建promise的
var defer = $.Deferred(); var promise = defer.promise();
如果你用过angular,里面有个promise service叫$q,它又是这么创建promise的
var defer = $q.defer(); var promise = defer.promise;
好了,这里已经有三种创建promise的方式了。其中第一种是现在最常见的,第二种和第三种看上去很像,但却有细微的差别。比如jquery里面是通过执行函数promise()返回promise,而angular中defer的属性就是promise。如果你还有兴趣,那么我从头开始讲。
promise的引入是为了规范化异步操作,随着前端的逻辑越来越复杂,异步操作的问题越来越亟待解决。首先大量的异步操作形成了N级的大括号,俗称“回调地狱”;其次callback的写法没有标准,nodejs里面的callback一般是(err, data) => {...},jquery里面的success callback又是data => {...}。在这种场景下,很多异步流程控制的类库应运而生。
作为前端,一般最早接触promise的概念是在jquery的1.5版本发布的deferred objects。但是前端最早引入promise的概念的却不是jquery,而是dojo,而且promise之所以叫promise也是因为dojo。Promises/A标准的撰写者KrisZyp于09年在google的CommonJS讨论组发了一个贴子,讨论了promise API的设计思路。他声称想将这类API命名为future,但是dojo已经实现的deferred机制中用到了promise这个术语,所以还是继续使用promise为此机制命名。之后便有了CommonJS社区的这个proposal,即Promises/A。如果你对什么是deferred,什么是promise还存在疑问,不要急,先跳过,后面会讲到。
Promises/A是一个非常简单的proposal,它只阐述了promise的基本运行规则
- promise对象存在三种状态:unfulfilled, fulfilled和failed
- 一旦promise由unfulfilled切换为fulfilled或者failed状态,它的状态不可再改变
- proposal没有定义如何创建promise
- promise对象必须包含then方法:then(fulfilledHandler, errorHandler, progressHandler)
- 交互式promise对象作为promise对象的扩展,需要包含get方法和call方法:get(propertyName)、call(functionName, arg1, arg2, ...)
如果你研究过现在浏览器或nodejs的promise,你会发现Promises/A好像处处相似,但又处处不同。比如三种状态是这个叫法吗?progressHandler没见过啊!get、call又是什么鬼?前面两个问题可以先放一放,因为后面会做出解答。第三个问题这里解释下,什么是get,什么是call,它们的设计初衷是什么,应用场景是什么?虽然现在你轻易见不到它们了,但是了解它们有助于理解后面的部分内容。
一般来说,promise调用链存在两条管道,一条是promise链,就是下图一中的多个promise,一条是回调函数中的值链,就是下图二中的多个value或reason
现在我们都知道,值链中前一个callback(callback1)的返回值是后一个callback(callback2)的入参(这里仅讨论简单值类型的fulfilled的情况)。但是如果我callback1返回的是a,而callback2的入参我希望是a.b呢?或许你可以说那我callback1返回a.b就是了,那如果callback1和callback2都是固定的业务算法,它们的入参和返回都是固定的,不能随便修改,那又怎么办呢?如果promise只支持then,那么我们需要在两个then之间插入一个新的then:promise.then(callback1).then(a => a.b).then(callback2)。而get解决的就是这个问题,有了get后,可以这么写:promise.then(callback1).get(‘b‘).then(callback2),这样promise链条中就可以减少一些奇怪的东西。同理,当a.b是一个函数,而callback2期望的入参是a.b(c),那么可以这样写:promise.then(callback1).call(‘b‘, c).then(callback2)。
我们回到之前的话题,现在常见的promise和Promise/A到底是什么关系,为什么会有花非花雾非雾的感觉?原因很简单,常见的promise是参照Promises/A的进阶版——Promises/A+定义的。
Promises/A存在一些很明显的问题,如果你了解TC39 process或者RFC等标准审核流程,你会发现:
- 首先Promise/A里面用语不规范,尤其是对术语的使用
- 只描述API的用途,没有详细的算法
Promises/A+就是基于这样的问题产生的,要说明的是Promises/A+的维护者不再是前面提到的KrisZyp,而是由一个组织维护的。
组织的成员如下,其中圈出来的另一个Kris需要留意一下,之后还会提到他。
Promises/A+在Promises/A的基础上做了如下几点修正:
- 移除了then的第三个入参progressHandler,所以你见不到了
- 移除了交互式promise的API:get和call,所以你用不了了
- 规定promise2 = promise1.then(...)中允许promise1 === promise2,但是文档必须对此情况进行说明
- promise的三种状态术语化:pending,fulfilled,rejected
- 规定fulfilled传递的参数叫value,rejected传递的参数叫reason
- 严格区分thenable和promise,thenable作为promise的鸭子类型存在,thenable是什么、鸭子类型是什么,下面会解释
- 使用正式且标准的语言描述了then方法的逻辑算法,promises-aplus还提供了验证实现的test case
Promises/A+没有新增任何API,而且删掉了Promises/A的部分冗余设计。这样一来,Promises/A+其实只规定了,promise对象必须包含指定算法的方法then。接下来我会归整下所谓的then算法,以及它存在哪些不常见的调用方式。
- onFulfilled和onRejected都是可选的,如果省略了或者类型不是函数,前面流过来的value或者reason直接流到下一个callback,我们举两个极端的例子
Promise.resolve(‘resolve‘).then().then(value => console.log(value)) // resolve Promise.reject(‘reject‘).then().then(void 0, reason => console.log(reason)) //reason
这个特性决定了我们现在可以这样写异常处理
Promise.reject(‘reason‘).then(v => v).then(v => v).then(v => v).catch(reason => console.log(reason)) //reason
但是如果你在then链条中,插入一个空的onRejected,reason就流不到catch了。因为onRejected返回了undefined,下一个promise处于fulfilled态
Promise.reject(‘reason‘).then(v => v).then(v => v).then(v => v, () => {}).catch(reason => console.log(reason))
- onFulfilled或onRejected只能调用一次,且只能以函数的形式被调用,对应的是不能以属性方法的方式被调用,比如
var name = ‘tarol‘; var person = { name: ‘okal‘, say: function() { console.log(this.name); } } person.say(); //okal Promise.resolve(‘value‘).then(person.say); //tarol
如果你想第二行还是打印出‘okal‘,请使用bind
Promise.resolve(‘value‘).then(person.say.bind(person)); //okal
-
var promise2 = promise1.then(onFulfilled, onRejected)
onFulfilled或者onRejected中抛出异常,则promise2状态置为rejected
-
上面的例子中,onFulfilled或者onRejected如果返回了任意值x(如果不存在return语句,则是返回undefined),则进入解析过程[[Resolve]](promise2, x)
解析过程[[Resolve]](promise2, x)算法如下 - 如果x是promise,则promise2的状态取决于x的状态
- 那么你会想,如果x === promise2呢?promise2的状态取决于本身的状态?这就像把obj的原型设置为自身一样肯定是不允许的。所以其实在第一条规则之前,还有一条:如果x === promise2,抛出TypeError。之所以把这条规则放到下面,是用前一条规则引出这条规则的必要性
- 如果x不是对象,promise2置为fulfilled,value为x
- 如果x是对象
- 访问x.then时,如果抛出异常,则promise2置为rejected,reason为抛出的异常
var obj = {get then() {throw ‘err‘}}; Promise.resolve(‘value‘).then(v => obj).catch(reason => console.log(reason)); // err
- 如果then不是函数,则同3
Promise.resolve(‘value‘).then(v => { return { name: ‘tarol‘, then: void 0 } }).then(v => console.log(v.name)); //tarol
如果then是函数,那么x就是一个thenable,then会被立即调用,传入参数resolve和reject,并绑定x作为this。 - 如果执行过程中调用了resolve(y),那么进入下一个解析过程[[Resolve]](promise2, y),可以看出解析过程实际上是一个递归函数
- 如果调用了reject(r),那么promise2置为rejected,reason为r
- 调用resolve或reject后,后面的代码依然会运行
Promise.resolve(‘value‘).then(v => { return { then: (resolve, reject) => { resolve(v); console.log(‘continue‘); // continue } } }).then(v => console.log(v)); // value
- 如果既调用了resolve、又调用了reject,仅第一个调用有效
Promise.resolve(‘value‘).then(v => { return { then: (resolve, reject) => { resolve(‘resolve‘); reject(‘reject‘) } } }).then(v => console.log(v), r => console.log(r)); // resolve
- 如果抛出了异常,而抛出的时机在resolve或reject前,promise2置为rejected,reason为异常本身。如果抛出的时机在resolve或reject之后,则忽略这个异常。以下case在chrome 66上运行失败,promise处于pending状态不切换,但是在nodejs v8.11.1上运行成功
Promise.resolve(‘value‘).then(v => { return { then: (resolve, reject) => { resolve(‘resolve‘); throw ‘err‘; } } }).then(v => console.log(v), r => console.log(r)); // resolve
Promise.resolve(‘value‘).then(v => { return { then: (resolve, reject) => { throw ‘err‘; resolve(‘resolve‘); } } }).then(v => console.log(v), r => console.log(r)); // err
- 访问x.then时,如果抛出异常,则promise2置为rejected,reason为抛出的异常
上面的例子中涉及到一个重要的概念,就是thenable。简单的说,thenable是promise的鸭子类型。什么是鸭子类型?搜索引擎可以告诉你更详尽的解释,长话短说就是“行为像鸭子那么它就是鸭子”,即类型的判断取决于对象的行为(对象暴露的方法)。放到promise中就是,一个对象如果存在then方法,那么它就是thenable对象,可以作为特殊类型(promise和thenable)进入promise的值链。
promise和thenble如此相像,但是为什么在解析过程[[Resolve]](promise2, x)中交由不同的分支处理?那是因为虽然promise和thenable开放的接口一样,但过程角色不一样。promise中then的实现是由Promises/A+规定的(见then算法),入参onFulfilled和onRejected是由开发者实现的。而thenable中then是由开发者实现的,入参resolve和reject的实现是由Promises/A+规定的(见then算法3.3.3)。thenable的提出其实是为了可扩展性,其他的类库只要实现了符合Promises/A+规定的thenable,都可以无缝衔接到Promises/A+的实现库中。
Promises/A+先介绍到这里了。如果你细心,你会发现前面漏掉了一个关键的内容,就是之前反复提到的如何创建promise。Promise/A+中并没有提及,而在当下来说,new Promise(resolver)的创建方式仿佛再正常不过了,普及程度让人忘了还有deferred.promise这种方式。那么Promise构造器又是谁提出来的,它为什么击败了deferred成为了promise的主流创建方式?
首先提出Promise构造器的标准大名鼎鼎,就是es6。现在你见到的promise,一般都是es6的实现。es6不仅规定了Promise构造函数,还规定了Promise.all、Promise.race、Promise.reject、Promise.resolve、Promise.prototype.catch、Promise.prototype.then一系列耳熟能详的API(Promise.try、Promise.prototype.finally尚未正式成为es标准),其中then的算法就是将Promises/A+的算法使用es的标准写法规范了下来,即将Promises/A+的逻辑算法转化为了es中基于解释器API的具体算法。
那么为什么es6放弃了大行其道的deferred,最终敲定了Promise构造器的创建方式呢?我们写两个demo感受下不同
var Q = require("q"); var deferred = Q.defer(); deferred.promise.then(v => console.log(v)); setTimeout(() => deferred.resolve("tarol"), 3000);
var p = new Promise(resolve => { setTimeout(() => resolve("tarol"), 3000); }); p.then(v => console.log(v));
前者是deferred方式,需要依赖类库Q;后者是es6方式,可以在nodejs环境直接运行。
如果你习惯使用deferred,你会觉得es6的方式非常不合理:
首先,promise的产生的原因之一是为了解决回调地狱的问题,而Promise构造器的方式在构造函数中直接注入了一个函数,如果这个函数在复杂点,同样存在一堆大括号。
其次,promise基于订阅发布模式实现,deferred.resolve/reject可以理解为发布器/触发器(trigger),deferred.promise.then可以理解为订阅器(on)。在多模块编程时,我可以在一个公共模块创建deferred,然后在A模块引用公共模块的触发器触发状态的切换,在B模块引用公共模块使用订阅器添加监听者,这样很方便的实现了两个没有联系的模块间互相通信。而es6的方式,触发器在promise构造时就生成了并且立即进入触发阶段(即创建promise到promise被fulfill或者reject之间的过程),自由度减少了很多。
我一度很反感这种创建方式,认为这是一种束缚,直到我看到了bluebird(Promise/A+的实现库)讨论组中某个帖子的解释。大概说一下,回帖人的意思是,promise首先应该是一个异步流程控制的解决方案,流程控制包括了正常的数据流和异常流程处理。而deferred的方式存在一个致命的缺陷,就是promise链的第一个promise(deferred.promise)的触发阶段抛出的异常是不交由promise自动处理的。我写几个demo解释下这句话
var Q = require("q"); var deferred = Q.defer(); deferred.promise.then(v => { throw ‘err‘ }).catch(reason => console.log(reason)); // err setTimeout(() => deferred.resolve("tarol"));
以上是一个正常的异常流程处理,在值链中抛出了异常,自动触发下一个promise的onRejected。但是如果在deferred.promise触发阶段的业务流程中抛出了异常呢?
var Q = require("q"); var deferred = Q.defer(); deferred.promise.catch(reason => console.log(reason)); // 不触发 setTimeout(() => { throw "err"; deferred.resolve("tarol"); });
这个异常将抛出到最外层,而不是由promise进行流程控制,如果想让promise处理抛出的异常,必须这么写
var Q = require("q"); var deferred = Q.defer(); deferred.promise.catch(reason => console.log(reason)); // err setTimeout(() => { try { throw "err"; } catch (e) { deferred.reject(e); } });
deferred的问题就在这里了,在deferred.promise触发阶段抛出的异常,不会自动交由promise链进行控制。而es6的方式就简单了
var p = new Promise(() => { throw "err"; }); p.catch(r => console.log(r)); // err
可见,TC39在设计Promise接口时,首先考虑的是将Promise看作一个异步流程控制的工具,而非一个订阅发布的事件模块,所以最终定下了new Promise(resolver)这样一种创建方式。
但是如果你说:我不听,我不听,deferred就是比new Promise好,而且我的promise在触发阶段是不会抛出异常的。那好,还有另外一套标准满足你,那就是Promises/B和Promises/D。其中Promises/D可以看做Promises/B的升级版,就如同Promises/A+之于Promises/A。这两个标准的撰写者都是同一个人,就是上面Promises/A+组织中圈起来的大胡子,他不仅维护了这两个标准,还写了一个实现库,就是上面提到的Q,同时angular中的$q也是参照Q实现的。
Promises/B和Promises/D(以下统称为Promises/B)都位于CommonJS社区,但是由于没有被社区采用,处于废弃的状态。而Q却是一个长期维护的类库,所以Q的实现和两个标准已经有所脱离,请知悉。
Promises/B和es6可以说是Promises/A+的两个分支,基于不同的设计理念在Promises/A+的基础上设计了两套不同的promise规则。鉴于Promises/A+在创建promise上的空白,Promises/B同样提供了创建promise的方法,而且是大量创建promise的方法。以下这些方法都由实现Promises/B的模块提供,而不是Promises/B中promise对象的方法。
- when(value, callback, errback_opt):类似于es6中Promise.resolve(value).then(callback, errback_opt)
- asap(value, callback, errback_opt):基本逻辑同when,但是when中callback的调用会放在setTimeout(callback, 0)中,而asap中callback是直接调用,该接口在Q中已经废弃
- enqueue(task Function):将一个callback插入队列并执行,其实就是fn => setTimeout(fn, 0),该接口在Q中已经废弃
- get(object, name):类似于Promise.resolve(object[name])
- post(object, name, args):类似于Promise.resolve(object[name].apply(object, args))
- put(object, name, value):类似于Promise.resolve({then: resolve => object[name] = value; resolve()}),该接口在Q中重命名为set
- del(object, name):类似于Promise.resolve({then: resolve => delete object[name]; resolve()}),该接口在Q中alias为delete
- makePromise:创建一个流程控制类的promise,并自定义其verbs方法,verbs方法指以上的get、post、put、del
- defer:创建一个deferred,包含一个延时类的promise
- reject:创建一个rejected的流程控制类promise
- ref:创建一个resolve的流程控制类promise,该接口在Q中重命名为fulfill
- isPromise:判断一个对象是否是promise
- method:传入verbs返回对应的函数,如method(‘get‘)即是上面4中的get,已废弃
不知道以上API的应用场景和具体用法不要紧,我们先总结一下。Promises/B和es6理念上最大的出入在于,es6更多的把promise定义为一个异步流程控制的模块,而Promises/B更多的把promise作为一个流程控制的模块。所以Promises/B在创建一个promise的时候,可以选择使用makePromise创建一个纯粹的操作数据的流程控制的promise,而get、post、put、del、reject、ref等都是通过调用makePromise实现的,是makePromise的上层API;也可以使用defer创建一个deferred,包含promise这个属性,对应一个延时类的promise。
延时类的promise经过前面的解释基本都了解用法和场景,那对数据进行流程控制的promise呢?在上面Promises/A部分说明了get和call两个API的用法和场景,Promises/B的get对应的就是Promises/A的get,call对应的是post。put/set是Promises/B新增的,和前二者一样,在操作数据时进行流程控制。比如在严格模式下,如果对象a的属性b的writable是false。这时对a.b赋值,是会抛出异常的,如果异常未被捕获,那么会影响后续代码的运行。
"use strict"; var a = {}; Object.defineProperty(a, "name", { value: "tarol", writable: false }); a.name = "okay"; console.log("end"); // 不运行
这时候如果使用Q的put进行流程控制,就可以把赋值这部分独立开来,不影响后续代码的运行。
"use strict"; var Q = require("q"); var a = {}; Object.defineProperty(a, "name", { value: "tarol", writable: false }); Q.set(a, "name", "okay").then( () => console.log("success"), () => console.log("fail") // fail ); console.log("end"); // end
这部分的应用场景是否有价值呢?答案就是见仁见智了,好在Q还提供了makePromise这个底层API,自定义promise可以实现比增删改查这些verbs更强大的功能。比如当我做数据校验的时候可以这样写
var Q = require("q"); var p = Q.makePromise({ isNumber: function(v) { if (isNaN(v)) { throw new Error(`${v} is not a number`); } else { return v; } } }); p .dispatch("isNumber", ["1a"]) .then(v => console.log(`number is ${v}`)) .catch(err => console.log("err", err)); // 1a is not a number p .dispatch("isNumber", ["1"]) .then(v => console.log(`number is ${v}`)) // number is 1 .catch(err => console.log("err", err));
以上不涉及任何异步操作,只是用Q对某个业务功能做流程梳理而已。
而且Q并未和es6分家,而是在后续的版本中兼容了es6的规范(Q.Promise对应es6中的全局Promise),成为了es6的父集,加之Q也兼容了Promises/A中被A+抛弃的部分,如progressHandler、get、call(post)。所以对于Q,你可以理解为promise规范的集大成者,整体来说是值得一用的。
最后要提到的是最为式微的promise规范——Promises/KISS,它的实现库直接用futures命名,实现了KrisZyp未竟的心愿。如果比较github上的star,KISS甚至不如我没有提及的then.js和when。但是鉴于和Q一样,是有一定实践经验后CommonJS社区promise规范的提案,所以花少量的篇幅介绍一下。
Promises/KISS不将Promises/A作为子集,所以它没有提供then作为订阅器,代之的是when和whenever两个订阅器。触发器也不是常见的resolve、reject,而是callback、errback和fulfill。其中callback类似于notify,即progressHandler的触发器,errback类似于reject,fulfill类似于resolve。
为什么会有两个订阅器呢?因为KISS不像Promises/A,A中的then中是传入三个监听器,其中progressHandler还可以多次触发。但是KISS中的when和whenever一次只能传入一个监听器,所以它要解决的是,同一种订阅方式,怎么订阅三种不同的监听器?
首先,怎么区分fulfilledHandler和errorHandler呢?KISS借鉴了nodejs的回调函数方式,第一个参数是err,第二个参数是data。所以fulfilledHandler和errorHandler在一个监听器里这样进行区分:
function(err, data) { if (err) {...} // errorHandler else {...} // fulfilledHandler }
那怎么区分多次调用的progressHandler呢?使用when注册的监听器只能调用一次,使用whenever注册的监听器可以调用多次。我们写个demo区分Q和KISS的API的不同:
var Q = require("q"); var defer = Q.defer(); defer.promise.then( v => console.log("fulfill", v), err => console.log("reject", err), progress => console.log("progress", progress) ); defer.notify(20); // progress 20 defer.notify(30); // progress 30 defer.notify(50); // progress 50 defer.resolve("ok"); // fulfill ok
var future = require("future"); var p = new future(); var progressHandler = function(err, progress) { if (err) { console.log("err", err); } else { console.log("progress", progress); } }; p.whenever(progressHandler); p.callback(20); // progress 20 p.callback(30); // progress 30 p.callback(50); // progress 50 p.removeCallback(progressHandler); // 需要移除监听器,不然fulfill时也会触发 p.when(function(err, v) { // 需要在callback调用后注册fulfill的监听器,不然callback会触发 if (err) { console.log("reject", err); } else { console.log("fulfill", v); } }); p.fulfill(void 0, "ok"); // fulfill ok
可见,实现同样的需求,使用future会更麻烦,而且还存在先后顺序的陷阱(我一向认为简单类库的应用代码如果存在严重的先后顺序,是设计的不合格),习惯使用es6的promise的童鞋还是不建议使用KISS标准的future。
整篇文章就到这里,前面提到的then.js和when不再花篇幅介绍了。因为promise的实现大同小异,都是订阅发布+特定的流程控制,只是各个标准的出发点和侧重点不同,导致一些语法和接口的不同。而随着es标准的越来越完善,其他promise的标准要么慢慢消亡(如future、then.js),要么给后续的es标准铺路(如bluebird、Q)。所以如果你没有什么执念的话,乖乖的跟随es标准是最省事的做法。而这边随笔的目的,一是借机整理一下自己使用各个promise库时长期存在的疑惑;二是告诉自己,很多现在看来尘埃落地的技术并非天生如此,沿着前路走过来会比站在终点看到更精彩的世界。
以上是关于聊一聊promise的前世今生的主要内容,如果未能解决你的问题,请参考以下文章