Загрузка модуля по требованию в AngularJS

в 8:01, , рубрики: AngularJS, front-end, front-end разработка, javascript

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

Неужели AngularJS не поддерживает отложенную загрузку в каким либо способом?

AngularJS является одним из лучших шаблонов для front end разработки, но он все еще молод, и не имеет нескольких важных возможностей (кто сказал хорошего маршрутизатора?).
В то время как большинство из этих возможностей может быть добавлено в виде модулей, которые можно найти в google или на специализированных веб сайтах, есть некоторые функции, которые не получится добавить таким способом.
В настоящее время многим требуется асинхронная загрузка модулей, и кажется Google собирается реализовать ее во второй версии фремворка, но кто знает, когда это будет…

Я ищу способ сделать это сейчас, потому что я хочу оптимизировать мое приложение и ускорить время его загрузки.
Я нашел две очень интересные статьи (на английском): отложенная загрузка в AngularJS и загрузка компонентов AngularJS после старта приложения с помощью RequireJS.
Но в них обоих, объясняется отложенная загрузка контроллеров, сервисов, фильтров, директив, но не отложенная загрузка модулей.
Я решил изучить исходный код angular, посвященный загрузке модулей (вы можете увидеть его здесь), и я заметил, что регистрация модулей происходит после инициализации, но новые загруженные модули и их зависимости просто не подключаются к приложению и не инициализируются.

Подождите, а разве нельзя сделать это самим?

Ну да, конечно это можно сделать! Все что нам потребуется сделать, это убедиться в том, что добавляемый нами модуль не был ранее добавлен в приложение, так как у нас нет желания переписать уже существующий код. Представьте себе, вам требуется сервис, который уже был загружен и настроен ранее, он очень быстро перестанет работать как нужно, если его переписать.
Итак, нам нужен список ранее загруженных модулей, который должно быть просто найти, не так ли?
Ну… да… на самом деле… нет.
Если вы заглянете в исходный код, то найдете там внутреннюю переменную с именем modules. Эта переменная используется для хранения списка всех загруженных модулей, и она не доступна из вне.

Поэтому мы не сможем получить список модулей?

Нет, не сможем. Но мы сможем воссоздать его заново.
Мы можем использовать angular.module('moduleName') в любой момент чтобы получить существующий модуль. Если вы выведите его результат в лог, то заметите свойство: _invokeQueue. Это и есть список его зависимостей.
Поскольку модули могут быть загружены только на старте и приложение может быть запущенно только с помощью директивы ng-app, если вы сможете найти модуль приложения, то вы сможете получить весь список загруженных модулей и их зависимостей.
Примечание переводчика: на самом деле приложение можно стартовать и без директивы ng-app, но в данном случае это не важно.
Сделать это можно с помощью следующего кода:

function init(element) {  
    var elements = [element],
        appElement,
        module,
        names = ['ng:app', 'ng-app', 'x-ng-app', 'data-ng-app'],
        NG_APP_CLASS_REGEXP = /sng[:-]app(:s*([wd_]+);?)?s/;

    function append(elm) {
        return (elm && elements.push(elm));
    }

    angular.forEach(names, function(name) {
        names[name] = true;
        append(document.getElementById(name));
        name = name.replace(':', '\:');
        if(element.querySelectorAll) {
            angular.forEach(element.querySelectorAll('.' + name), append);
            angular.forEach(element.querySelectorAll('.' + name + '\:'), append);
            angular.forEach(element.querySelectorAll('[' + name + ']'), append);
        }
    });

    angular.forEach(elements, function(elm) {
        if(!appElement) {
            var className = ' ' + element.className + ' ';
            var match = NG_APP_CLASS_REGEXP.exec(className);
            if(match) {
                appElement = elm;
                module = (match[2] || '').replace(/s+/g, ',');
            } else {
                angular.forEach(elm.attributes, function(attr) {
                    if(!appElement && names[attr.name]) {
                        appElement = elm;
                        module = attr.value;
                    }
                });
            }
        }
    });

    if(appElement) {
        (function addReg(module) {
            if(regModules.indexOf(module) === -1) {
                regModules.push(module);
                var mainModule = angular.module(module);
                angular.forEach(mainModule.requires, addReg);
            }
        })(module);
    }
}

Регистрация новых модулей

Сейчас, когда у нас есть список ранее загруженных модулей, мы можем добавить новые модули (только те, что не были загружены ранее).
Чтобы это сделать, нам нужно понимать как модули и зависимости вызываются в angular.
Как только модуль будет зарегистрирован, загружаются все его зависимости (в фазе «инициализации»), которые в результате инициализации добавляются в модуль. Если любая зависимость уже существует в памяти, то фаза инициализации будет пропущена, и в модуль будет добавлена ссылка на существующую зависимость.
Модуль может проходить через фазы конфигурирования и выполнения.
Фаза конфигурирования выполняется перед загрузкой зависимостей.
Фаза выполнения начинается только после фазы конфигурирования и полной загрузки всех требуемых зависимостей. Вы можете посмотреть код фазы выполнения в параметре _runBlocks.
Чтобы запустить фазу выполнения мы будем использовать функцию invoke сервиса $injector.
Итак, в итоге мы сделаем список всех зависимостей, будем отслеживать фазы конфигурации и выполнения, и вызывать их в правильном порядке.
На самом деле мы воспроизведем способ, которым angular загружает свои модули, который вы можете изучить по исходным кодам.
Результат показан в следующей функции:

function register(providers, registerModules, $log) {  
    var i, ii, k, invokeQueue, moduleName, moduleFn, invokeArgs, provider;
    if(registerModules) {
        var runBlocks = [];
        for(k = registerModules.length - 1; k >= 0; k--) {
            moduleName = registerModules[k];
            regModules.push(moduleName);
            moduleFn = angular.module(moduleName);
            runBlocks = runBlocks.concat(moduleFn._runBlocks);
            try {
                for(invokeQueue = moduleFn._invokeQueue, i = 0, ii = invokeQueue.length; i < ii; i++) {
                    invokeArgs = invokeQueue[i];

                    if(providers.hasOwnProperty(invokeArgs[0])) {
                        provider = providers[invokeArgs[0]];
                    } else {
                        return $log.error("unsupported provider " + invokeArgs[0]);
                    }
                    provider[invokeArgs[1]].apply(provider, invokeArgs[2]);
                }
            } catch(e) {
                if(e.message) {
                    e.message += ' from ' + moduleName;
                }
                $log.error(e.message);
                throw e;
            }
            registerModules.pop();
        }
        angular.forEach(runBlocks, function(fn) {
            providers.$injector.invoke(fn);
        });
    }
    return null;
}

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

Давайте писать наш сервис

Каждый провайдер и инжектор доступны в фазах конфигурации и инициализации. Нам нужно сохранить на них ссылку, если мы хотим использовать их позже.
Нам также нужно отслеживать загруженные модули, с целью предотвращения их повторной загрузки.
Я не хочу писать собственный асинхронный загрузчик, так как их достаточно много присутствует на рынке (requireJS, script.js...) и было бы глупо, изобретать собственный велосипед, так что вам придется с этим определяться самостоятельно используя конфигурацию.
Можно использовать любой загрузчик, который поддерживает следующий синтаксис:

loader([urls], function callback() {}); 

Этим способом можно подгружать любые ресурсы, которые вам требуются (только js файлы, css и т.д.).
В этом примере я буду использовать script.js.

var modules = {},  
    asyncLoader,
    providers = {
        $controllerProvider: $controllerProvider,
        $compileProvider: $compileProvider,
        $filterProvider: $filterProvider,
        $provide: $provide, // other things
        $injector: $injector
    };

Это достаточно легкая часть конфигурации, нам нужно определить асинхронный загрузчик и список модулей, который мы могли бы подгружать с помощью директивы.
Мы также запускаем скрипт инициализации, который был определен ранее, чтобы заполнить список модулей для первоначальной инициализации.

this.config = function(config) {  
    if(typeof config.asyncLoader === 'undefined') {
        throw('You need to define an async loader such as requireJS or script.js');
    }

    asyncLoader = config.asyncLoader;
    init(angular.element(window.document));

    if(typeof config.modules !== 'undefined') {
        if(angular.isArray(config.modules)) {
            angular.forEach(config.modules, function(moduleConfig) {
                modules[moduleConfig.name] = moduleConfig;
            });
        } else {
            modules[config.modules.name] = config.modules;
        }
    }
};

Чтобы настроить провайдера, просто добавьте этот код в ваше приложение:

angular.module('app').config(['$ocLazyLoadProvider', function($ocLazyLoadProvider) {  
    $ocLazyLoadProvider.config({
        modules: [
            {
                name: 'TestModule',
                files: ['js/testModule.js'],
                template: 'partials/testLazyLoad.html'
            }
        ],
        asyncLoader: $script
    });
}]);

Так как мы пишем провайдера, единственное место где мы может использовать инъекцию компонентов, это свойство $get. То что будет возвращать этим свойством, и будет доступно в вашем сервисе.
Мы определим геттеры и сеттеры для конфигурации модулей и функцию для их загрузки.
Мы также добавим геттер для списка модулей, так как он должен быть доступен, если кто-либо захочет написать плагин.
Геттеры и сеттеры определяются легко:

getModuleConfig: function(name) {  
    if(!modules[name]) {
        return null;
    }
    return modules[name];
},

setModuleConfig: function(module) {  
    modules[module.name] = module;
    return module;
},

getModules: function() {  
    return regModules;
}

Теперь давайте внимательно посмотрим на функцию загрузки. Нам нужно реализовать загрузку модулей по имени или по конфигурации.
Объект конфигурации содержит имя модуля, список файлов (скрипты, css...) и не обязательный шаблон.
Если мы будем загружать модуль с помощью директивы, шаблон будет использоваться для замены ее кода.
Мы также будем поддерживать список зависимостей модуля, чтобы их можно было зарегистрировать.
Функция загрузки будет возвращать promise, что упростит дальнейшую разработку.

load: function(name, callback) {  
    var self = this,
        config,
        moduleCache = [],
        deferred = $q.defer();

    if(typeof name === 'string') {
        config = self.getModuleConfig(name);
    } else if(typeof name === 'object' && typeof name.name !== 'undefined') {
        config = self.setModuleConfig(name);
        name = name.name;
    }

    moduleCache.push = function(value) {
        if(this.indexOf(value) === -1) {
            Array.prototype.push.apply(this, arguments);
        }
    };

    if(!config) {
        var errorText = 'Module "' + name + '" not configured';
        $log.error(errorText);
        throw errorText;
    }
}

Нам нужна функция для получения зависимостей модуля:

function getRequires(module) {  
    var requires = [];
    angular.forEach(module.requires, function(requireModule) {
        if(regModules.indexOf(requireModule) === -1) {
            requires.push(requireModule);
        }
    });
    return requires;
}

Так же нам требуется функция для проверки, был ли ранее загружен определенный модуль, в случае если мы чего пропустили с момента инициализации и до настоящего момента. Нет «чистого» способа сделать это, поэтому придется использовать «грязный»:

function moduleExists(moduleName) {  
    try {
        angular.module(moduleName);
    } catch(e) {
        if(/No module/.test(e) || (e.message.indexOf('$injector:nomod') > -1)) {
            return false;
        }
    }
    return true;
}

Сейчас мы можем написать функцию, которая будет подгружать зависимости нового модуля. Она будет немедленно возвращать управление, если модуль был загружен ранее, или заполнять переменную moduleCache, чтобы получить список новых модулей и их зависимостей для регистрации.

function loadDependencies(moduleName, allDependencyLoad) {  
    if(regModules.indexOf(moduleName) > -1) {
        return allDependencyLoad();
    }

    var loadedModule = angular.module(moduleName),
        requires = getRequires(loadedModule);

    function onModuleLoad(moduleLoaded) {
        if(moduleLoaded) {

            var index = requires.indexOf(moduleLoaded);
            if(index > -1) {
                requires.splice(index, 1);
            }
        }
        if(requires.length === 0) {
            $timeout(function() {
                allDependencyLoad(moduleName);
            });
        }
    }

    var requireNeeded = getRequires(loadedModule);
    angular.forEach(requireNeeded, function(requireModule) {
        moduleCache.push(requireModule);

        if(moduleExists(requireModule)) {
            return onModuleLoad(requireModule);
        }

        var requireModuleConfig = self.getConfig(requireModule);
        if(requireModuleConfig && (typeof requireModuleConfig.files !== 'undefined')) {
            asyncLoader(requireModuleConfig.files, function() {
                loadDependencies(requireModule, function requireModuleLoaded(name) {
                    onModuleLoad(name);
                });
            });
        } else {
            $log.warn('module "' + requireModule + "' not loaded and not configured");
            onModuleLoad(requireModule);
        }
        return null;
    });

    if(requireNeeded.length === 0) {
        onModuleLoad();
    }
    return null;
}

В конце нам нужно вызвать асинхронный загрузчик, который подгрузит зависимости и зарегистрирует их.

asyncLoader(config.files, function() {  
    moduleCache.push(name);
    loadDependencies(name, function() {
        register(providers, moduleCache, $log);
        $timeout(function() {
            deferred.resolve(config);
        });
    });
});

Мы сделали это, теперь можно подгружать модули по требованию!!!

$ocLazyLoad.load({
    name: 'TestModule',
    files: ['js/testModule.js']
}).then(function() {
    console.log('done!');
});

Использование директивы

Мы должны иметь возможность подгружать модуль через директиву. Чтобы сделать это, мы будем использовать параметр template, который мы упоминали ранее. Этот шаблон будет замещать собой директиву.
Мы будем использовать сервис $templateCache, чтобы предотвратить загрузку шаблонов, уже существующих в кэше нашего приложения.
Директива будет вызываться следующим способом:

<div oc-lazy-load="{name: 'TestModule', files: ['js/testModule.js'], template: 'partials/testLazyLoad.html'}"></div>  

Если мы определили конфигурацию модуля TestModule в настройках провайдера, мы можем вызвать нашу директиву следующим образом:

<div oc-lazy-load="'TestModule'"></div>

Написание директивы не цель данной статьи, так что я пропущу ее описание. Важной частью директивы является загрузка нового шаблона по его url, или из кэша, если он был загружен ранее:

ocLazyLoad.directive('ocLazyLoad', ['$http', '$log', '$ocLazyLoad', '$compile', '$timeout', '$templateCache',  
    function($http, $log, $ocLazyLoad, $compile, $timeout, $templateCache) {
        return {
            link: function(scope, element, attr) {
                var childScope;

                /**
                 * Destroy the current scope of this element and empty the html
                 */
                function clearContent() {
                    if(childScope) {
                        childScope.$destroy();
                        childScope = null;
                    }
                    element.html('');
                }

                /**
                 * Load a template from cache or url
                 * @param url
                 * @param callback
                 */
                function loadTemplate(url, callback) {
                    scope.$apply(function() {
                        var view;

                        if(typeof(view = $templateCache.get(url)) !== 'undefined') {
                            scope.$evalAsync(function() {
                                callback(view);
                            });
                        } else {
                            $http.get(url)
                                .success(function(data) {
                                    $templateCache.put('view:' + url, data);
                                    scope.$evalAsync(function() {
                                        callback(data);
                                    });
                                })
                                .error(function(data) {
                                    $log.error('Error load template "' + url + "': " + data);
                                });
                        }
                    });
                }

                scope.$watch(attr.ocLazyLoad, function(moduleName) {
                    if(moduleName) {
                        $ocLazyLoad.load(moduleName).then(function(moduleConfig) {
                            if(!moduleConfig.template) {
                                return;
                            }
                            loadTemplate(moduleConfig.template, function(template) {
                                childScope = scope.$new();
                                element.html(template);

                                var content = element.contents();
                                var linkFn = $compile(content);
                                $timeout(function() {
                                    linkFn(childScope);
                                });
                            });
                        });
                    } else {
                        clearContent();
                    }
                });
            }
        };
    }]);

Интеграция нашего сервиса с ui-router

Отложенная загрузка модулей обычно происходит когда вы загружаете новый маршрут. Давайте посмотрим как можно делать это с ui-router (но это будет работать и с ng-route).
Так как мы можем подгружать наш модуль с использованием сервиса или директивы, мы можем использовать два варианта: использовать объект resolve или использовать шаблон.
Использование сервиса требует использование объекта resolve. Объект resolve позволяет определить некоторые параметры для вашего маршрута, и вызывается перед загрузкой шаблона. Это важно, шаблон может использовать контроллер, который осуществляет отложенную загрузку.
Каждый параметр функции resolve дает возможность promise определить, как он должен быть разрешен. Так как наша функция загрузки возвращает promise, мы можем просто использовать его. Здесь часть views является обязательной, это только для этого примера.

$stateProvider.state('index', {
    url: "/", // root route
    views: {
        "lazyLoadView": {
            templateUrl: 'partials/testLazyLoad.html'
        }
    },
    resolve: {
        test: ['$ocLazyLoad', function($ocLazyLoad) {
            return $ocLazyLoad.load({
                name: 'TestModule',
                files: ['js/testModule.js']
            });
        }]
    }
});

Использовать директиву также просто:

$stateProvider.state('index', {
    url: "/",
    views: {
        "lazyLoadView": {
            template: '<div oc-lazy-load="{name: 'TestModule', files: ['js/testModule.js'], template: 'partials/testLazyLoad.html'}"></div>'
        }
    }
});

Я думаю что это немного менее оптимально, чем использование функции resolve, так как мы добавили сложный слой, но это может быть очень полезно в некоторых случаях.
Вот мы и все сделали. Я надеюсь вы найдете этот отложенный загрузчик полезным!
Полностью рабочий пример вы можете глянуть на Plunkr.
Также можно глянуть весь код и пример на github.
Я использовал этот модуль angular как базу для моего проекта, но я сильно его улучшил, добавив новые функции, которые требовались мне.

Автор: Tulov_Alex

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js