Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Lazy-loading modules #146

Closed
nateabele opened this issue May 24, 2013 · 17 comments
Closed

Lazy-loading modules #146

nateabele opened this issue May 24, 2013 · 17 comments
Labels
Milestone

Comments

@nateabele
Copy link
Contributor

I'm opening this as a place to continue discussion around lazy-loading of modules from different states (continued from #123).

Rationale

The ui.state module provides an architecture within which it is possible to cleanly assemble very large applications. As ui.state allows users to navigate whole applications without page reloads, it is necessary to be efficient about script loading.

Resources

@stu-salsbury has done a decent bit of work to enable lazy module loading, loosely based on ui.state, and I wrote a stupid gist that we can probably throw out: :-)

API / Syntax

State definitions can supply a depends key, that lists the modules that they (or their child states) depend on, i.e.:

/* ... */
.state("contacts.item", {
    url: "/:id",
    views: {
        main: {
            templateUrl: "contacts/item.html",
            controller: function($scope, $stateParams, $resource, editor) {
                /* ... */
            }
        }
    },
    depends: ['ngResource', 'customEditor']
})

Because module management is not within the domain of ui.state, it is important not to go too far down the path of explicitly handling it. Instead, we can provide hooks for users to do it themselves. For example:

/* ... */
.run(function($state, $q) {
    $state.dependencyLoaders.push(function(depends) {
        var notify = $q.defer();
        var toPaths = moduleSpecificMethodToConvertDependsToPaths;
        var paths = toPaths(depends);

        if (!paths || !paths.length) {
            // This module doesn't know anything about any of these dependencies
            return;
        }

        // Example: use require.js to load dependencies:
        require(toPaths(depends), function() {
            notify.resolve();
        });
        return notify.promise;
    });
})

Here, modules could register handlers with $state ($stateProvider would work, too, if we made the handlers injectable and had them return a function, similar to $httpProvider.responseInterceptors), and when a state is transitioned to, those handlers receive an array of the combined list of modules required by a state (i.e. that state and any parents) that are not currently defined.

A handler that "knows about" one or more modules in the list can return a promise that resolves when all the modules that handler can load are loaded. The combined list of promises can then be thrown into $q.all(), the resolution of which triggers the resolve for the state transition itself.

Advantages

  • Makes it easier to divide sections of an app into separate modules
  • Logical sections and sub-sections of apps can be self-contained, as states associated with a section/module can identify & communicate their own dependencies, as well as how to load them

Caveats & Alternatives

One potential caveat of this approach is that multiple handlers may attempt to load the same module at the same time. Possible workarounds:

  • Valid handler return values may be annotated in some way with the modules being loaded, such that those modules are not passed to other handlers
  • Evaluate loaded scripts inside a try/catch block
  • Document it as a user-land responsibility

Alternatively, it is possible that the whole concept of module-loading is simply too far outside the domain of state management, such that it should be handled entirely by a separate module.

If that's the case, we should come up with a system of interceptors/decorators sufficient to allow a third-party module to easily intercept and defer state transitions while module loading takes place, as well as a way to get the current state and traverse upwards from it, in order gather all necessary dependencies.

@laurelnaiad
Copy link

Thanks, @nateabele!

We're going to need to be careful about the word module. I already got confused in reading Nate's post. I think I was thinking he meant angular modules when he really meant AMD modules... but I'm still not sure.

Just to be clear -- loading angular modules is the one thing that couchPotato doesn't do (yet)... rather it enables loading and registering components (services/controllers/directives/filters) using AMD modules, which can therefore be done in the context of resolving promises during transition to a state.

My next task was to look at how you load angular modules in your gist (that is what it does, I hope), and to try incorporate that, as well.

For the rest of this post, I'm referring to AMD modules, unless otherwise indicated.

couchPotato currently works with the stock router, and with ui-router by means of a "dummy" property within the resolve property of the state config.

detour (which is the one based on ui-router) integrates and wraps couchPotato, providing a "dependencies" property that is used to invoke couchPotato for download and registration. I'll be pushing the detour code that integrates couchPotato later today.

However, the real purpose (as yet unrealized) of detour is about lazy-loading and editing routes themselves, which I consider to be a whole different animal (even though it begs support for the lazy loading and registration of the components on which the states depend). I'm pretty sure that that is not in the scope of this issue, so we can probably steer clear of thinking about it too much in this context.

Back to lazy-loading angular components/angular modules for a state: I guess the second approach with having registered handlers sounds like a nice abstraction -- if that means that someone can choose to use couchPotato or some other method as long as it ultimately deliveres a promise.

I would think whichever direction is taken, you'd want the references (URL paths in AMD) to the set of components upon which a state relies to remain with the definition of that state -- right? In other words, you wouldn't want to bury the list of dependencies for state XYZ in a monolithic application-wide run function.

Could you explain your concerns about multiple simultaneous loading from multiple handlers? Since we're single-threaded, I figured that at least with AMD we know that the first call to a given AMD module will be the one that runs the module, and the rest just get it's result.... but I suppose if you actually use two different methods to register components, you could be in a bit of a hurt.

On another note, I was thinking this morning about how it might be possible to avoid leaning on AMD. If anyone has ideas, I'd be excited to discuss that. Right now, what it brings are (a) just-in-time code download and (b) easily avoids re-download/re-registration and (c) path-based pointers to the code. Also, it has no trouble working with sources from different domains.

For what it's worth, couchPotato is just a formalization of and extension of a bunch of other examples out there. Functionally the only thing it adds is that it allows/requires the AMD modules to do the registering of the components they define, thereby allowing for dependencies of dependencies to automatically be downloaded/registered without having to express the entire set in the state definition, as fits with the AMD paradigm.

@nateabele
Copy link
Contributor Author

Hey, sorry for not being clear. When I said 'modules', I did, in fact, mean Angular modules. Angular's module implementation provides an adequate encapsulation mechanism, so unless I'm missing something here (entirely possible), I don't think we need to care about the AMD paradigm.

For purposes of what I had in mind, all we really need to care about is mapping a module name to one or more script URLs (i.e. if a module is split across multiple files, or if a module depends on another file that's just generic JavaScript code). Angular itself, combined with simple module -> URL(s) hash maps should provide all the benefits and flexibility of AMD.

However, the real purpose (as yet unrealized) of detour is about lazy-loading and editing routes themselves [...]

I think this will work itself out naturally once we figure out a state traversal / manipulation API.

[Y]ou wouldn't want to bury the list of dependencies for state XYZ in a monolithic application-wide run function.

Personally, I think that states should only list their module dependencies, not the URLs for those dependencies. The point of run() (or config(), whatever) was more as an implementation example. A 'real' implementation would be in a 3rd-party (AngularUI, or custom) module that would expose an API for registering module -> URL mappings, and provide an opportunity for the main module to override URLs for tertiary dependencies. In an ideal world, that module would become standard, and would provide the only handler ever attached (this is confusing, partially because we're talking about two separate possible solutions here).

Could you explain your concerns about multiple simultaneous loading from multiple handlers?

If you have two separate handlers from two separate modules, both of which depend on the same (third) module, it's conceivable that they could attempt to load the same module at the same time, since the promises would be loading in parallel. Strictly speaking, yes, these things happen synchronously, but loading the same script twice would attempt to register the same module twice, which would cause Angular to throw an error.

@laurelnaiad
Copy link

So if we're talking specifically about modules and not individual angular components, then neither couchPotato nor detour do anything about that, yet, and many of my comments above should be read with the assumption that some of what you were discussing was in part about AMD modules. I do want to do lazy angular module loading, but that's not in couchPotato or detour as of now.

If you have two separate handlers from two separate modules, both of which depend on the same (third) module, it's conceivable that they could attempt to load the same module at the same time, since the promises would be loading in parallel. Strictly speaking, yes, these things happen synchronously, but loading the same script twice would attempt to register the same module twice, which would cause Angular to throw an error.

AFAIK, if you use AMD you don't need to worry about this as the second caller will get the result of the AMD-definition-module without running it. I'm sure the same functionality could be implemented without AMD, too, and I guess that was your point.

I think its worth separating some different but overlapping goals that one might have:

lazy loading: essentially, you don't want to incur network traffic or processor time/RAM usage until you need to. Big sites/apps can benefit from this.

loose coupling: you don't want one part of your site/app to require intimate knowledge or intricate dependencies on others where not strictly necessary. Sites/apps that are managed by larger teams, and sites/apps built from pieces developed by different teams can benefit from this, and modularization of site/app architecture can have benefits in use cases such as a CMS framework where "components" (using the term loosely here) should be pluggable.

dependency management: you want to specify dependencies where they are needed to help you keep your code understandable and manageable, including cases where different teams are responsible for different pieces.

For example, couchPotato doesn't do too much for you on its own with loose coupling. Whatever dependencies you load/register need to work together and with the specified templates and in the part of the app where they are invoked. But it does provide the lazy benefit, and it allows the dependencies to be referenced where they are needed (in the definition of the states that they are needed). For dependency management, it does provide that the path is specified in the definition of the state, therefore it doesn't require any master URL map to be updated.

I suppose that expressing dependencies between modules (each of which is composed of a single component) is similar to expressing them with AMD. But I'm trying to wrap my head around where you would specify the URLs for everything.

I have a feeling I'm missing a piece of the puzzle here. Is one of your goals to specifically avoid AMD? Please correct me if I'm wrong... It sounds like you're thinking about a replacement for AMD that lives in angular-land such that you can specify the URLs somewhere near the top of the food chain, and thus not worry about them when specifying the actual dependencies within the application -- in other words, you'd use names of components/modules in the app code, and have a centralized store mapping the URLs to those components/modules. If that is so, it worries me from a loose-coupling standpoint. If a large organization has one group (call it Technical Services) that owns a big web site's/web app's overall config, does the Technical Services group need to modify a master list in order for a smaller group in the organization (call it Widget Sales) to use a little component in one of the states/routes/components that they own?

Don't get me wrong -- I'd be happy not to "require" requirejs... we may just be thinking of different use cases with respect to dependency management? I'm sure we could build something that serves "centralized URL map use casees" and "decentralized" versions as well.

@laurelnaiad
Copy link

one more thing I neglected to mention...

When I think of loose coupling, I imagine widgets being loaded in a container and the container not caring about the specificities of the widgets (just that they support the widget interface) and the widgets not knowing anything at all about the container (just that it supports the container interface).

Loose coupling is the goal that seems truly challenging -- the dependency management stuff and lazy loading stuff should really just be a matter of making some decisions and ensuring sufficient flexibility for things like having both centralized and decentralized dependency management.

@laurelnaiad
Copy link

Sorry -- forgot to actually make my key point. -- that I want to recognize
that the loose coupling goal is probably outside the scope of this issue,
so my comment was meant to set it aside, not to dive into it.

@ksperling
Copy link
Contributor

In terms of how $state interacts with this, wouldn't it be enough to be able to use

$sP.state('bla', {
  views: {
    '': {
      resolve: {
        '$controller': function (MyLazyLoadService) { return MyLazyLoadService.load('FooController') }
      }
    }
  }
});

I.e. the controller simply gets loaded as part of the normal resolve process; all $state cares about is that it's there when resolve finishes. In fact once $resolver is factored out into it's own service, your lazy loading solution should simply be able to decorate $resolver to be able to do fancy stuff like allowing services to be lazy loaded as well. Just as a wild idea, you when you ask for a service 'foo.bar' it could ensure module 'foo' is loaded, create a private $injector for the module that inherits from the app injector, and then resolve 'bar' within that injector, and finally return the service just like plain old $resolver would have done.

@laurelnaiad
Copy link

Are there any subtle tradeoffs between registering controllers and referencing them by registered name vs. assigning them directly to a controller property? I can't think of anything serious as long as the lazy loader can give the already-loaded copy of FooController the next time it's called for.

I gravitated to the pattern where the routers don't need to care what the dependencies are. In the case of ui-router, putting the loader in the resolve list with a "dummy" property gets the job done because the dependencies register themselves in the module. This works today, but has no concept of modules. So it's lazy and supports dependency management within the main module only (unless the AMD module definition were to load code from another module). I'm kind of behind on that portion of the conversation because I still haven't had time to look at Nate's runtime module dependency loader.

Back on the subject of where to define URLs... it would be nice to come up with something hierarchical that starts in a local place (perhaps the state definition itself) and then gradually looks upward toward a root in order to resolve references to modules and components.... something like, perhaps, the state objects themselves (with the final stop being the root state/state service)? I think that would be a pretty decent way to allow for both centralized and decentralized mapping (and of course the path up the states already exists).

I suppose one could make the case that this would clutter the router with non-router issues, but if you consider the router to be the place where you define what's going on for a given app URL (or state), then where else would it really belong? If a local state needs a different version of a component, it need only override the URL that it would naturally have gotten from its ancestor.

@laurelnaiad
Copy link

somewhat related -- https://github.com/afterglowtech/angular-detour now supports configuration through json as well as allowing for services to be specified by name to resolve locals and to act as template providers. Specifying named services is necessary because javascript functions can't travel in json.

I had to move the lazy registration of dependencies to a higher level in the transitionTo function in order to get them registered prior to invoking the templateService (the parameter used to specify a service that returns a template).

So, for example, the detail state from the sample looks like this in json (there's an abbreviated syntax so that a real server-sent json string would be much shorter):

name: 'detail', definition: {
  url: '/{contactId}',
  aliases: {'/c?id': '/:id', '/user/{id}': '/:id'},
  resolveServices: {
    something: 'getContactIdFromParams'
  },
  dependencies: ['controllers/contactsDetailController', 'services/getContactIdFromParams', 'services/getContactIdHtml'],
  views: {
    '': {
      templateUrl: 'partials/contacts.detail.html',
      controller: 'contactsDetailController'
    },
    'hint@': {
      template: 'This is contacts.detail populating the view "hint@"'
    },
    'menu': {
      templateService: 'getContactIdHtml'
    }
  }
},

Looking at this now, "resolveServices" is misleading and needs to be renamed. "resolveByService" is more true, but that doesn't sound very good. :)

@timkindberg
Copy link
Contributor

Also check out Future States: http://christopherthielen.github.io/ui-router-extras. I'm closing this because at this time people can use these third party options.

@christopherthielen
Copy link
Contributor

Interesting... I wasn't aware of all this work that had been done on lazy loading states. @stu-salsbury are you still using couch potato or detour?

@toddwong
Copy link

What about make the template and controller accept functions that returning promises? like:

.state("contacts.item", {
    url: "/:id",
    views: {
        main: {
            template: function() { return System.import('some.html'); },
            controller: function() { return System.import('some.js'); } 
        }
    }
})

@nateabele
Copy link
Contributor Author

I believe promises are already supported by templateProvider and controllerProvider. Does that mean we're done here? 😉

@toddwong
Copy link

@nateabele thanks for pointing me out templateProvider and controllerProvider! I didn't notice that!

@toddwong
Copy link

@nateabele I can't make that work,


        controllerProvider: function () {
            return System.import(controllerUrl).then(function (controller) {
                return controller.default;
            });
        }

got this error : Argument 'fn' is not a function, got Promise

@nateabele
Copy link
Contributor Author

Ah, you're right, I forgot... controllerProvider does not currently accept promises. Seems like this is the only thing that needs to be addressed for full lazy-loading support.

@toddwong
Copy link

@nateabele With some help with "resolve", I actually made it work:


var template, controller;

// .... 

controllerProvider: function () {
    return controller;
},
templateProvider: function() { 
    return template;
},
resolve: {
    '--------------------------------------': function($q) {
         return $q(function (resolve, reject) {
             Promise.all([....]).then(function(ret) {
                         template = ret[0];
                         controller  = ret[1];
                         resolve();
             }, reject);
         });
    }
}


a little ugly

@toddwong
Copy link

I'm struggling with how to lazy loading directives now, any idea?

christopherthielen added a commit that referenced this issue Sep 9, 2016
- Retry URL sync by matching state*.url
- Make registration of new states (by the lazyload hook) optional
- Reuse lazy load promise, if one is in progress
- Refactor so ng1-to-ng2 UIRouter provider is implicit
- When state has a .lazyLoad, decorate state.name with `.**` wildcard
Closes #146
Closes #2739
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

6 participants