Lazy Loading of Angular Component Scripts when Cha

2019-03-09 14:17发布

This problem has been taking up the last day or so.

I've been trying to get my AngularJS application to load the script files for each state's components lazily. I'm working on a large project at work in Angular, and the index.html file has morphed into over 100 <script> tags including the JS for the various controllers, services, and libraries. Most of them are small, so it isn't so much that loading time is a HUGE problem (though it could be), but it just never looked clean to me.

Maybe it's because I've gotten used to PHP's autoloader or have just been spoiled by all of the languages that can load their own dependencies at compile time. It's just not modular to have to load scripts for some minor, fringe state's directive in the root document of the application, or for the module that directive actually belongs to not to load the script itself if it's moved into another application without the <script> list of glory.

Either way, I'm starting a new project and want to keep it cleaner, but loading components into Angular in this way presents a number of challenges. A lot of them have been addressed at one time or another in the documentation or some blog post, SO question, or another, but I've yet to see an end-to-end solution that integrates cleanly with other Angular components.

  1. Angular only bootstraps the ng-app directive if Angular and the modules are already loaded when the page is rendered. Even starting the application with lazy-loading requires a workaround.
  2. The module API's methods only work before an application is bootstrapped. Registering new controllers, directives, filters, or services after the application has been bootstrapped, but after the scripts defining them have actually been loaded (and when they're actually needed) requires a workaround.
  3. Both lazy loading scripts and invoking AJAX-based services require the invocation of callbacks, and injecting the result of service calls into state controllers requires those services to actually exist to be called when the state transition starts. Actually INVOKING a lazily loaded service and resolving it before the state changes...requires a workaround.
  4. All of this needs to fit together in a way that doesn't look kludgy and can easily be reused in multiple applications without reinventing the wheel each time.

I've seen answers to #1 and #2. Obviously, angular.bootstrap can be used to start up a module after the whole page has loaded without an ng-app directive. Adding components after bootstrapping is a little less obvious, but saving references to the various $provider services in the config blocks does the trick, overwriting the module API more seamlessly so. Resolving #3 and doing it all in a way that satisfies #4 has been a bit more elusive.

The above examples solving #2 were for controllers and directives. Adding in services turns out to be a little bit more complicated, asynchronous ones, lazily loaded, and meant to provide their data to a lazily loaded controller especially so. With respect to Mr. Isitor, his code certainly works for registering a controller as a proof of concept, but the code is not written in a way that easily scales up to the kind of application for which lazy-loading the scripts makes sense, a much larger application with tens to hundreds of includes, dependencies, and asynchronous services.

I'm going to post the solution I came up with, but if anyone has suggestions to improve it or has already found a dramatically and radically different, better way, please feel free to add it on.

1条回答
Viruses.
2楼-- · 2019-03-09 14:34

Here's the code for an Angular module lazy, depending on the ui.router module. When it's included in your module's dependencies, the lazy loading functionality of the state's scripts will be enabled. I've included examples of the primary app module, a few lazy components, and my index.html, sanitized for demonstration purposes. I'm using the Script.js library to actually handle the script loading.

angular-ui-router-lazy.js

/**
 * Defines an AngularJS module 'lazy' which depends on and extends the ui-router
 * module to lazy-load scripts specified in the 'scripts' attribute of a state
 * definition object.  This is accomplished by registering a $stateChangeStart
 * event listener with the $rootScope, interrupting the associated state change
 * to invoke the included $scriptService which returns a promise that restarts the
 * previous state transition upon resolution.  The promise resolves when the
 * extended Script.js script loader finishes loading and inserting a new <script>
 * tag into the DOM.
 *
 * Modules using 'lazy' to lazy-load controllers and services should call lazy.makeLazy
 * on themselves to update the module API to inject references for the various $providers 
 * as the original methods are only useful before bootstrapping, during configuration,
 * when references to the $providers are in scope.  lazy.makeLazy will overwrite the
 * module.config functions to save these references so they are available at runtime,
 * after module bootstrapping.
 * See http://ify.io/lazy-loading-in-angularjs/ for additional details on this concept
 *
 * Calls to $stateProvider.state should include a 'scripts' property in the object
 * parameter containing an object with properties 'controllers', 'directives', 'services',
 * 'factories', and 'js', each containing an array of URLs to JS files defining these
 * component types, with other miscelleneous scripts described in the 'js' array.
 * These scripts will all be loaded in parallel and executed in an undefined order
 * when a state transition to the specified state is started.  All scripts will have
 * been loaded and executed before the 'resolve' property's promises are deferred,
 * meaning services described in 'scripts' can be injected into functions in 'resolve'.
 */

 (function() {
    // Instantiate the module, include the ui.router module for state functionality
    var lazy = angular.module('lazy',['ui.router']);

    /**
     * Hacking Angular to save references to $providers during module configuration.
     * 
     * The $providers are necessary to register components, but they use a private injector
     * only available during bootstrap when running config blocks.  The methods attached to the
     * Vanilla AngularJS modules rely on the same config queue, they don't actually run after the
     * module is bootstrapped or save any references to the providers in this injector.
     * In makeLazy, these methods are overwritten with methods referencing the dependencies
     * injected at configuration through their run context.  This allows them to access the
     * $providers and run the appropriate methods on demand even after the module has been
     * bootstrapped and the $providers injector and its references are no longer available.
     *
     * @param module      An AngularJS module resulting from an angular.module call.
     * @returns module    The same module with the provider convenience methods updated
     * to include the DI $provider references in their run context and to execute the $provider
     * call immediately rather than adding calls to a queue that will never again be invoked.
     */
    lazy.makeLazy = function(module) {
      // The providers can be injected into 'config' function blocks, so define a new one
      module.config(function($compileProvider,$filterProvider,$controllerProvider,$provide) {
        /**
         * Factory method for generating functions to call the appropriate $provider's
         * registration function, registering a provider under a given name.
         * 
         * @param registrationMethod    $provider registration method to call
         * @returns function            A function(name,constructor) calling
         * registationMethod(name,constructor) with those parameters and returning the module.
         */
        var register = function(registrationMethod) {
          /**
           * Function calls registrationMethod against its parameters and returns the module.
           * Analogous to the original module.config methods but with the DI references already saved.
           *
           * @param name          Name of the provider to register
           * @param constructor   Constructor for the provider
           * @returns module      The AngularJS module owning the providers
           */
          return function(name,constructor) {
            // Register the provider
            registrationMethod(name,constructor);
            // Return the module
            return module;
          };
        };

        // Overwrite the old methods with DI referencing methods from the factory
        // @TODO: Should probably derive a LazyModule from a module prototype and return
        // that for the sake of not overwriting native AngularJS code, but the old methods
        // don't work after `bootstrap` so they're not necessary anymore anyway.
        module.directive = register($compileProvider.directive);
        module.filter = register($filterProvider.register);
        module.controller = register($controllerProvider.register);
        module.provider = register($provide.provider);
        module.service = register($provide.service);
        module.factory = register($provide.factory);
        module.value = register($provide.value);
        module.constant = register($provide.constant);
      });
      // Return the module
      return module;
    };

    /**
     * Define the lazy module's star $scriptService with methods for invoking
     * the extended Script.js script loader to load scripts by URL and return
     * promises to do so.  Promises require the $q service to be injected, and
     * promise resolutions will take place in the Script.js rather than Angular
     * scope, so $rootScope must be injected to $apply the promise resolution
     * to Angular's $digest cycles.
     */
    lazy.service('$scriptService',function($q,$rootScope) {
      /**
       * Loads a batch of scripts and returns a promise which will be resolved
       * when Script.js has finished loading them.
       *
       * @param url   A string URL to a single script or an array of string URLs
       * @returns promise   A promise which will be resolved by Script.js
       */
      this.load = function(url) {
        // Instantiate the promise
        var deferred = $q.defer();
        // Resolve and bail immediately if url === null
        if (url === null) { deferred.resolve(); return deferred.promise; }
        // Load the scripts
        $script(url,function() {
          // Resolve the promise on callback
          $rootScope.$apply(function() { deferred.resolve(); });
        });
        // Promise that the URLs will be loaded
        return deferred.promise;
      };

      /**
       * Convenience method for loading the scripts specified by a 'lazy'
       * ui-router state's 'scripts' property object.  Promises that all
       * scripts will be loaded.
       *
       * @param scripts   Object containing properties 'controllers', 'directives',
       * 'services', 'factories', and 'js', each containing an array of URLs to JS
       * files defining those components, with miscelleneous scripts in the 'js' array.
       * any of these properties can be left off of the object safely, but scripts
       * specified in any other object property will not be loaded.
       * @returns promise   A promise that all scripts will be loaded
       */
      this.loadState = function(scripts) {
        // If no scripts are given, instantiate, resolve, and return an easy promise
        if (scripts === null) { var d = $q.defer; d.resolve(); return d; }
        // Promise that all these promises will resolve
        return $q.all([
          this.load(scripts['directives'] || null),
          this.load(scripts['controllers'] || null),
          this.load(scripts['services'] || null),
          this.load(scripts['factories'] || null),

          this.load(scripts['js'] || null)
        ]);
      };
    });

    // Declare a run block for the module accessing $rootScope, $scriptService, and $state
    lazy.run(function($rootScope,$scriptService,$state) {
      // Register a $stateChangeStart event listener on $rootScope, get a script loader
      // for the $rootScope, $scriptService, and $state service.
      $rootScope.$on('$stateChangeStart',scriptLoaderFactory($scriptService,$state));
    });

    /**
     * Returns a two-state function for handing $stateChangeStart events.
     * In the first state, the handler will interrupt the event, preventing
     * the state transition, and invoke $scriptService.loadState on the object
     * stored in the state definition's 'script' property.  Upon the resolution
     * of the loadState call, the handler restarts a $stateChangeStart event
     * by invoking the same transition.  When the handler is called to handle
     * this second event for the original state transition, the handler is in its
     * second state which allows the event to continue and the state transition
     * to happen using the ui-router module's default functionality.
     *
     * @param $scriptService    Injected $scriptService instance for lazy-loading.
     * @param $state            Injected $state service instance for state transitions.
     */
    var scriptLoaderFactory = function($scriptService,$state) {
      // Initialize handler state
      var pending = false;
      // Return the defined handler
      return function(event,toState,toParams,fromState,fromParams) {
        // Check handler state, and change state
        if (pending = !pending) {   // If pending === false state
          // Interrupt state transition
          event.preventDefault();
          // Invoke $scriptService to load state's scripts
          $scriptService.loadState(toState.scripts)
            // When scripts are loaded, restart the same state transition
            .then(function() { $state.go(toState,toParams); });
        } else {  // If pending === true state
          // NOOP, 'ui-router' default event handlers take over
        }
      };
    };
  })();

/** End 'lazy' module */

index.html

<!DOCTYPE html>
<html>
  <head>
    <title>Lazy App</title>
    <script type='text/javascript' src='libs/script.js'></script>
    <script type='text/javascript'>
      $script.queue(null,'libs/angular/angular.min.js','angular')
             .queue('angular','libs/angular/angular-ui-router.min.js','ui-router')
             .queue('ui-router','libs/angular/angular-ui-router-lazy.js','lazy')
             .queue('lazy',null,'libs-angular')

             .queue('libs-angular','lazyapp/lazyapp.module.js','lazyapp-module');

      $script.ready('lazyapp-module',function() { console.log('All Scripts Loaded.'); });
    </script>
  </head>

  <body>
    <div ui-view='mainView'></div>
  </body>
</html>

Function Hacked into Script.js because I Prefer the Syntax

$script.queue = function(aQueueBehind,aUrl,aLabel) {
  if (aQueueBehind === null) { return $script((aUrl === null?[null]:aUrl),aLabel); }
  $script.ready(aQueueBehind,function() {
    if (aUrl !== null)
      $script(aUrl,aLabel);
    else
      $script.done(aLabel);
  });
  return $script;
}

lazyapp.module.js

(function() {
  var lazyApp = angular && angular.module('lazyApp ',['lazy']);
  lazyApp = angular.module('lazy').makeLazy(lazyApp);

  lazyApp.config(function($stateProvider) {

    $stateProvider.state({
      name: 'root',
      url: '',
      views: {
        'mainView': { templateUrl: '/lazyapp/views/mainview.html', controller: 'lazyAppController' }
      },
      scripts: {
        'directives': [ 'lazyapp/directives/lazyheader/src/lazyheader.js' ],
        'controllers': [ 'lazyapp/controllers/lazyappcontroller.js' ],
        'services': [ 'lazyapp/services/sectionservice.js' ]
      },
      resolve: {
        sections: function(sectionService) {
          return sectionService.getSections();
        }
      }
    });
  });

  angular.bootstrap(document,['lazyApp']);
})();

sectionservice.js

(function() {
  var lazyApp = angular.module('lazyApp');

  lazyApp.service('sectionService',function($q) {
    this.getSections = function() {
      var deferred = $q.defer();
      deferred.resolve({
        'home': {},
        'news': {},
        'events': {},
        'involved': {},
        'contacts': {},
        'links': {}
      });
      return deferred.promise;
    };
  });
})();

lazyheader.js

(function() {
  var lazyApp = angular.module('lazyApp ');

  lazyApp.directive('lazyHeader',function() {
    return {
      templateUrl: 'lazyapp/directives/lazyheader/templates/lazyheader-main.html',
      restrict: 'E'
    };
  });
})();

lazyappcontroller.js

(function() {
  var lazyApp = angular.module('lazyApp ');

  lazyApp.controller('lazyAppController',function(sections) {
    // @TODO: Control things.
    console.log(sections);
  });
})();
查看更多
登录 后发表回答