Skip to content

Instantly share code, notes, and snippets.

@alxhub
Created February 14, 2018 19:14
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save alxhub/08f81899cb4dbf2f086696dd48d84fc4 to your computer and use it in GitHub Desktop.
Save alxhub/08f81899cb4dbf2f086696dd48d84fc4 to your computer and use it in GitHub Desktop.

Tree-shakeable Tokens Docs

Status quo and issues with it

Injector structure

Currently, to provide services in Angular, you include them in an @NgModule:

@Injectable()
export class Service {}

@NgModule({
  providers: [Service],
})
export class ServiceModule {}

This module can then be imported into your application module, to make the service available for injection in your app:

@NgModule({
  imports: [
    BrowserModule,
    RouterModule.forRoot(...),
    ServiceModule,
  ], ...
})
export class AppModule {}

When ngc runs, it compiles AppModule into a module factory, which contains definitions for all the providers declared in all the modules it includes. At runtime, this factory will become an Injector which will instantiate these services.

Non-tree-shakeability of modules

Ideally, if the application doesn't actually inject Service, it won't be included in the final output. This process of removing code which is not referenced is known as tree-shaking.

However, it turns out the Angular compiler can't know at build time if the service will be required or not. That's because it's always possible to inject the service directly:

injector.get(Service);

Angular cannot understand all of the places in your code where this injection could happen, so it has no choice but to include the Service in the injector regardless. Thus, services provided in modules are not tree-shakeable.

Summary

The reason tree-shaking breaks above is that we cannot decide to exclude one chunk of code (the provider definition for the service within the module factory) based on whether another chunk of code (the service class) is used. To make services tree-shakeable, the information about how to construct an instance of the service (the provider definition) needs to be a part of the service class itself.

Injectable scoping

Overview

To accomplish the above, the information that used to be specified in the module is now specified in the @Injectable decorator on the service itself. This includes two main pieces of information.

  1. Which injector should the service belong to?

Not all services are provided to the application's "root" injector. Some belong to modules which are lazily loaded and become their own injectors, which are children of the root injector. It's necessary to indicate which injector should be responsible for instantiating a service (and its dependencies) and making it available for injection.

  1. How to instantiate the service itself?

Often this is as simple as "call new on the service class and inject its dependencies," but Angular allows specifying different ways of providing a service (useValue, useFactory, etc).

@Injectable

The tree-shakeable equivalent to the Service / ServiceModule example above is thus:

// Note that ServiceModule here is empty, it doesn't declare
// any providers of its own.
@NgModule({})
export class ServiceModule {}

@Injectable({
  // Instead, we declare that this service should be provided
  // by any injector which includes ServiceModule.
  //
  // This is almost as if the module declared `providers: [Service]`.
  scope: ServiceModule,
})
export class Service {}

scope allows us to declare the module which "owns" this service. Any injector which includes that module will instantiate the decorated service as if it was provided directly within the module, if the injector has no other providers for the service. Thus, it's not exactly as if the module declared a provider for the service (which could override other providers for thes ame service).

Provider Declarations

By default, the service will be instantiated by the injector normally (new Service() with injected dependencies). This is similar to declaring the service directly in the providers array, or a provider of {provide: Service, useClass: Service}.

Just like with provider declarations, it's possible to override the instantiation of a service by configuring a factory:

@Injectable({
  scope: ServiceModule,
  useFactory: (dep: string) => new Service(dep),
  deps: [...],
})
export class Service {...}

useExisting, useClass, and useValue are also supported. multi is not.

APP_ROOT_SCOPE

(Not yet landed but coming soon)

Sometimes there isn't a clear module to which a provider should be scoped, but instead you want to indicate that the provider belongs in the root injector. This has traditionally been accomplished by convention, through a ModuleWithProviders function named forRoot on a module:

@NgModule({...})
export class MyModule {
  static forRoot(): ModuleWithProviders {
    return {
      ngModule: MyModule,
      providers: [Service],
    };
  };
}

This pattern relies on users properly calling .forRoot when importing the module into their application module (which becomes the root injector) and on them not calling it when importing the same module into lazily loaded route modules.

Instead, a service can be scoped to a special scope APP_ROOT_SCOPE:

@Injectable({
  scope: APP_ROOT_SCOPE,
})
export class Service {}

This ensures that the service will be scoped to the root injector, without needing to name a particular module that will be present in that injector.

InjectionToken

Sometimes instead of using classes as tokens, users create instances of InjectionToken to represent something available for injection which doesn't have an @Injectable class (or where the class is not known/public to the user).

It's still possible to construct a tree-shakeable InjectionToken, but instead of configuring a standard provider, you directly specify a factory to be called, which will return the value to be used.

const TOKEN = new InjectionToken('tree-shakeable token', {
  scope: APP_ROOT_SCOPE,
  factory: () => new Service(),
});

If the service needs access to other DI tokens, it can use the inject function from @angular/core to invoke DI.

const TOKEN = new InjectionToken('tree-shakeable token', {
  scope: APP_ROOT_SCOPE,
  factory: () => new Service(inject(Dependency)),
});

What to teach

We probably need to revise the existing docs to show scoped @Injectables instead of providers: in modules, except when explicitly teaching providers.

We should also probably have a guide for migrating existing services.

We also need to decide what to call these things. Something like:

  • Tree-shakeable services
  • Tree-shakeable tokens
  • Tree-shakeable providers
  • Scoped ?
  • etc
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment