Promise-ы в AngularJS

    Одной из ключевых составляющих практически любого веб-приложения является взаимодействие с сервером. В больших приложениях это далеко не один запрос. При этом запросы часто необходимо объединять для последовательного или параллельного выполнения, а часто сочетать и то и другое. Кроме того, большие приложения обычно имеют многослойную архитектуру — обертка над RESTFul API => бизнес-сущности => более комплексная бизнес-логика (разбиение условно для примера). И на каждом слое необходимо принять данные в одном формате и передать на следующий слой уже в другом.

    Вот со всеми этими задачами могут помочь справиться Promise-ы.

    За подробностями добро пожаловать под кат.


    Promise-ы предоставляют интерфейс для взаимодействия с объектами, содержащими результат выполнения некоторой операции, время окончания которой неизвестно. Изначально любой promise не разрешен (unresolved) и будет разрешен либо с определенным значением (resolved), либо отвергнут с ошибкой (rejected). Как только promise становится разрешен или отвергнут, его состояние уже не может измениться, что обеспечивает неизменность состояния в течение какого угодно числа проверок. Что не означает, что на разных этапах проверок вы получите одно и тоже значение.

    Кроме того, promise-ы можно объединять как для последовательного, так и для параллельного исполнения.

    Далее все описание будет построено на базе AngularJS 1.1.5, а все примеры будут исполнены в виде тестов.

    Итак, что из себя представляет promise? Это объект с двумя методами:
    • then(successCallback, errorCallback);
    • always(callback);


    Что в AngularJS вернет вам promise?
    • $http — сервис для выполнения AJAX-запросов;
    • $timeout — AngularJS-обертка над setTimeout;
    • различные методы $q — сервиса для создания своих deferred-объектов и promise-ов.


    Далее последовательно пройдемся по всем вариантам работы с promise-ами.

    Простейшее использование



        var responseData;
        $http.get('http://api/user').then(function(response){
            responseData = response.data;
        });
    
    


    Ничего интересного — callback и все. Но надо же от чего-то отталкиваться в изложении?.. :-)

    Возврат значения из обработчиков



        var User = function(data){
            return angular.extend(this, data);
        };
        $httpBackend.expectGET('http://api/user').respond(200, {
            result: {
                data: [{name: 'Artem'}],
                page: 1,
                total: 10
            }
        });
    
        var data = {};
        $http.get('http://api/user').then(function(response){
            var usersInfo = {};
    
            usersInfo.list = _.collect(response.data.result.data, function(u){ return new User(u); });
            usersInfo.total = response.data.result.total;
    
            return usersInfo;
        }).then(function(usersInfo){
            data.users = usersInfo;
        });
    


    Благодаря такой цепочке then-ов можно строить многослойное приложение. ApiWrapper сделал запрос, выполнил общие обработчики ошибок, отдал данные из ответа без изменений на следующий слой. Там данные преобразовали нужным образом и отдали на следующий. И т.д.

    Любое возвращаемое значение из then придет в success-callback следующего then. Не вернули ничего — в success-callback придет undefined (см. тесты).

    Чтобы сделать reject — необходимо вернуть $q.reject(value).
        $httpBackend.expectGET('http://api/user').respond(400, {error_code: 11});
    
        var error;
        $http.get('http://api/user').then(
            null,
            function(response){
                if (response.data && response.data.error_code == 10){
                    return {
                        list: [],
                        total: 0
                    };
                }
    
                return $q.reject(response.data ? response.data.error_code : null);
            }
        ).then(
            null,
            function(errorCode){
                error = errorCode;
            }
        );
    


    Цепочки вызовов


        $httpBackend.expectGET('http://api/user/10').respond(200, {id: 10, name: 'Artem', group_id: 1});
        $httpBackend.expectGET('http://api/group/1').respond(200, {id: 1, name: 'Some group'});
    
        var user;
        $http.get('http://api/user/10').then(function(response){
            user = response.data;
            return $http.get('http://api/group/' + user.group_id);
        }).then(function(response){
            user.group = response.data;
        });
    

    Это позволит избежать пирамидального кода, сделать код более линейным, а значит более читабельным и простым в поддержке.

    Параллельное выполнения запросов с ожиданием всех


    $q.all(...) принимает массив или словарь promise-объектов, объединяет их в один, который будет разрешен, когда разрешатся все promise, или отвергнут с ошибкой, когда хотя бы один promise будет отвергнут. При этом значения придут в success-callback либо в виде массива, либо в виде словаря в зависимости от того, как был вызван метод all.

        $httpBackend.expectGET('http://api/obj1').respond(200, {type: 'obj1'})
    
        var obj1, obj2;
        var request1 = $http.get('http://api/obj1');
        var request2 = $timeout(function(){ return {type: 'obj2'}; });
        $q.all([request1, request2]).then(function(values){
            obj1 = values[0].data;
            obj2 = values[1];
        });
    
        expect(obj1).toBeUndefined();
        expect(obj2).toBeUndefined();
    
        $httpBackend.flush();
        expect(obj1).toBeUndefined();
        expect(obj2).toBeUndefined();
    
        $timeout.flush();
        expect(obj1).toEqual({type: 'obj1'});
        expect(obj2).toEqual({type: 'obj2'});
    


        $q.all({
            obj1: $http.get('http://api/obj1'),
            obj2: $timeout(function(){ return {type: 'obj2'}; })
        }).then(function(values){
            obj1 = values.obj1.data;
            obj2 = values.obj2;
        });
    


    $q.when


    Обернет любой объект в promise-объект. Чаще всего необходимо для моков в юнит-тестах. Или когда есть цепочка запросов, один из которых нужно выполнять не всегда, а только в некоторых случаях, а в остальных случаях есть уже готовый объект.

        spyOn(UserApi, 'get').andReturn($q.when({id: 1, name: 'Artem'}));
    
        var res;
        UserApi.get(1).then(function(user){
            res = user;
        });
    
        $rootScope.$digest();
        expect(res).toEqual({id: 1, name: 'Artem'});
    


    Обратите внимание, что для разрешения promise-ов должен быть выполнен хотя бы один $digest цикл.

    Создание своих deferred-объектов


    $q-сервис также позволяет обернуть любую асинхронную операцию в свой deferred-объект с соответствующим ему promise-объектом.
        var postFile = function(name, file) {
            var deferred = $q.defer();
    
            var form = new FormData();
            form.append('file', file);
    
            var xhr = new XMLHttpRequest();
            xhr.open('POST', apiUrl + name, true);
            xhr.onload = function(e) {
                if (e.target.status == 200) {
                    deferred.resolve();
                } else {
                    deferred.reject(e.target.status);
                }
                if (!$rootScope.$$phase) $rootScope.$apply();
            };
            xhr.send(form);
    
            return deferred.promise;
        };
    


    Ключевые моменты здесь:
    • создание своего deferred объекта: var deferred = $q.defer()
    • его разрешение в нужный момент: deferred.resolve()
    • или reject в случае ошибки: deferred.reject(e.target.status)
    • возврат связанного promise-объекта: return deferred.promise


    Особенности AngularJS


    Во-первых, $q-сервис реализован с учетом dirty-checking в AngularJS, что дает более быстрые resolve и reject, убирая ненужные перерисовки браузером.
    Во-вторых, при интерполяции и вычислении angular-выражений promise-объекты интерпретируются как значение, полученное после resolve.
        $rootScope.resPromise = $timeout(function(){ return 10; });
    
        var res = $rootScope.$eval('resPromise + 2');
        expect(res).toBe(2);
    
        $timeout.flush();
        res = $rootScope.$eval('resPromise + 2');
    
        expect(res).toBe(12);
    


    Это означает, что если следить за изменением переменной через строковый watch, то в качестве значения будет приходить именно resolved-значение.
        var res;
        $rootScope.resPromise = $timeout(function(){ return 10; });
        $rootScope.$watch('resPromise', function(newVal){
            res = newVal;
        });
    
        expect(res).toBeUndefined();
    
        $timeout.flush();
        expect(res).toBe(10);
    

    Исключение составляет использование функции. Возвращаемое из нее значение будет использовано как есть, т.е. это будет именно promise-объект.
        $rootScope.resPromise = function(){
            return $timeout(function(){ return 10; });
        };
    
        var res = $rootScope.$eval('resPromise()');
    
        expect(typeof res.then).toBe('function');
    


    Понимание этого важно при написании различных loading-виджетов, loading-директив для кнопок и т.п.

    AngularJS 1.2.0 — что нового в promise-ах


    • catch(errorCallback) как укороченный синоним для promise.then(null, errorCallback);
    • для большей схожести с Q always(callback) переименован в finally(callback);
    • и самое существенное — поддержка нотификации в deferred- и promise-объектах, что удобно использовать для извещения о прогрессе и т.п. Теперь полная сигнатура promise.then выглядит как then(successCallback, errorCallback, progressCallback), а у deferred появился метод notify(progress).


    И напоследок скриншот выполненных тестов из WebStorm 7 EAP. Все же приятную добавили интеграцию с karma.
    Поделиться публикацией

    Комментарии 18

      +4
      Популярность AngularJS растет и фичи в нем тоже, и это не может не радовать)
        +2
        … скоро будет новый монстр типа dojo, только с MVVM.
      +1
      Прогресс это хорошо, что добавили. Можно это как-нибудь отменить запрос? Не нашел в доках ничего на эту тему.
        +3
        Отменить $http запрос? В 1.1.5 добавили параметр timeout, который может быть как числом, так и promise-ом, resolve которого приведет к отмене.
        +3
        было бы классно, если бы angular сделали модульным, и тот огромный файл можно было бы не подключать, как в jq можно выкинуть, что не надо при сборке.
          0
          А я еще думал, написать ли этот коммент или нет :) С 1.2 они как раз и начали увеличивать модульность: посмотрите на иерархию каталогов (там каждый каталог и есть модуль).
            0
            Супер) это очень радует.
          +2
          Очень странный рассказ. Совершенно непонятно, причём здесь вообще AngularJS.
          Промисы — очень интересная и многообещающая технология, применение которой, вообще говоря, далеко выходит за рамки описанного в статье.
          И уж тем более рассказ выглядит неполным, если не упомянуть о том, что промисы включены прямо в спецификацию HTML DOM Level 4:
          dom.spec.whatwg.org/#promises
          и в обозримом будущем появятся как часть стандарта ECMAScript.
            +2
            AngularJS тут при том, что в этой статье рассказывается, как promise-ы применяются в AngularJS и примеры все соответственно для AngularJS.
            Рассказ о многообещающей технологии, выходящей далеко за рамки описанного, действительно, не планировался. Что, конечно же, не мешает написать подобный рассказ Вам.
              0
              Действительно, не мешает. Быть может, и напишу.
            0
            Интерация Кармы в WebStorm порадовала, больше не прийдется шаманить с конфигом
              +1
              Статью не читал, но при беглом поиске не нашел ничего про промисы в шаблонах.

              В чем кайф:

              $scope.user = User.get(1) // ассинхронный метод возвращает промис (в angular-resource 1.2 так)
              

              Работаем с промисом как с обычным объектом:
              {{user.name}}
              


              Ангуляр сам дождется пока все промисы в шаблоне не будут выполнены, и только потом отрендерит его с результируюшими объектами. Красота и удобство.
                0
                Ну вобщем-то это и есть интерполяция и вычисление angular-выражений. Но может кому-то этого акцента и не хватит. Обратная сторона медали — с таким объектом неудобно в контроллере работать. При необходимости обращения к нему, надо будет получать значение через then.
                  0
                  Да, уже нашел. Все же стоило бы отдельно упомянуть:)
                  По поводу неудоства работы в контроллере: если получение данных асинхронно, то в любом случае в колбэке придется с ними работать.
                    0
                    Вы их можете сначала получить все, которые нужны, показывая какой-нить loading в процессе. А еще лучше через resolve маршрута — приедут уже готовые.
                      0
                      Через resolve — да, удобно очень.
                0
                Проблемка есть в новой версии. Если раньше $then передавался объект config из которого можно было узнать, например url запроса, то сейчас ничего подобного нет. Т.е. теперь из объекта ресурса url узнать никак не получится

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое