1. 程式人生 > >聊一聊promise的前世今生

聊一聊promise的前世今生

在操作 審核 person ces require 子集 描述 下一個 delete

  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/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對象的方法。

  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.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的前世今生