angular / angular.js

AngularJS - HTML enhanced for web apps!
https://angularjs.org
MIT License
58.85k stars 27.52k forks source link

feat($injector): Deferred loading of providers into the injector #11015

Closed btford closed 6 years ago

btford commented 9 years ago

This is a draft for this feature. I'm still working on the API. Feedback welcome.

Summary

This is a proposal for a new API in Angular that would make it easier for developers to lazy load parts of their application.

This proposal is only for adding loaded providers into a bootstrapped app's injector. It does not include the lazy loading functionality itself, but rather complements module loaders like require.js, webpack, or SystemJS.

Motivations

Developers want to be able to lazy load application code for a variety of reasons. One is to deliver smaller initial payloads. Another is to hide application logic from unprivileged users (think admin panels) as a first layer of security.

Developers are already hacking their own lazy-loading solutions into 1.x, so there's a clear demand for support. Angular 2 will support lazy-loading, so having facilities in Angular 1 means we can write facades that better align APIs (for example, in the new router) and ease migration.

Finally, this API only addresses adding loaded code into Angular's injector, and does not implement lazy loading itself. There are already existing tools that can do this, so we just want to provide a nice API for them to hook into Angular.

Implementation

The implementation would consist of a new method on Angular Modules and a new service.

You would register some placeholder in the injector. Then you use a service to add the provider later.

lazy.js:

var lazyModule = angular.module('lazy', []);
lazyModule.factory('a', …);

app.js:

var ngModule = angular.module('app', []);

// names of services that some module will provide
ngModule.placeholder(['a', 'b', 'c']);
ngModule.directivePlaceholder(['myFirstDirective', 'mySecondDirective']);

ngModule.run(function($lazyProvide) {
  $lazyProvide.addModule('lazy'); // or: $lazyProvide.addModule(lazyModuleRef);
});

The only change to the behavior of the injector is that trying to inject a service that only has a placeholder, and not a corresponding provider implementation will throw a new type of error. The injector is still synchronous. HTML that has already been compiled, will not be affected by newly loaded directives.

Placeholders

The goal of this placeholder API is to make it easy to reason about how an app should be put together. Still, it's important that the ability to lazy-load parts of your app isn't prohibitively expensive because the work of adding the placeholders is too much.

The placeholder API is designed so that if a developer is using the AngularJS module system in an idiomatic way, you could statically analyze an app and automatically generate placeholders. ng-annotate already has most of this functionality, so I suspect it'd be easy to add generating placeholders to it.

Okay but I really hate the placeholders thing

You can disable the requirement to have a placeholder before a module is lazily added with the following directive:

<div ng-app="2Cool4SchoolApp" ng-allow-dangerous-lazy-providers>
  <!-- ... -->
</div>

This works the same as ngStrictDi.

If manually bootstrapping, you can use the following bootstrap option:

angular.bootstrap(someElt, ['2Cool4SchoolApp'], {
  allowDangerousLazyProviders: true
});

This is for developers who really know what they're doing, and are willing to maintain the invariants about lazy loading manually.

My goal is to make it so easy to provide placeholders that we can deprecate this API because it's never used. But because there's a clear demand for such an option in the Angular community, I want to make sure that it's possible.

Module redefinition

To avoid situations where it's ambiguous which implementation of a module is used in an app, once an app has been bootstrapped, any modules that include a placeholder cannot be redefined. Trying to redefine a module like this will throw an error:

it('should throw if a module with placeholders is redefined after being bootstrapped', function () {
  angular.module('lazy', []).placeholder(['a', 'b', 'c']);
  angular.bootstrap(document, ['lazy']);
  expect(function () {
    angular.module('lazy', []).placeholder(['d', 'e', 'f']);
  }).toThrow();
});

it('should not throw if a module with placeholders is redefined before being bootstrapped', function () {
  angular.module('lazy', []).placeholder(['a', 'b', 'c']);
  expect(function () {
    angular.module('lazy', []).placeholder(['d', 'e', 'f']);
  }).not.toThrow();
  angular.bootstrap(document, ['lazy']);
  // expect the bootstrapped app to have placeholders for `d`, `e`, `f`.
});

Adding to a module after bootstrap

To avoid situations where it's ambiguous what is actually included in a module, once a module with placeholders has been included in a bootstrapped app, it cannot have new placeholders or providers added to it.

it('should throw if a module has placeholders added after being bootstrapped', function () {
  angular.module('lazy', []).placeholder(['a', 'b', 'c']);
  angular.bootstrap(document, ['lazy']);
  expect(function () {
    angular.module('lazy').placeholder(['d', 'e', 'f']);
  }).toThrow();
  expect(function () {
    angular.module('lazy').factory('foo', function () {});
  }).toThrow();
});

it('should not throw if a module has placeholders added before being bootstrapped', function () {
  angular.module('lazy', []).placeholder(['a', 'b', 'c']);
  expect(function () {
    angular.module('lazy').placeholder(['d', 'e', 'f']);
  }).not.toThrow();
  angular.bootstrap(document, ['lazy']);
  // expect the bootstrapped app to have placeholders for `a`, `b`, `c`, `d`, `e`, and `f`.
});

Loading new code

Loading the provider implementation would be left up to the application developer. It is the responsibility of the developer to make sure that components are loaded at the right time.

For instance, you might use it with ngRoute's resolve:

$routeConfig.when('/', {
  controller: 'MyController',
  resolve: {
    'a': () => $http.get('./lazy.js').then((contents) => {
        // this example is silly
        $injector.addModule(eval(contents));
        return $injector.get('a');
      })
    }
  });

This API intentionally does not include a way to "unload" a service or directive.

Run and config blocks

Lazy loaded modules will not run config blocks:

it('should throw if a module has config blocks', function () {
  angular.module('lazy', []).config(function () {});
  expect(function () {
    $injector.addModule('lazy');
  }).toThrow();
});

But lazily loaded modules will run run blocks when they are loaded.

it('should run run blocks', function () {
  var spy = jasmine.createSpy();
  angular.module('lazy', []).run(spy);
  $injector.addModule('lazy');
  // flush
  expect(spy).toHaveBeenCalled();
});

Risks

The API should mitigate the possibility of making it difficult about the state of the injector. For example, we want developers to be able to distinguish between a case where a user mistyped a provider's name from a case when it was requested before it was loaded. Since compiled templates will not be affected by lazily loaded directives, the compiler should also warn if it compiles a template with placeholder directives, but not their implementation.

On the other hand, the "placeholders" should not require too much upkeep, otherwise this API would be too cumbersome to use. Ideally, the placeholders could be automatically generated at build time.

Prior art

I looked at these lazy-loading solutions:

lgalfaso commented 9 years ago

config blocks can change a behavior and must be executed before the app starts. The immediate consequence is that whatever needs to be configured cannot be loaded after an app starts, and config blocks added to a module after an app should not be executed (BTW, this last part is true for lazy-loaded modules or not, and also applies today)

btford commented 9 years ago

I think it's okay to run run blocks, but it's really a bad idea to try to run config blocks. I think by default, it should throw if you try to add a module with config blocks, maybe with an option to ignore them.

jvandemo commented 9 years ago

Would it make sense for a build tool to extract config blocks from dependencies together with the items that are needed to execute them (e.g. the providers) during initial bootstrap?

Then the run blocks could run whenever the lazy loading happens.

May be prone to errors though as it would probably get complicated...

lgalfaso commented 9 years ago

I will not oppose that when adding a run block, then running applications should execute it, but this is not how applications work today... Anyhow, I think this can cause confusion

gautelo commented 9 years ago

One idea would be to allow config blocks, but only when all injected providers are from the module or module-tree that's being lazy-loaded. Basically that we only block the config blocks if you attempt to lazy load a config block that takes an already loaded provider. But again, that could add confusion.

btford commented 9 years ago

@geddski @ocombe – I just added a new section called "Okay but I really hate the placeholders thing"

Let me know what you think.

ocombe commented 9 years ago

Hey, works for me :) Thanks

PS: you were coding in java before javascript? :P

geddski commented 9 years ago

Yay thx @btford!

raul-arabaolaza commented 9 years ago

Hi,

Currently at work we are implementing a very very modular angular app using lazy loading, the basic idea is to be able to create a sort of Angular RCP (a la eclipse) and create plugins instead of apps.

Every plugin is implemented as an angular module.

This means we have only one angular app but it's functionality is completely dynamic depending on the plugins/components/modules you want to load. For example, we have a main menu directive with a provider that allow us to add new items to the menu, so if we want to expose a new functionality in the app we only need to create a new plugin, hook into the main menu provider and voilá the app is extended.

Every plugin defines its dependencies that are lazy loaded (the plugins itself are also lazy loaded) into the angular app by using the great oclazyLoad library and requirejs.

The initial list of plugins to load comes from a backend service. So our platform is an angular app that simply lazy loads a bunch of components.

This gives us a great amount of flexibility we can expand/modify the app's functionality without code changes, great for role based authorization for example. And allow us to work on reusable isolated components instead of apps. Also we have only one deployed app which can give service to a lot of different user needs based on the components to load

I told all this because I believe this is a use of lazy loading you guys have not considered, all this proposal seems to be based on the idea of lazy loading components that are previously known, for performance reasons, reduce footprint, etc. But what about if I want to lazy load some modules dynamically, that is modules not known at coding time?

Some friction points:

HTH, Raúl

btford commented 9 years ago

Config and run blocks: This is another problem, it could be very interesting for a lazy loaded module to be able to add functionality to the app using standard angular ways. One of this ways I believe is providers. If config or run blocks are not allowed then somehow lazy modules are not regular angular code but a sort of "second class citizens" in an angular app which would IMHO seriously limit its usefulness or capability to be reused as usual angular components or libraries.

Run lazily-loaded run blocks are fine, but it's very easy to introduce unintended behavior with race conditions between different values for the same settings specified in different config blocks. Config blocks are for setting up behavior before an app bootstraps. If you need to change behavior after bootstrap, you should expose this in the service itself, not the provider.

Yes, this means you might have to re-write existing code that uses config blocks incorrectly. The good news is that your app becomes easier to understand.

Can you give me a specific example where it's somehow desirable to lazy-load a module with a config block?

I told all this because I believe this is a use of lazy loading you guys have not considered, all this proposal seems to be based on the idea of lazy loading components that are previously known, for performance reasons, reduce footprint, etc. But what about if I want to lazy load some modules dynamically, that is modules not known at coding time?

This is a use case that I have specifically considered. See this comment: https://github.com/angular/angular.js/issues/11015#issuecomment-77318320. In the future, I'd recommend reading the entire thread before commenting.

The "ng-allow-dangerous-lazy" option is a direct result of this discussion. Apps that want this level of dynamicity at the expense of being easy to reason about can use it. Most apps know ahead of time all possible injectables, hence the placeholders.

geddski commented 9 years ago

Can you give me a specific example where it's somehow desirable to lazy-load a module with a config block?

Loading an isolated app that has its own routes. This is our primary use case for lazy loading.

btford commented 9 years ago

Loading an isolated app that has its own routes. This is our primary use case for lazy loading.

The New Router lets you re-configure it at run-time. It will soon have a shim that lets it use ngRoute's DSL. In theory this will address your concern, correct?

geddski commented 9 years ago

@btford yep should. You're smarter than you look ;)

raul-arabaolaza commented 9 years ago

@btford Sorry I did a quick read of comments and didn't realize the one about the plugin system :(

I know config blocks could introduce race conditions but my point is that without config blocks any module that depends upon libraries with providers could potentially not be compatible with lazy loading. There are a lot of great libraries out there that use providers, ui router for example. In my opinion trying to find and solve specific cases seems a waste of time, if lazy loading allow the use of providers for lazy modules good, if not and we are aware of the limitations is good too.

Correct me if I am wrong but this proposal means that for a library to be usable with lazy loaded modules must port all/some/part/none configuration logic from providers to services like the new router service you are mentioning.

Honestly speaking I don't mind that on my components, as you say there are advantages on that and probably I have implemented wrong uses for providers. But I understand it could be not very interesting for library owners out there to do a big refactor in their code to make their libraries usable under lazy load. Which in turn could make this feature not worth to be used instead of the existing solutions out there

Regards, Raúl

raul-arabaolaza commented 9 years ago

I forgot to ask for constants, if no config blocks are run, that means a lazy loaded module can not use constants??

btford commented 9 years ago

module that depends upon libraries with providers could potentially not be compatible with lazy loading

Yes, if the library is using config blocks in a way that is not intended.

There are a lot of great libraries out there that use providers, ui router for example.

The mere presence of a provider is not a problem. Also you would never lazy-load UI Router. You'd always need it upfront.

Correct me if I am wrong but this proposal means that for a library to be usable with lazy loaded modules must port all/some/part/none configuration logic from providers to services like the new router service you are mentioning.

This isn't correct. Any library you lazily load that requires reconfiguration at run-time must be re-written just for those services that require configuration. This will mean changing just a few lines of code, which is a small price to pay for determinism.

But I understand it could be not very interesting for library owners out there to do a big refactor in their code to make their libraries usable under lazy load.

Please show me an example where you'd need to do a ton of refactoring.

I forgot to ask for constants, if no config blocks are run, that means a lazy loaded module can not use constants??

This is incorrect, they'll still be useable post-config phase.

christopherthielen commented 9 years ago

The mere presence of a provider is not a problem. Also you would never lazy-load UI Router. You'd always need it upfront.

I think his point is that ui-router states are declared by registering them with the ui-router state provider, which obviously must happen in the config block. Any lazy loaded user modules that register states with ui-router would have to be reworked to do so at runtime (which is something that ui-router has no official mechanism for)

btford commented 9 years ago

Right, but lazy loading was never officially supported. Backwards compatibility with existing hacks is not one of my goals here. And again, the changes to user code would be minimal.

I feel like this thread is going in circles. If you have a specific, concrete case where it would be difficult for a third party library to be lazy loaded, and where it cannot be easily adapted, feel free to share. Otherwise please make sure that what you post hasn't already been discussed. Thanks!

On Sat, Mar 28, 2015, 11:55 Chris Thielen notifications@github.com wrote:

The mere presence of a provider is not a problem. Also you would never lazy-load UI Router. You'd always need it upfront.

I think his point is that ui-router states are declared by registering them with the ui-router state provider, which obviously must happen in the config block. Any lazy loaded user modules that register states with ui-router would have to be reworked to do so at runtime.

— Reply to this email directly or view it on GitHub https://github.com/angular/angular.js/issues/11015#issuecomment-87282888 .

raul-arabaolaza commented 9 years ago

Right, but lazy loading was never officially supported. Backwards compatibility with existing hacks is not one of my goals here

Fair enough, no more complaints on my part then

ocombe commented 9 years ago

you need the config block if the lazy loaded module defines a decorator, but I'm not sure if there is a way to do that

geddski commented 9 years ago

@btford do you have a current implementation of this I can start playing with? I've followed this up to this point.

gautelo commented 9 years ago

Eyes @btford Eyes OP - Loading new code section and placeholder syntax Eyes Counter proposal 1 :smiley:

andrezero commented 9 years ago

I feel like this thread is going in circles.

@btford on the contrary, it has never been so clear.

If one takes the time to read it all.

I believe the doubts expressed by @raul-arabaolaza are very representative of the community anxiety towards AngularJS progress when it comes to maintaining large, complex systems.

Thank you both for the exercise.

@btford can you please improve your original post so that it incorporates the enlightened discussion?

Under "Run and config blocks":

You might also rethink the // this example is silly, maybe present a more real world one.

thelgevold commented 9 years ago

Question about the new router in 1.4: Why can't the lazy loading be convention based and follow the structure under the components folder?

Meaning, if you navigate using the router and activate a component, can't you just load the top level controller for the component at that point? Ideally you shouldn't have to reference the top level controller using a script tag. Upon instantiating the controller it would be ideal if all the downstream dependencies would be loaded and instantiated at this point as well (services, directives,etc). Again without the need for script tags in index.html. Given how Angular 1.x DI works I suppose this solution would require all downstream resources to be combined into a single file though. I was hoping the new router would function more like Durandal. The router config seems almost identical to Durandal routing, so it seem like it would be possible to support a more seamless RequireJS-esq DI here. It seems a bit odd to me to specify placeholders etc.

Narretz commented 6 years ago

The option to load new modules into the injector will be part of 1.6.7. However, it's slightly different from the original proposal. Read more here: https://github.com/angular/angular.js/commit/34237f929927295392bbb1a600e78cbde581839a

ocombe commented 6 years ago

Nice! It's funny to see that it doesn't take much code to do that when it's inside of the framework :)