Вариант условного роутинга в AngularJS

    Я новичок в AngularJS, совсем недавно решил его использовать в своем хобби-проекте. Довольно быстро передо мной встала задача настроить роутинг по определенным условиям, самое простое и очевидное из таких условий — авторизован пользователь или нет. Приложение содержит страницы, открытые любому пользователю, и страницы, куда можно зайти только авторизовавшись. Если авторизации нет, нужно попробовать ее получить прозрачно для пользователя, если это не удалось — средиректить на страницу логина.

    На сколько я понимаю, это задача достаточно распространенная, тем не менее я не нашел простого готового способа сделать это из коробки. Потратив изрядное количество времени на гугление, чтение документации и эксперименты, я в итоге нашел достаточно элегантное решение для моего случая. Спешу поделиться своим велосипедом, надеюсь, это поможет сэкономить время таким же начинающим пользователям AngularJS. Возможно, найдется гуру, который укажет на то самое стандартное решение, которое я почему-то не нашел. Я, например, еще не разбирался с ui-router.

    Задача


    Стоит чуть подробнее написать про мою задачу. Имеется одностраничное веб-приложение (Single Page Application). Для простоты будем считать, что есть одна общедоступная «главная» страница по пути "/", то есть в корне сайта. На ней пользователь может пройти регистрацию или залогиниться. При успешном логине, приложение получает авторизационный токен и пользовательский профиль. Профиль представляет собой развесистую структуру данных и, для снижения нагрузки на сервер, я хочу загружать его один раз при логине, а не по частям на каждой странице. Авторизационный токен может храниться достаточно долго в локальном хранилище браузера, но данные профиля загружаются заново при каждом обновлении страницы. Получив токен один раз, я могу свободно ходить по всем закрытым страницам, перезагружать любую из них, добавлять в закладки и т.д. профиль при этом подгружается прозрачно для меня. Но если токен протухнет или я дам ссылку на закрытую страницу сайта другу, сайт должен отправить меня (или друга) на главную страницу.

    Поиски решения


    Единственное полезное, что выдал гугл на запрос «Angularjs conditional routing» — вот этот вопрос на stackoverflow. Там предлагалось два решения:

    Первое — посылать с сервера статус 401 и перехватывать его через сервис $http — предполагается запрос на сервер с каждой защищенной страницы. Может, кому-то подойдет, но не мне — я загружаю данные один раз и допиливать сервер ради роутинга на клиенте не хотелось бы.

    Второе — перехватывать сообщение $routeChangeStart, проверять, куда идем, есть ли авторизация и, если нет, редиректить. Как вариант — слушать изменения пути через $scope.$watch(function() { return $location.path(); }. Недостатки этого решения:

    1. В случае с $routeChangeStart объект next не предоставляет пути роутинга, понимать куда идем не очень удобно; сообщение это будет кидаться и на редиректах с несуществующих страниц, в итоге выражения в условиях роутинга будут не слишком красивые, завязаны на названия темплейтов, имена контроллеров и прочие странные вещи.
    2. Если нужно подгрузить данные, как в моем случае, нет способа задержать роутинг до конца загрузки. При этом роутинг может зависеть от самих данных в профиле пользователя — к примеру, у него новое супер-предложение и нужно все бросать и срочно идти на страницу этого предложения.

    У меня была мысль при отсутствующих данных редиректить на отдельную «страницу загрузки», там догружать данные и редиректить по результатам, но во-первых логика роутинга размазывается по двум местам — в одном смотрим путь, в другом данные; во-вторых у пользователя в истории останется эта промежуточная страница. Историю можно затирать с помощью $location.replace(), но если загрузка по каким-то причинам задержится и пользователь успеет нажать Назад, затрется не та страница, а его еще и средиректит уже с другой страницы, нужно как-то обрабатывать еще и этот случай, что простоты решению не добавляет. В-третьих нужно где-то запоминать, куда мы шли, чтобы правильно средиректить, с учетом ситуации из «во-вторых». Это решение меня не вдохновило и я продолжил поиски.

    Решение


    AngularJS предоставляет сервис со интересным названием $q. В документации можно прочитать почему q и спецификацию defered/promise — достаточно простая и интересная концепция. Вкратце, мы просим сервис изготовить специальный объект

    var defered = $q.defer();


    Из этого объекта получаем объект-promise и отдаем клиенту нашего кода

    return defered.promise;


    Клиент вешает на promise коллбэки успеха и неудачи операции

    promise.then(function (result) {...}, function (reason) {...});


    Теперь когда мы мы сделаем на нашем объекте

    defered.resolve(result);


    или

    defered.reject();


    у клиента вызовется соответствующий коллбэк. Чем это лучше обычных коллбэков? promise'ы можно объединять в цепочки (за подробностями — в документацию) и, что важно для моей задачи, многие сервисы AngularJS умеют работать с ними, в том числе в $routerProvider в конфигурации роута можно указать поле resolve и передать туда функцию, возвращающую promise. При этом если эта функция вернет объект, который не является promise, он будет интерпретирован как уже зарезолвленный promise. Роут будет ждать, пока promise не зарезолвится, а если произойдет reject, то и вовсе отменится. Дальше все просто — пишем функцию, которая загружает данные, если нужно, делает все проверки и редиректы. Если нужно подгрузить данные — возвращается promise, если нужно сделать редирект — перед ним promise реджектится, чтобы старый роут не ждал зря.

    Код решения:

    'use strict';
    
    var app = angular.module('app', [])
        .config(['$routeProvider', function($routeProvider) {
            $routeProvider
                .when('/', {
                    templateUrl: "login.html",
                    controller: LoginController
                })
                .when('/private', {
                    templateUrl: "private.html",
                    controller: PrivateController,
                    resolve: {
                        factory: checkRouting
                    }
                })
                .when('/private/anotherpage', {
                    templateUrl:"another-private.html",
                    controller: AnotherPriveController,
                    resolve: {
                        factory: checkRouting
                    }
                })
                .otherwise({ redirectTo: '/' });
        }]);
    
    var checkRouting= function ($q, $rootScope, $location) {
        if ($rootScope.userProfile) {
            return true;
        } else {
            var defered = $q.defer();
            $http.post("/loadUserProfile", { userToken: "blah" })
                .success(function (response) {
                    $rootScope.userProfile = response.userProfile;
                    defered.resolve(true);
                })
                .error(function () {
                    defered.reject();
                    $location.path("/");
                 });
            return defered.promise;
        }
    };
    


    В итоге получилось достаточно просто и прозрачно, даже странно почему я такого сразу не нашел в сети (теперь, надеюсь, найти будет проще). Из недостатков можно отметить то, что в каждом роуте нужно прописывать resolve, но c другой стороны это дает явность конфигурации и гибкость — можно написать еще пару таких же check* функций (если логика для разных страниц совершенно разная) и использовать, где нужно.

    UPDATE: Комментарии сподвигли меня на написание кода с передачей обещаний по цепочке от $http.post(), который, как и другие методы сервиса $http, возвращает обещание. При таком, натуральном, использовании обещаний получаем классное четкое разделение процесса по этапам и функции с ясным контрактом.

    Механизм цепочек обещаний такой: метод then обещания возвращает другое «производное» обещание, которое зарезолвится с тем значением, которое вернет один из обработчиков, указанных в then — резолва или реджекта — или зареджектится в случае, если один из обработчиков выбросит исключение. При этом если возвращаемое значение — само обещание, то его результат определит результат производного обещания. Так, чтобы зареджектить производное обещание, достаточно вернуть $q.reject().

    В итоге решение выглядит так:

    // Возвращает значение или обещание, определяющее результат resolve роутинга.
    var checkRouting = function ($q, $rootScope, $http, $location, localStorageService) {
        // Утилитная функция для редиректа
        function redirect(path) {
            if ($location.path() != path) {
                $location.path(path); // Делаем редирект.
                return $q.reject(); // Отменит старый роутинг.
            } else {
                return true; // Позволит роутингу случиться.
            }
        }
    
        return getUserDataPromise($q, $rootScope, $http, localStorageService)
            .then(function (userData) {
                // Здесь в единственном месте проверяем данные пользователя.
                if (userData.sales.lenght > 0) {
                    return redirect("/sales"); // Пока есть распродажа, надо идти туда!
                } else {
                    return true; // Иначе идем, куда шли.
                }
            }, function (reason) {
                // Здесь в единственном месте обрабатываем ошибки.
                console.error(reason); // Хотя бы просто логируем ; )
                return redirect("/");
            });
    };
    
    // Вернет обещание, которое либо зарезолвится с готовым userData, либо зареджектится.
    var getUserDataPromise = function ($q, $rootScope, $http, localStorageService) {
        if ($rootScope.userData) {
            return $q.when($rootScope.userData); // Оборачиваем значение в (уже зарезолвленное) обещание.
        } else {
            var userToken = localStorageService.get("userToken");
            if (!userToken) {
                return $q.reject("No user token."); // Следующее обещание в цепочке зареджектится.
            } else {
                // Возвращаем обещание, которое либо заролвится с данными пользователя с сервера,
                // либо зареджектится, если не удалось их загрузить.
                return $http.post("/loadUserData", { userToken: userToken })
                    .then(function (result) {
                        if (result.data.userData) {
                            $rootScope.userData = result.data.userData;
                            return result.data.userData;
                        } else {
                            return $q.reject("Got response from server but without user data.");
                        }
                    }, function (reason) {
                        return $q.reject("Error requesting server: " + reason);
                    });
            }
        }
    };
    
    
    Share post

    Comments 18

      +1
      Хорошее решение, лучше использования resolve ничего и не придумать.
      Разве что можно обойтись без создания дополнительного обещания, а воспользоваться уже тем, которое возвращает $http:
          var checkRouting= function ($q, $rootScope, $location) {
              return !!$rootScope.userProfile || $http.post("/loadUserProfile", { userToken: "blah" })
                  .success(function (response) {
                       $rootScope.userProfile = response.userProfile;
                  })
                  .error(function () {
                      $location.path("/");
                  });
          };
      
        0
        Спасибо за замечание про обещание из $http. Действительно, если не нужно ничего, кроме загрузки, можно сократить до такого кода. Код в статье предполагает, что возможно нужно посмотреть внутрь данных и на их основе сделать дополнительный редирект, тогда нужен отдельный defered/promise.
          0
          success/error обработчики по-идее так же являются deffered объектами, так что они так же могут вернуть какое-то значение, которое в итоге будет заресолвлено. Если же нужно будет зареджектить что-то, то можно просто вернуть $q.reject(). То бишь создавать отдельный promise при любом раскладе не обязательно.
            +1
            был не совсем прав… лучше так:
            var checkRouting= function ($q, $rootScope, $location) {
            
                    return !!$rootScope.userProfile 
                           || $http.post("/loadUserProfile", { userToken: "blah" })
                                    .error(function () {
                                         $location.path('/');
                                    })
                                    .then(function (response) {
                                            $rootScope.userProfile = response.data.userProfile;
                                            // тут могла быть ваша пост-обработка данных
                                            // если тут нужно зареджектить, то можно вернуть $q.reject()
                            
                                            return true;
                                    });
                };
            
              0
              Тут еще нужно учесть условия на основе самих данных. Написал в апдейте полное решение на цепочках обещаний.
        +7
        ээээ «раутинг»?
          0
          Ну это как с «браузер» вместо «броузер». Хотя согласен, раутинг звучит странно.
          Все же русcкоговорящие программисты больше называют это «роутинг».
            0
            браузер вполне, раутинг ну прям совсем… хотя блин придираюсь, чувак статью написал в конце концов)
              0
              по-русски, вообще, маршрутизация))
              0
              Этот комментарий собрал плюсов столько же, сколько сама статья: ) Поменял на роутинг.
              0
              Конкретно для поставленной задачи решение отличное, но с ui-router советую разобраться, он прекрасен.
                0
                Точно. В ui-router отменить переход можно очень просто:
                $scope.$on('$stateChangeStart', function(event, toState, toParams, fromState, fromParams){ 
                    if(toState.name==='...' && notAuthorized){
                        event.preventDefault();
                    }
                })
                
                  0
                  Это удобнее, чем с $routeChangeStart (есть имя стейта), но по сути тот же подход. Как с ui-router заставить переключение стейта ждать, пока не подгрузятся данные с сервера?
                    0
                    Самый простой способ, который сразу приходит в голову, это сделать что-то типа такого:
                    var process = false;
                    var auth = function(stateName){
                        $http.get('...').success(
                            function(data){
                                process=true;
                               $state.go(stateName);
                        });
                    };
                    $scope.$on('$stateChangeStart', function(event, toState, toParams, fromState, fromParams){ 
                        if(process){
                             process=false;
                        }else{
                            auth(toState.name);
                            event.preventDefault();
                       }
                    })
                    

                    Но скорее всего есть и более элегантные подходы.
                        0
                        Не смотрел resolve стандартного роутера, но в ui-router'е он не работает с вложенными зависимости. Т.е., если в качестве обещания использовать свой сервис, который зависит, например, от $resource, то работать не будет(
                          0
                          А отрезолвить зависимости сервиса в стейте (перед тем, что с промисом на свой сервис) не помогает?
                            0
                            Неа, все варианты перепробовал и через инжектор и так…

                Only users with full accounts can post comments. Log in, please.