AngularJS 中的 Promise 和 設計模式

Promises And Design Patternsjavascript

寫得好長好長好長長~前端


解決 Javascript 異步事件的傳統方式是回調函數;調用一個方法,而後給它一個函數引用,當這個方法完結的時候執行這個函數引用。java

<!-- lang: js -->
$.get('api/gizmo/42', function(gizmo) {
  console.log(gizmo); // or whatever
});

看起來很不錯對不對,不過,也有缺點的;首先,合併或者連接多個異步過程超複雜;要麼就是大量的模板代碼,要麼就是嗯哼你懂的回調地獄(一層套一層的回調):git

<!-- lang: js -->
$.get('api/gizmo/42', function(gizmo) {
  $.get('api/foobars/' + gizmo, function(foobar) {
    $.get('api/barbaz/' + foobar, function(bazbar) {
      doSomethingWith(gizmo, foobar, bazbar);
    }, errorCallback);
  }, errorCallback);
}, errorCallback);

明白了吧。其實在 Javascript 中,有另一種異步處理模式:更屌,在 Javascript 裏面常常被叫作 Promises, CommonJS 標準委員會因而發佈了一個規範,就把這個 API 叫作 Promises 了。angularjs

Promise 背後的概念很是簡單,有兩部分:github

  • Deferreds,定義工做單元
  • Promises,從 Deferreds 返回的數據

promise-deferred-objects-in-javascript-pt1-theory-and-semantics

基本上,你會用 Deferred 做爲通訊對象,用來定義工做單元的開始,處理和結束三部分。web

Promise 是 Deferred 響應數據的輸出;它有狀態 (等待,執行和拒絕),以及句柄,或叫作回調函數,反正就是那些在 Promise 執行,拒絕或者提示進程中會被調用的方法。編程

Promise 不一樣於回調的很重要的一個點是,你能夠在 Promise 狀態變成執行(resolved)追加處理句柄。這就容許你傳輸數據,而忽略它是否已經被應用獲取,而後緩存它,等等之類的操做,所以你能夠對數據執行操做,而無論它是否已經或者即將可用。api

在以後的文章中,咱們將會基於 AngularJS 來說解 Promises 。AngularJS 的整個代碼庫很大程度上依賴於 Promise,包括框架以及你用它編寫的應用代碼。AngularJS 用的是它本身的 Promises 實現, $q 服務,又一個 Q 庫的輕量實現。數組

$q 實現了上面提到的全部 Deferred / Promise 方法,除此以外 $q 還有本身的實現: $q.defer(),用來建立一個新的 Deferred 對象; $q.all(),容許等待多 Promises 執行終了,還有方法 $q.when()$q.reject(),具體咱們以後會講到。

$q.defer() 返回一個 Deferred 對象,帶有方法 resolve(), reject(), 和 notify()。Deferred 還有一個 promise 屬性,這是一個 promise對象,能夠用於應用內部傳遞。

promise 對象有另外三個方法: .then(),是惟一 Promise 規範要求的方法,用三個回調方法做爲參數;一個成功回調,一個失敗回調,還有一個狀態變化回調。

$q 在 Promise 規範之上還添加了兩個方法: catch(),能夠用於定義一個通用方法,它會在 promise 鏈中有某個 promise 處理失敗時被調用。還有 finally(),無論 promise 執行是成功或者失敗都會執行。注意,這些不該該和 Javascript 的異常處理混淆或者並用: 在 promise 內部拋出的異常,不會被 catch() 俘獲。(※貌似這裏我理解錯了)

Promise 簡單例子

下面是使用 $qDeferred,和 Promise 放一塊兒的簡單例子。首先我要聲明,本文中全部例子的代碼都沒有通過測試;並且也沒有正確的引用 Angular 服務和依賴,之類的。不過我以爲對於啓發你怎麼玩,已經夠好了。

首先,咱們先建立一個新的工做單元,經過 Deferred 對象,用 $q.defer():

<!-- lang: js -->
var deferred = $q.defer();

而後,咱們從 Deferred 拿到 promise,給它追加一些行爲。

<!-- lang: js -->
var promise = deferred.promise;

promise.then(function success(data) {
  console.log(data);
}, function error(msg) {
  console.error(msg);
});

最後,咱們僞裝作點啥,而後告訴 deferred 咱們已經完成了:

<!-- lang: js -->
deferred.resolve('all done!');

固然,這不須要真的異步,因此咱們能夠用 Angular 的 $timeout 服務(或者 Javascript 的 setTimeout,不過,在 Angular 應用中最好用 $timeout,這樣你能夠 mock/test 它)來僞裝一下。

<!-- lang: js -->
$timeout(function() {
  deferred.resolve('All done... eventually');
}, 1000);

好了,有趣的是:咱們能夠追加不少個 then() 到一個 promise 上,以及咱們能夠在 promise 被 resolved 以後追加 then():

<!-- lang: js -->
var deferred = $q.defer();
var promise = deferred.promise;

// assign behavior before resolving
promise.then(function (data) {
  console.log('before:', data);
});

deferred.resolve('Oh look we\'re done already.')

// assign behavior after resolving
promise.then(function (data) {
  console.log('after:', data);
});

那,要是發生異常怎麼辦?咱們用 deferred.reject(),它會出發 then() 的第二個函數,就像回調同樣。

<!-- lang: js -->
var deferred = $q.defer();
var promise = deferred.promise;

promise.then(function success(data) {
  console.log('Success!', data);
}, function error(msg) {
  console.error('Failure!', msg);
});

deferred.reject('We failed :(');

不用 then() 的第二個參數,還有另一種選擇,你能夠用鏈式的 catch(),在 promise 鏈中發生異常的時候它會被調用(可能在不少鏈以後)。

<!-- lang: js -->
promise
  .then(function success(data) {
    console.log(data);
  })
  .catch(function error(msg) {
    console.error(msg);
  });

做爲一個附加,對於長耗時的處理(好比上傳,長計算,批處理,等等),你能夠用 deferred.notify() 做爲 then() 第三個參數,給 promise 一個監聽來更新狀態。

<!-- lang: js -->
var deferred = $q.defer();
var promise = deferred.promise;

promise
  .then(function success(data) {
    console.log(data);
  },
  function error(error) {
    console.error(error);
  },
  function notification(notification) {
    console.info(notification);
  }));

 var progress = 0;
 var interval = $interval(function() {
  if (progress >= 100) {
    $interval.cancel(interval);
    deferred.resolve('All done!');
  }
  progress += 10;
  deferred.notify(progress + '%...');
 }, 100)

#鏈式 Promise

以前咱們已經看過了,你能夠給一個 promise 追加多個處理(then())。Promise API 好玩的地方在於容許鏈式處理:

<!-- lang: js -->
promise
  .then(doSomething)
  .then(doSomethingElse)
  .then(doSomethingMore)
  .catch(logError);

舉個簡單的例子,這容許你把你的函數調用切分紅單純的,單一目的方法,而不是一攬子麻團;還有另一個好處是你能夠在多 promise 任務中重用這些方法,就像你執行鏈式方法同樣(好比說任務列表之類的)。

若是你用前一個異步執行結果出發下一個異步處理,那就更牛X了。默認的,一個鏈式,像上面演示的那種,是會把前一個執行結果對象傳遞給下一個 then() 的。好比:

<!-- lang: js -->
var deferred = $q.defer();
var promise = deferred.promise;

promise
  .then(function(val) {
    console.log(val);
    return 'B';
  })
  .then(function(val) {
    console.log(val);
    return 'C'
  })
  .then(function(val) {
    console.log(val);
   });

deferred.resolve('A');

這會在控制檯輸出如下結果:

<!-- lang: js -->
A
B
C

雖然例子簡單,可是你有沒有體會到若是 then() 返回另外一個 promise 那種強大。這種狀況下,下一個 then() 會在 promise 完結的時候被執行。這種模式能夠用到把 HTTP 請求串上面,好比說(當一個請求依賴於前一個請求的結果的時候):

<!-- lang: js -->
var deferred = $q.defer();
var promise = deferred.promise;

// resolve it after a second
$timeout(function() {
  deferred.resolve('foo');
}, 1000);

promise
  .then(function(one) {
    console.log('Promise one resolved with ', one);

    var anotherDeferred = $q.defer();

    // resolve after another second

    $timeout(function() {
      anotherDeferred.resolve('bar');
    }, 1000);

    return anotherDeferred.promise;
  })
  .then(function(two) {
    console.log('Promise two resolved with ', two);
  });

總結:

  • Promise 鏈會把上一個 then 的返回結果傳遞給調用鏈的下一個 then (若是沒有就是 undefined)
  • 若是 then 回掉返回一個 promise 對象,下一個 then 只會在這個 promise 被處理結束的時候調用。
  • 在鏈最後的 catch 爲整個鏈式處理提供一個異常處理點
  • 在鏈最後的 finally 老是會被執行,無論 promise 被處理或者被拒絕,起清理做用

#Parallel Promises And 'Promise-Ifying' Plain Values

我還提到了 $q.all(),容許你等待並行的 promise 處理,當全部的 promise 都被處理結束以後,調用共同的回調。在 Angular 中,這個方法有兩種調用方式: 以 Array 方式或 Object 方式。Array 方式接收多個 promise ,而後在調用 .then() 的時候使用一個數據結果對象,在結果對象裏面包含了全部的 promise 結果,按照輸入數組的順序排列:

<!-- lang: js -->
$q.all([promiseOne, promiseTwo, promiseThree])
  .then(function(results) {
    console.log(results[0], results[1], results[2]);
  });

第二種方式是接收一個 promise 集合對象,容許你給每一個 promise 一個別名,在回調函數中可使用它們(有更好的可讀性):

<!-- lang: js -->
$q.all({ first: promiseOne, second: promiseTwo, third: promiseThree })
  .then(function(results) {
    console.log(results.first, results.second, results.third);
  });

我建議使用數組表示法,若是你只是但願能夠批處理結果,就是說,若是你把全部的結果都平等處理。而以對象方式來處理,則更適合須要自注釋代碼的時候。

另外一個有用的方法是 $q.when(),若是你想經過一個普通變量建立一個 promise ,或者你不清楚你要處理的對象是否是 promise 時很是有用。

<!-- lang: js -->
$q.when('foo')
  .then(function(bar) {
    console.log(bar);
  });

$q.when(aPromise)
  .then(function(baz) {
    console.log(baz);
  });

$q.when(valueOrPromise)
  .then(function(boz) {
    // well you get the idea.
  })

$q.when() 在諸如服務中的緩存這種狀況也很好用:

<!-- lang: js -->
angular.module('myApp').service('MyService', function($q, MyResource) {

  var cachedSomething;

  this.getSomething = function() {
    if (cachedSomething) {
      return $q.when(cachedSomething);
    }

    // on first call, return the result of MyResource.get()
    // note that 'then()' is chainable / returns a promise,
    // so we can return that instead of a separate promise object
    return MyResource.get().$promise
      .then(function(something) {
        cachedSomething = something
      });
  };
});

而後能夠這樣調用它:

<!-- lang: js -->
MyService.getSomething()
    .then(function(something) {
        console.log(something);
    });

AngularJS 中的實際應用

在 Angular 的 I/O 中,大多數會返回 promise 或者 promise-compatible(then-able)對象,可是,都挺奇怪的。$http 文檔 說,它會返回一個 HttpPromise 對象,嗯,確實是 promise,可是有兩個額外的(有用的)方法,應該不會嚇到 jQuery 用戶。它定義了 success()error() ,用來分別對應 then() 的第一和第二個參數。

Angular 的 $resource 服務,用於 REST-endpoints 的 $http 封裝,一樣有點奇怪;通用方法(get(),save()之類的四個)接收第二和第三個參數做爲 successerror 回調,同時它們還返回一個對象,當請求被處理以後,會往其中填充請求的數據。它不會直接返回 promise 對象;相反,經過 get() 方法返回的對象有一個屬性 $promise,用來暴露 promise 對象。

一方面,這和 $http 不符,而且 Angular 的全部東西都是/應該是 promise,不過另外一方面,它容許開發者簡單的把 $resource.get() 的結果指派給 $scope。原先,開發者能夠給 $scope 指定任何 promise,可是從 Angular 1.2 開始被定義爲過期了:請看this commit where it was deprecated

我我的來講,我更喜歡統一的 API,因此我把全部的 I/O 操做都封裝到了 Service 中,統一返回一個 promise 對象,不過調用 $resource 有點糙。下面是個例子:

<!-- lang: js -->
angular.module('fooApp')
  .service('BarResource', function ($resource) {
    return $resource('api/bar/:id');
  })

  .service('BarService', function (BarResource) {

    this.getBar = function (id) {
      return BarResource.get({
        id: id
      }).$promise;
    }

  });

這個例子有點晦澀,由於傳遞 id 參數給 BarResource 看起來有點多餘,不過它也仍是有道理的,好比你有一個複雜的對象,但只須要用它的 ID 屬性來調用一個服務。上面的好處還在於,在你的 controller 中,你知道從 Service 返回來的全部東西都是 promise 對象;你不須要擔憂它究竟是 promise 仍是 resouce 或者是 HttpPromise,這能讓你的代碼更加一致,而且可預測 - 由於 Javascript 是弱類型,而且到目前爲止,據我所知沒有任何一款 IDE 能告訴你方法返回值的類型,它只能告訴你開發者寫了什麼註釋,這點上面就很是重要了。

實際鏈式例子

咱們的代碼庫有一部分是依賴於前一個調用的結果來執行的。Promise 很是適用這種狀況,而且容許你書寫易於閱讀的代碼,儘量保持你的代碼整潔。考慮以下例子:

<!-- lang: js -->
angular.module('WebShopApp')
  .controller('CheckoutCtrl', function($scope, $log, CustomerService, CartService, CheckoutService) {

    function calculateTotals(cart) {
      cart.total = cart.products.reduce(function(prev, current) {
        return prev.price + current.price;
      };

      return cart;
    }

    CustomerService.getCustomer(currentCustomer)
      .then(CartService.getCart) // getCart() needs a customer object, returns a cart
      .then(calculateTotals)
      .then(CheckoutService.createCheckout) // createCheckout() needs a cart object, returns a checkout object
      .then(function(checkout) {
        $scope.checkout = checkout;
      })
      .catch($log.error)

    });

聯合異步獲取數據(customers, carts,建立 checkout)和處理同步數據(calculateTotals);這個實現不知道,甚至不須要知道這些服務是否是異步的,它會等到方法之行結束,不論異步與否。在這個例子中,getCart()會從本地存儲中獲取數據, createCheckout() 會執行一個 HTTP 請求來肯定產品的採購,諸如此類。不過從用戶的視角來看(執行這個調用的人),它不會關心這些;這個調用起做用了,而且它的狀態很是明瞭,你只要記住前一個調用會將結果返回傳遞到下一個 then()

固然,它就是自注釋代碼,而且很簡潔。

#測試 Promise - 基於代碼

測試 Promise 很是簡單。你能夠硬測,建立你的測試模擬對象,而後暴露 then() 方法,這種直接測法。可是,爲了讓事情簡單,我只用了 $q 來建立 promise - 這是一個很是快的庫。下面嘗試演示如何模擬上面用到過的各類服務。注意,這很是冗長,不過,我尚未找出一個方法來解決它,除了在 promise 以外弄一些通用的方法(指針看起來更短更簡潔,會比較受歡迎)。

<!-- lang: js -->
describe('The Checkout controller', function() {

  beforeEach(module('WebShopApp'));

  it('should do something with promises', inject(function($controller, $q, $rootScope) {

    // create mocks; in this case I use jasmine, which has been good enough for me so far as a mocking library.
    var CustomerService = jasmine.createSpyObj('CustomerService', ['getCustomer']);
    var CartService = jasmine.createSpyObj('CartService', ['getCart']);
    var CheckoutService = jasmine.createSpyObj('CheckoutService', ['createCheckout']);

    var $scope = $rootScope.$new();
    var $log = jasmine.createSpyObj('$log', ['error']);

    // Create deferreds for each of the (promise-based) services
    var customerServiceDeferred = $q.defer();
    var cartServiceDeferred = $q.defer();
    var checkoutServiceDeferred = $q.defer();

    // Have the mocks return their respective deferred's promises
    CustomerService.getCustomer.andReturn(customerServiceDeferred.promise);
    CartService.getCart.andReturn(cartServiceDeferred.promise);
    CheckoutService.createCheckout.andReturn(checkoutServiceDeferred.promise);

    // Create the controller; this will trigger the first call (getCustomer) to be executed,
    // and it will hold until we start resolving promises.
    $controller("CheckoutCtrl", {
      $scope: $scope,
      CustomerService: CustomerService,
      CartService: CartService,
      CheckoutService: CheckoutService
    });

    // Resolve the first customer.
    var firstCustomer = {id: "customer 1"};
    customerServiceDeferred.resolve(firstCustomer);

    // ... However: this *will not* trigger the 'then()' callback to be called yet;
    // we need to tell Angular to go and run a cycle first:

    $rootScope.$apply();

    expect(CartService.getCart).toHaveBeenCalledWith(firstCustomer);

    // setup the next promise resolution
    var cart = {products: [ { price: 1 }, { price: 2 } ]}
    cartServiceDeferred.resolve(cart);

    // apply the next 'then'
    $rootScope.$apply();

    var expectedCart = angular.copy(cart);
    cart.total = 3;

    expect(CheckoutService.createCheckout).toHaveBeenCalledWith(expectedCart);

    // Resolve the checkout service
    var checkout = {total: 3}; // doesn't really matter
    checkoutServiceDeferred.resolve(checkout);

    // apply the next 'then'
    $rootScope.$apply();

    expect($scope.checkout).toEqual(checkout);

    expect($log.error).not.toHaveBeenCalled();
  }));
});

你看到咯,測試 promise 的代碼比它本身自己要長十倍;我不知道是否/或者有更簡單的代碼能達到一樣目的,不過,也許這裏應該還有我沒找到(或者發佈)的庫。

要獲取完整的測試覆蓋,須要爲三個部分都編寫測試代碼,從失敗處處理結束,一個接一個,確保異常被記錄。雖然代碼中沒有很清楚演示,可是代碼/處理實際上會有許多分支;每一個 promise 到最後都會被解決或者拒絕;真或假,或者被創建分支。不過,測試的粒度究竟是由你決定的。

我但願這篇文章給你們帶來一些理解 promise 的啓示,以及教會怎樣結合 Angular 來使用 promise。我以爲我只摸到了 一些皮毛,包括在這篇文章以及在到目前爲止我所作過的 AngularJS 工程上;promise 可以擁有如此簡單的 API,如此簡單的概念,而且對大多數 Javascript 應用來講,有如此強大的力量和影響有點難以置信。結合高水平的通用方法,代碼庫,promise 可讓你寫出更乾淨,易於維護和易於擴展的代碼;添加一個句柄,改變它,改變實現方式,全部這些東西都很容易,若是你對 promise 的概念已經理解了的話。

從這點考慮,NodeJS 在開發早期就拋棄了 promise 而採用如今這種回調方式,我以爲很是古怪;固然我尚未徹底深刻理解它,可是看起來好像是由於性能問題,不符合 Node 的本來目標的緣故。若是你把 NodeJS 當成一個底層的庫來看的話,我以爲仍是頗有道理的;有大量的庫能夠爲 Node 添加高級的 promise API(好比以前提到的 Q).

還有一點請記住,這篇文章是以 AngularJS 爲基礎的,可是,promises類promise 編程方式已經在 Javascript 庫中存在好幾年了;jQuery ,Deferreds 早在 jQuery 1.5 (1月 2011) 就被添加進來。雖然看起來同樣,但不是全部插件都能用。

一樣,Backbone.js 的 Model Api 也暴露了 promise 在它的方法中(save() 之類),可是,以個人理解,它貌似沒有沿着模型事件真正的起做用。也有可能我是錯的,由於已經有那麼一段時間了。

若是開發一個新的 webapp 的時候,我確定會推薦基於 promise 的前端應用的,由於它讓代碼看起來很是整潔,特別是結合函數式編程範式。還有更多功能強勁的編程模式能夠在 Reginald BraithwaiteJavascript Allongé book 中找到,你能夠從 LeanPub 拿到免費的閱讀副本;還有另一些比較有用的基於 promise 的代碼。

相關文章
相關標籤/搜索