Tree Shakeable Providers and Services in Angular
Angular recently introduced a new feature, Tree Shakeable Providers. Tree Shakeable Providers are a way to define services and other things to be used by Angular's dependency injection system in a way that can improve the performance of an Angular application.
First, let's define tree shaking before we dig too deep. Tree shaking is a step in a build process that removes unused code from a code base. Removing unused code can be thought as "tree shaking," or you can visualize the physical shaking of a tree and the remaining dead leaves falling off of the tree. By using tree shaking, we can make sure our application only includes the code that is needed for our application to run.
For example, say we have a utility library that has functions a()
, b()
and c()
. In our app we import and use function a()
and c()
but do not use b()
. We would expect that the code for b()
to not be bundled and deployed to our users. Tree shaking is the mechanism to remove function b()
from our deployed production code we send to our user's browsers.
Why were Services in Angular not tree shakable already? Well, it comes back to how we registered Service in prior versions of Angular. Let's look at an example of how we would have registered a Service in previous Angular versions to be used for dependency injection.
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import { SharedService } from './shared.service';
@NgModule({
imports: [BrowserModule, FormsModule],
declarations: [AppComponent],
bootstrap: [AppComponent],
providers: [SharedService]
})
export class AppModule {}
As you can see we import the Service and add it to our Angular AppModule
. This registers the Service to Angular's Dependency Injection system. Whenever a component requests to use this service, Angular's DI will make sure that Service and any of its dependencies are created and passed onto the component's constructor. The issue with this registration system is it is challenging for the build tools and compilers to determine if this code is used in our application.
One of the primary ways a tree shaking system removes code is looking at the import paths we define. If a class or function is not imported it is not included in the production code bundles we ship to our users. If it's imported, the tree shaker assumes that it is being used in the application. In our example above we are importing and referencing our Service in the AppModule
causing an explicit dependency that cannot be tree shaken.
Angular Tree Shaking Providers
With Tree Shaking Providers (TSP) we can use a different mechanism to register our services. Using this new TSP mechanism will provide the benefits of both tree shaking performance and dependency injection. We have a demo application with specific code to demonstrate the different performance characteristics of how we register these services. Let's take a look at what the new TSP syntax looks like.
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class SharedService {
constructor() {}
}
In the @Injectable
decorator we have a new property called providedIn
. With this property we can tell Angular which module to register our service to instead of having to import the module and register it to the providers of a NgModule
. By default, this syntax registers it to the root injector which will make our service an application wide singleton. The root provider is a reasonable default for most use cases. If you still need to control the number of instance of a Service the regular providers API is still available on Angular Modules and Components.
With this new API, you can see since we did not have to import the service into a NgModule
for registration that we did not make an explicit dependency. Because there is no import statement, the build tools can ensure that this service is only bundled in our application if a component uses it. Let's take a look at an example application.
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { AppComponent } from './app.component';
import { HelloComponent } from './hello.component';
import { Shared3Service } from './shared3.service';
@NgModule({
imports: [
BrowserModule,
FormsModule,
RouterModule.forRoot([
{ path: '', component: HelloComponent },
{
path: 'feature-1',
loadChildren: () => import('./feature-1/feature-1.module').then(m => m.Feature1Module)
},
{
path: 'feature-2',
loadChildren: () => import('./feature-2/feature-2.module').then(m => m.Feature2Module) }
}
])
],
declarations: [AppComponent, HelloComponent],
bootstrap: [AppComponent],
providers: [Shared3Service]
})
export class AppModule {}
In this example app, we have three components; two are lazily loaded modules while one is our landing home component. We also have three different services we will use in the application. Let's start with the first service and see how it is used.
import { Injectable } from '@angular/core';
console.log('SharedService bundled because two components use it');
@Injectable({
providedIn: 'root'
})
export class SharedService {
constructor() {
console.log('SharedService instantiated');
}
}
Our first service uses the tree shakable providers API. We import this service twice once in each of our lazy loaded feature modules like below.
import { Component, OnInit } from '@angular/core';
import { SharedService } from './../shared.service';
@Component({
selector: 'app-feature-1',
templateUrl: './feature-1.component.html',
styleUrls: ['./feature-1.component.css']
})
export class Feature1Component implements OnInit {
constructor(private sharedService: SharedService) {}
ngOnInit() {}
}
Because Service 1 is used in both of our components, the code is loaded and bundled into our app. If we inspect the console we see the following message:
SharedService bundled because two components use it
Our second service in our application Service 2 looks like this:
import { Injectable } from '@angular/core';
console.log('Shared2Service is not bundled because it not used');
@Injectable({
providedIn: 'root'
})
export class Shared2Service {
constructor() {}
}
If we inspect the console, we do not see the log message. That is because this service is not used in either of our feature modules or components. Since it's not used the code is not bundled and loaded.
Lastly our third service similar to the previous two looks like the following:
import { Injectable } from '@angular/core';
console.log('Shared3Service bundled even though not used');
@Injectable()
export class Shared3Service {
constructor() {}
}
If we look at the console we see the following message:
Shared3Service bundled even though not used
Because Shared3Service
is registered with the older providers API, it creates an explicit dependency because of the import statement needed to register. The import statement causes the build system to include and load this code even though no component uses it.
Between these three services, we can see the characteristics of how the tree shaking systems include or remove code in our applications. With the TSP API, our services are still singletons even for services used in lazily loaded modules like in our example. If we load our example app, we will notice that if we route between feature one and feature two the console log in the SharedService
is only called once. Once a module is requested Angular will instantiate and ensure that instance is used for the rest of the life of the application.
Angular Tree Shakeable Providers give us better performance in our applications as well as reducing the amount of boilerplate code needed to create injectable services. Check out the full working demo application in the link below!