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 的代码。

相关文章
相关标签/搜索