This article is going to look at optimizing the runtime performance of an Angular Application, by focusing on change detection.
Back in the old days of AngularJS, people were a bit sceptical that Angular's magic change detection could coexist with strong UI performance. Interestingly, competitors at the time to Angular used data binding via explicit setter functions (e.g. KnockoutJS), which meant that the UI only updated when the developer made it update. It also meant that dealing with complex data types in bindings was harder (e.g. Knockout had to provide a whole set of array functions for its "observable array" type, but only provided a small fraction of what's available using plain JS arrays and lodash). Angular scans your whole model to detect changes, which is a lot easier for you as a developer, but it has performance implications.
Angular's documentation at the time warned people away from using more than a certain number of bindings per page, with some justification that if you were showing that much data then you were overloading your users' attention. The justification was pretty much nonsense, because bindings could be used for things such as classnames and individual CSS properties (colour, backgrounds, position, etc), not just textual pieces of information.
Modern Angular works a lot differently, but the key point regarding optimization and performance is still the same: If you give Angular's change detection a lot of things to look at, it's going to get slow at some point.
Fortunately, modern Angular is designed with optimization in mind, and makes it a lot easier to override the automatic change detection where necessary.
The first step of optimization is profiling
So, we've got an Angular application and UI interaction is starting to feel slow. Especially on lower powered mobile devices. This is no good. Where do we start?
When optimizing, we start by measuring. Otherwise we don't really know what effects changes are having.
Angular has some built in profiling ability to help measure the current performance of its change detection. It's worth enabling this and get a baseline figure of how slow your change detection is to begin with, so you can compare after you make changes.
To enable this, we need to enable debug tools in the application by adding it after bootstrapping the AppModule.
In main.ts:
import { ApplicationRef, enableProdMode } from '@angular/core';
import { enableDebugTools } from '@angular/platform-browser';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
if (environment.production) {
enableProdMode();
}
platformBrowserDynamic().bootstrapModule(AppModule)
// Enable debug tools
.then(moduleRef => {
if (!environment.production) {
const applicationRef = moduleRef.injector.get(ApplicationRef);
const componentRef = applicationRef.components[0];
enableDebugTools(componentRef);
}
})
.catch(err => console.log(err));
Then, from the JavaScript console, we can run this:
ng.profiler.timeChangeDetection()
/* =>
ng.profiler.timeChangeDetection()
platform-browser.js:1893 ran 32024 change detection cycles
platform-browser.js:1894 0.02 ms per check
ChangeDetectionPerfRecord {msPerTick: 0.015616412691226638, numTicks: 32024}
*/
ng.profiler.timeChangeDetection({record: true})
This function is not well documented, but Angular source code gives some clues as to what it actually does:
/**
* Exercises change detection in a loop and then prints the average amount of
* time in milliseconds how long a single round of change detection takes for
* the current state of the UI. It runs a minimum of 5 rounds for a minimum
* of 500 milliseconds.
*
* Optionally, a user may pass a `config` parameter containing a map of
* options. Supported options are:
*
* `record` (boolean) - causes the profiler to record a CPU profile while
* it exercises the change detector. Example:
*
* ```
* ng.profiler.timeChangeDetection({record: true})
* ```
*/
So, basically, it's running the change detection repeatedly and then printing the average time it takes to run. When the output says 0.02 ms per check
, you want to get that number as low as possible. Ideally, you also want to keep it fairly constant with respect to the number of components on the page.
With the {record: true}
option, timeChangeDetection will create a CPU profile under the 'JavaScript Profile' tab in the browser's debug tools, something like this:
It's important to note that browsers implement something called a sampling profiler. What does this mean? A traditional profiler hooks very deeply into the execution of the program and measures exactly the amount of time spent between entering a function and exiting that function. A sampling profiler doesn't hook deeply at all, it just inspects the current execution state at frequent intervals to build up an idea of where the program is spending its time. It's not as reliable as a traditional profiler, because a lot can happen when the profiler isn't looking. So you might have to run it a few times to get a good idea of what's going on, but it will give a ballpark estimate of the slow points in your code.
This gives you a baseline for measuring optimizations later.
Problematic patterns
Angular performance is a function of the number of components' bindings on screen. We're going to focus on components rather than individual bound data fields because components act as containers for both application logic and the change detection strategies.
Generally speaking, if you're using Angular to put a few simple form fields on screen, you're not going to see performance issues from Angular itself. So let's instead look at an example case where you might start making Angular struggle.
Since components act as nice abstractions for sections of interactive data, you might, for example, find yourself rendering a table where each cell is a component. With this kind of structure, you can easily end up with thousands of components on screen. Change detection will start lagging at this point, and this is a component type that is ripe for optimization!
A table is just one obvious example. There are plenty of situations where you might end up with a lot of components on the screen.
Optimization by overriding change detection
Fortunately, Angular lets the developer take control over the change detection strategy such that we can exempt components from being checked for changes and instead push changes ourselves to the change detector.
Let's say we have a really simple table cell component that displays a piece of data and has a click event to load and render extra information from an external resource.
The template might look something like this:
<td (click)='click()'>
<div class='data'> {{ data }} </div>
<div class='info' *ngIf='info && !loading'> {{ info }} </div>
<div class='spinner' *ngIf='loading'> </div>
</td>
So, we have a click event handler, two separate pieces of text data, and a loading spinner for when the second data item is being fetched.
The component is nicely self contained. This means that we can take full control within this component over the lifecycle of the change rendering of this component, because no other component is going to modify or otherwise interfere with it.
So we can tell Angular to exclude this component from change detection. And, instead, we will tell Angular when something changes.
To take control over change detection, we set changeDetection = ChangeDetectionStrategy.OnPush
in the component definition.
To request that Angular runs change detection for this component, we inject ChangeDetectorRef
and use its ChangeDetectorRef.detectChanges()
method.
Putting it all together, it looks something like this:
@Component({
selector: 'app-your-component',
templateUrl: './your-component.component.html'
styleUrls: ['./your-component.component.scss'],
/**
* Use the `CheckOnce` strategy, meaning that automatic change detection is deactivated
* until reactivated by setting the strategy to `Default` (`CheckAlways`).
* Change detection can still be explicitly invoked.
* This strategy applies to all child directives and cannot be overridden.
*/
changeDetection: ChangeDetectionStrategy.OnPush,
});
export class AppYourComponent {
data: string = '';
info: string = '';
loading: boolean = false;
constructor(
private cdr: ChangeDetectorRef,
private remoteService: RemoteService, // for demonstration purposes only
) { }
/**
* Inform the change detector that our state has changed
*/
private stateHasChanged() {
this.cdr.detectChanges();
}
click() {
if (this.loading) { return; }
this.loading = true;
this.info = '';
// Update the change detector so the spinner shows
// while the remote request is in progress.
this.stateHasChanged();
// Imagine this fetches some data from somewhere and returns
// an observable containing the fetched data
return this.remoteService.fetch(this.data)
.subscribe((response) => {
this.info = response;
},
(err: Error) => {
this.info = 'An error occurred'
})
.add(() => this.loading = false)
// After we've fetched the information,
// inform the change detector.
.add(() => this.stateHasChanged())
}
}
Now, when the application's change detection is triggered, this component will be excluded from being checked. But we've made sure that when something happens to the component that may change its state, we are explicitly telling Angular to take another look at it. With thousands of these components on screen, this is a huge performance and optimization win.