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的基本运行规则

  1. promise对象存在三种状态:unfulfilled, fulfilled和failed
  2. 一旦promise由unfulfilled切换为fulfilled或者failed状态,它的状态不可再改变
  3. proposal没有定义如何创建promise
  4. promise对象必须包含then方法:then(fulfilledHandler, errorHandler, progressHandler)
  5. 交互式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等标准审核流程,你会发现:

  1. 首先Promise/A里面用语不规范,尤其是对术语的使用
  2. 只描述API的用途,没有详细的算法

  Promises/A+就是基于这样的问题产生的,要说明的是Promises/A+的维护者不再是前面提到的KrisZyp,而是由一个组织维护的。

  组织的成员如下,其中圈出来的另一个Kris需要留意一下,之后还会提到他。

  Promises/A+在Promises/A的基础上做了如下几点修正:

  1. 移除了then的第三个入参progressHandler,所以你见不到了
  2. 移除了交互式promise的API:get和call,所以你用不了了
  3. 规定promise2 = promise1.then(…)中允许promise1 === promise2,但是文档必须对此情况进行说明
  4. promise的三种状态术语化:pending,fulfilled,rejected
  5. 规定fulfilled传递的参数叫value,rejected传递的参数叫reason
  6. 严格区分thenable和promise,thenable作为promise的鸭子类型存在,thenable是什么、鸭子类型是什么,下面会解释
  7. 使用正式且标准的语言描述了then方法的逻辑算法,promises-aplus还提供了验证实现的test case

  Promises/A+没有新增任何API,而且删掉了Promises/A的部分冗余设计。这样一来,Promises/A+其实只规定了,promise对象必须包含指定算法的方法then。接下来我会归整下所谓的then算法,以及它存在哪些不常见的调用方式。

  then的基本调用方式:promise.then(onFulfilled, onRejected),我默认你已经掌握了基础的then调用,所以常见的场景以下不做举例。
  1. 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))
    

      

  2. 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
    

      

  3. var promise2 = promise1.then(onFulfilled, onRejected)
    

    onFulfilled或者onRejected中抛出异常,则promise2状态置为rejected

  4. 上面的例子中,onFulfilled或者onRejected如果返回了任意值x(如果不存在return语句,则是返回undefined),则进入解析过程[[Resolve]](promise2, x)

  5. 解析过程[[Resolve]](promise2, x)算法如下

    1. 如果x是promise,则promise2的状态取决于x的状态
    2. 那么你会想,如果x === promise2呢?promise2的状态取决于本身的状态?这就像把obj的原型设置为自身一样肯定是不允许的。所以其实在第一条规则之前,还有一条:如果x === promise2,抛出TypeError。之所以把这条规则放到下面,是用前一条规则引出这条规则的必要性
    3. 如果x不是对象,promise2置为fulfilled,value为x
    4. 如果x是对象
      1. 访问x.then时,如果抛出异常,则promise2置为rejected,reason为抛出的异常
        var obj = {get then() {throw 'err'}};
        Promise.resolve('value').then(v => obj).catch(reason => console.log(reason));    // err
        

          

      2. 如果then不是函数,则同3
        Promise.resolve('value').then(v => {
          return {
            name: 'tarol',
            then: void 0
          }
        }).then(v => console.log(v.name));  //tarol
        

          

      3. 如果then是函数,那么x就是一个thenable,then会被立即调用,传入参数resolve和reject,并绑定x作为this。

        1. 如果执行过程中调用了resolve(y),那么进入下一个解析过程[[Resolve]](promise2, y),可以看出解析过程实际上是一个递归函数
        2. 如果调用了reject(r),那么promise2置为rejected,reason为r
        3. 调用resolve或reject后,后面的代码依然会运行
          Promise.resolve('value').then(v => {
            return {
              then: (resolve, reject) => {
                resolve(v);
                console.log('continue');  //  continue
              }
            }
          }).then(v => console.log(v)); //  value
          

            

        4. 如果既调用了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
          

            

        5. 如果抛出了异常,而抛出的时机在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

  上面的例子中涉及到一个重要的概念,就是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/BPromises/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对象的方法。

  1. when(value, callback, errback_opt):类似于es6中Promise.resolve(value).then(callback, errback_opt)
  2. asap(value, callback, errback_opt):基本逻辑同when,但是when中callback的调用会放在setTimeout(callback, 0)中,而asap中callback是直接调用,该接口在Q中已经废弃
  3. enqueue(task Function):将一个callback插入队列并执行,其实就是fn => setTimeout(fn, 0),该接口在Q中已经废弃
  4. get(object, name):类似于Promise.resolve(object[name])
  5. post(object, name, args):类似于Promise.resolve(object[name].apply(object, args))
  6. put(object, name, value):类似于Promise.resolve({then: resolve => object[name] = value; resolve()}),该接口在Q中重命名为set
  7. del(object, name):类似于Promise.resolve({then: resolve => delete object[name]; resolve()}),该接口在Q中alias为delete
  8. makePromise:创建一个流程控制类的promise,并自定义其verbs方法,verbs方法指以上的get、post、put、del
  9. defer:创建一个deferred,包含一个延时类的promise
  10. reject:创建一个rejected的流程控制类promise
  11. ref:创建一个resolve的流程控制类promise,该接口在Q中重命名为fulfill
  12. isPromise:判断一个对象是否是promise
  13. 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.jswhen。但是鉴于和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库时长期存在的疑惑;二是告诉自己,很多现在看来尘埃落地的技术并非天生如此,沿着前路走过来会比站在终点看到更精彩的世界。

版权声明:本文为tarol原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://www.cnblogs.com/tarol/p/9042407.html