AngularJs. Отложенная загрузка модулей

    AngularJs – великолепный фреймворк для разработки web-приложений. Разработка бизнес-логики приложения полностью отделена от сопутствующей суеты вокруг DOM. Angular модульный – это замечательно, но так же является источником проблемы. Количество модулей быстро растёт. И если директивы ещё можно упаковывать в отдельные пакеты типа angular-ui, то с контроллёрами бизнес-логики всё сложнее. Всё становится ещё хуже, когда требования безопасности в принципе запрещают загрузку на клиента контроллёров с бизнес-логикой, которые недоступны текущему пользователю. При развитой ролевой системе доступа к приложению масштаб проблемы становится очевиден.

    В Angular в принципе отсутствует система загрузки модулей по требованию. Но тем не менее можно самостоятельно разработать такой модуль, который будет подгружать javascript-файл. Но есть проблема. При вызове функции angular.module c которой начинается любой модуль Angular, не приводит к добавлению функционала во внутренние структуры Angular. И сделано это намеренно, чтобы можно было указывать тэги script в произвольном порядке не соблюдая зависимостей между модулями. Окончательная загрузка модулей будет произведена после того как html-документ будет полностью загружен. Собственно этим и занимается функция angular.bootstrap, которая создаёт экземпляр injector`а и инициализирует внутренние структуры фреймворка.

    Итак, возникает задача:
    1. Обеспечить загрузку модулей с помощью директивы. Это даст возможность загружать модуль именно тогда, когда он действительно необходим.
    2. Обеспечить разрешение зависимостей. Т.е. если модуль имеет зависимости, то проверить все ли они удовлетворены. И если нет – то инициировать процедуру загрузки модулей, удовлетворяющих зависимость.
    3. Директива так же должна обеспечивать загрузку указанного шаблона, поскольку директивы в шаблоне могут иметь зависимость от загружаемого модуля (например, указание контроллёра) и модуль должен быть загружен раньше, а только потом применён шаблон.
    4. Ну и, естественно, компиляция и линковка загруженного шаблона.

    Приступим.
    Пример директивы, появление которой в коде будет инициировать загрузку модуля home:
    <div load-on-demand="'home'"></div>
    

    Помимо самой директивы load-on-demand, имеется имя загружаемого модуля. Такой вариант выбран для большей гибкости в конфигурировании загружаемых модулей. Конфигурирование обычно производится с помощью вызова функции module.config.
    Пример вызова функции:
    var app = angular.module('app', ['loadOnDemand']);
    app.config(['$loadOnDemandProvider', function ($loadOnDemandProvider) {
        var modules = [
            {
                name: 'home',
                script: 'js/home.js'
            }
        ];
        $loadOnDemandProvider.config(modules, []);
    }]);
    


    Теперь перейдём непосредственно к директиве. В нашем случае нам не требуется тонко настраивать директиву, поэтому мы возвращаем только функцию связывания (linkFunction), которая делает всё необходимое. Псевдо-код, который демонстрирует алгоритм:
    var aModule = angular.module('loadOnDemand', []);
    aModule.directive('loadOnDemand', ['$loadOnDemand', '$compile',
            function ($loadOnDemand, $compile) {
                return {
                    link: function (scope, element, attr) {
                        var moduleName = scope.$eval(attr.loadOnDemand); // Имя модуля
                        // Получаем конфигурационную информацию о модуле
                        var moduleConfig = $loadOnDemand.getConfig(moduleName); 
                        $loadOnDemand.load(moduleName, function() { // Загружаем скрипт
                            loadTemplate(moduleConfig.template, function(template) { // Загружаем шаблон
                                 childScope = scope.$new(); // Создаём область видимости для контроллёра
                                 element.html(template); // Вставляем сырой html в DOM
                                 var content = element.contents(),
                                 linkFn = $compile(content); // Преобразуем DOM-узел в шаблон angular
                                 linkFn(childScope); // Связываем шаблон и scope
                             });
                        });
                    }
                };
            }]);
    


    Ключевым моментом здесь является вызов функции $loadOnDemand.load(). Весь функционал по конфигурированию и загрузки скрипта находится в провайдере $loadOnDemand. Раскроем его. Я намеренно скрываю детали реализации, чтобы не захламлять код.

    aModule.provider('$loadOnDemand', function(){
        this.$get = [function(){ // Обязательная для провайдера функция, которая будет возвращать сервис
            return {
                getConfig: function (name) {}, // Получение конфигурации для загрузки модуля
                load: function (name, callback) {} // Загрузка модуля
            };
        }];
        this.config = function (config, registeredModules) {} // Функция конфигурирования провайдера
    });
    


    Каждый провайдер должен предоставить функцию $get, которая должна возвращать объект-сервис. Этот сервис будет использоваться инектором, когда он потребуется. Помимо функции $get наш привайдер предоставляет функцию config — она используется для конфигурирования загрузчика модулей (app.config выше). Дело в том, что функция module.config предоставляет только провайдеров, поэтому необходимо разделить логику конфигурирования провайдера от предоставляемого им сервиса.
    Сам сервис имеет две функции: getConfig — используется для простоты получения конфигурационного объекта и, собственно, главная функция сервиса — load, которая загружает модуль. Низкоуровневая загрузка скрипта выполняется с помощью document.createScript — такая загрузка более дружественна для IDE отладчика.

    И воде бы — это и всё, что нужно сделать. Но, это не будет работать. Причина указана выше — после того как скрипт будет загружен и выполнен, функционал модуля не будет размещён в инфраструктуре angular. Итак, погружаемся в angular.bootstrap.

    После того как DOM загружен, запускается процедура инициализации angular. Она ищет директиву ng-app с именем главного модуля приложения. После этого создаётся инектор и выполняется компиляция DOM в шаблон angular`а. В этой цепочке нас больше всего интересует создание инектора, поскольку именно этот вызов запускает процедуру загрузки модулей — функцию loadModules. loadModules получает объект Module в котором имеется очередь команд для инектора — _invokeQueue. Эта очередь как раз и создаётся при вызове angular.module. Каждый элемент этой очереди отдаётся соответствующему провайдеру, который делает всю работу по добавлению функционала.
    Нам необходимо просто повторить этот алгоритм, используя уже существующие провайдеры. Их мы получаем используя инектор.
    aModule.provider('$loadOnDemand',
            ['$controllerProvider', '$provide', '$compileProvider', '$filterProvider',
                function ($controllerProvider, $provide, $compileProvider, $filterProvider) {
                    . . .
                    loadScript(moduleName, function(){
                        register(moduleName);
                    });
                    . . .
                }]);
    

    Функция регистрации модуля register.
    moduleFn = angular.module(moduleName);
    for (invokeQueue = moduleFn._invokeQueue, i = 0, ii = invokeQueue.length; i < ii; i++) {
      invokeArgs = invokeQueue[i];
      provider = providers[invokeArgs[0]]; 
      provider[invokeArgs[1]].apply(provider, invokeArgs[2]);
    }
    


    В invokeArgs[0] находится имя провайдера, invokeArgs[1] — его метод регистрации нового сервиса. invokeArgs[2] — параметры, которые передаются методу регистрации (список инекций и функция-конструктор сервиса).

    Вот, пожалуй и всё, остаётся только загрузить зависимости, которые находятся в moduleFn.requires в виде простого массива имён модулей. После подключения подобного модуля к вашему проекту главная страница будет выглядеть как-то так:
    <!DOCTYPE html>
    <html ng-app="app">
    <head>
    </head>
    <body>
        <div ng-view></div>
    
        <script src="js/angular.js"></script>    
        <script src="js/loadOnDemand.js"></script>
    </body>
    </html>
    

    А главный модуль приложения, как-то так:
    (function(){
        var app = angular.module('app', ['loadOnDemand']);
        app.config(['$routeProvider', function ($routeProvider) {
        . . .
        };
        app.config(['$loadOnDemandProvider', function ($loadOnDemandProvider) {
        . . .
        };
    })();
    


    Проект лежит на github с демонстрационным примером
    Метки:
    Поделиться публикацией
    Реклама помогает поддерживать и развивать наши сервисы

    Подробнее
    Реклама
    Комментарии 22
    • 0
      Есть прикладной смысл загрузки модуля по требованию?

      Мне казалось логичнее наоборот при бутстрапе ангулара грузить все, включая темплэейты, кэшировать их и дальше ходить к серверу уже за данными/сохранением данных. так гораздо проще можно обрабатывать ошибки + сделать полуофлайновое приложение.
      • 0
        Смысл обозначен в начале статьи. Представьте, что у Вас приложение с сотней-другой модулей. Каждый модуль реализует функционал для одной из групп пользователей приложения. Может так получиться, что конкретному пользователю, из всей сотни модулей для работы необходим только десяток. Остальные 90 он просто не имеет право использовать.
        • 0
          ну так может логичнее на момент бутстрапа уже знать что ему можно, а что нет, и грузить нужное?

          Просто довольно частый кейс: работаем, потом бах, на минуту пропало соединение, если все загружено, мы покажем пользователю сообщение что соединения нет, но все его данные будут сохранены и отправлены на сервер после восстановления соединения. (см. гмайл например). пока связи нет работаем с локальным сторэджем. как только появилась — отсылаем все на сервер.

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

          у нас например есть пользователи, которые с ноутбуком заходят в глубину помещений и там связи нет ( бц, офисное здание, склад) а работать им там надо
          • +1
            У любой технологии есть свои плюсы и минусы. Всегда приходится искать какой-то компромисс. Для Вашего сценария (с нестабильной связью) возможно, что полная загрузка в начале имеет смысл. Но тем не менее это не повод для фундаментального ограничения в других сценариях. К тому же функцией конфигурации модуля предусмотрено указание модулей, которые были загружены при bootstrap`е. Таким образом при первоначальной загрузке можно указать некоторый «ядерный» функционал, который будет доступен сразу после загрузки приложения.
            • 0
              Да, конечно. мне просто хотелось ваш случай понять. спасибо за объяснения.
              • 0
                Добавил прозрачную поддержку загруженных angular`ом модулей в штатном порядке. Теперь не нужно ничего указывать при конфигурировании. Все модули которые были загружены процедурой bootstrap автоматически учитываются загрузчиком.
            • +1
              особенно если учесть, что для приложения с сотней модулей, которым часто пользуются пользователи, фактически загрузка будет только 1 раз. потом все из кеша будет браться.
              • 0
                Что значит «не имеет право использовать»?
                • 0
                  Это значит что данные предоставляемые модулем предназначены для определённой группы пользователей, и эти данные не должны попасть в другую группу. Простейший пример: директор-сотрудник. Наличие всех модулей на клиенте — это потенциальная бреш в безопасности.
                  • 0
                    1) Брешь в безопасности это предоставить данные, а не «неявную» функциональность.
                    2) Что клиенту не даёт возможности прописать напрямую в url путь к супер-секретному скрипту.
                    • 0
                      Хочу обратить внимание, что целью статьи было показать решение для отложенной загрузки для angularjs, а никак не единственно верный механизм для обеспечение безопасности приложения.
                      А что касается безопасности, то конечно глупо надеяться обеспечить безопасность только средствами клиента. Но как по мне, так я не хочу оставлять ключ под ковриком, даже если он от второй двери.
                      • 0
                        Я ничего и не говорю про безопасность, я пытаюсь понять зачем нужно это решение.
                        • 0
                          По крайней мере мне, так проще конфигурировать функционал приложения. На клиента передаётся меню, которое формируется в зависимости от роли пользователя приложения. Дальше уже работает отложенная загрузка, которая подтянет необходимый модуль как только его функционал будет востребован. Я это делаю следующим образом:
                          где-то в html
                             <a href="#!/feature">cool feature</a>
                          

                          код:
                          app.config(['$routeProvider', function ($routeProvider) {
                              $routeProvider.
                                  when('/feature', { template:  '<div load-on-demand="\'feature\'"></div>' });
                          } ]);
                          app.config(['$loadOnDemandProvider', function ($loadOnDemandProvider) {
                              var modules = [{
                                  name: 'feature',
                                  script: 'js/feature.js',
                                  template: 'template/feature.html'
                              }];
                              $loadOnDemandProvider.config(modules, []);
                          } ]);
                          


                          На клиента не тянется ничего лишнего при старте — уже хорошо. Упрощается отладка — тоже неплохо. Применение находит в достаточно больших проектах, где весь функционал грузить при старте не только накладно, но и не за чем.
                          • 0
                            >> Упрощается отладка — тоже неплохо.
                            Чем?

                            >> На клиента не тянется ничего лишнего при старте — уже хорошо.
                            1) Всё и так идёт в кэш.
                            2) Я уверен что если собрать в единый файл и сжать через google closure — размер всего приложения будет в 2 раза меньше чем Вы подгружаете «то что нужно».
                            3) В Вашем примере при клике на ссылку дополнительно нужно ждать подгрузку модуля и регистрации сервисов.
                            4) Если у меня в момент клика на ссылку залагал интернет — как я узнаю что что-то пошло не так?
                            • 0
                              Не забывайте про то, что браузеру нужно не только загрузить JS-код, но и «скомпилировать» его — на это тоже уходит некоторое время при ощутимом количестве кода.
                              К тому же, говоря про кеш (браузера?) вы вероятно забываете, что есть пользователи, которые зайдут на ваш сайт всего один раз, и не дождавшись загрузки более никогда на него не вернутся. Сейчас, при бурном развитии мобильного интернета, этот сценарий становится всё более вероятным.
                              • 0
                                Простите, у Вас модулей на 100МБ или что?
                                • 0
                                  «Или что» — на реальных скоростях 3G даже несколько мегабайт будут грузиться непозволительно долго. Говоря за себя, я обычно закрываю сайт, который не осилил загрузиться примерно за 5 секунд.
                                  Ну и на крупных городах свет клином не сошёлся, скорость интернета в глубинке часто бывает весьма низкой.
                                  • 0
                                    Зато мы явно видим что страница грузится.
                                    А теперь представьте что за каша происходит с подгрузкой модулей.

                                    п1 Подгрузили приложение.
                                    п2 Подгрузили модуль.
                                    п3 Инициализируем провайдер.
                                    п4 Если у провайдера есть неинициализированные зависимости, переходим к п2.
                                    п5 Допустим приложение требует A сервис, инициализируем его, и если у него есть неинициализированные зависимости опять переходим к п2.

                                    Вы не думайте что загрузить файл в 1мб будет медленней чем подгрузить 5 модулей по 10кб.
                                    • 0
                                      Реально разница только в ОЗУ.
                              • 0
                                Насчёт 4-го пункта — спасибо. Добавлю в вызов функции $loadOnDemandProvider.config функцию, которая будет вызываться при ошибках загрузки. Или подумаю, как это сделать иначе. А то на данный момент всё в $log пишется.
              • 0
                Ну как всегда же — тяжелые виджеты, которыми пользователь не часто пользуется бессмысленно загружать сразу же
                • 0
                  Слава богу у меня не такое большое приложение. Обхожусь объединением на стороне сервера в один файл нужного и отдаче пользователю. После ExtJS конечно было не привычно, даже пробовал готовый вариант lazy loading, но с отладкой у меня не заладилось(просто отсутствие нужных скриптов в списке загруженных, хром.) и в итоге пришёл к такому методу, потом можно будет и минифицировать на ходу.

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