Improve Angular’s performance through memoization and TypeScript decorators
AngularInDepth is moving away from Medium. More recent articles are hosted on the new platform inDepth.dev. Thanks for being part of indepth movement!
Change detection is one the most important/complex topics in Angular, if you don’t get it right, you may harm the performance of your Angular app.
Let’s see a simple example.
@Component({
selector: 'my-app'
template: `
<h1>{{getTitle(framework)}}</h1>
<button (click)="changeFramework()">Change Framework</button>
<hr>
<h4>Number: {{count}}</h4>
<button (click)="counterAdd()">Add</button>
`,
})
export class AppComponent {
framework = 'Angular';
count = 0;
getTitle(framework: string): string {
console.log('getTitle is called');
return `I love ${framework.toUpperCase()}`;
}
changeFramework() {
if (this.framework === 'Angular') {
this.framework = 'React';
} else {
this.framework = 'Angular';
}
}
counterAdd() {
this.count += 1;
}
}
When it is running, it will simply look like this:
You might have already noticed the problem. As the UI event occurs, Angular runs change detection and triggers all callbacks in a template. Specifically, every time the “Change Framework” button is clicked, the method getTitle
is called, which is not a problem. However, when “Add” button is clicked, the non-related getTitle
is also called, which could have been avoided to improve performance.
This example alone might not cause the performance drop even a bit. But imagine if the getTitle
method has heavy computation and even worse, you subscribed to scroll event instead, which is causing your possible expensive method getTitle
to be called multiple times within very small period of time. In this case, I won’t be surprised that your page would freeze. This did happen to me before.
How to solve this problem?
Memoization
We can see that getTitle
is a pure function whose output is based solely on its input. In this case, count
change shouldn’t change the output of getTitle
. You might have guessed the answer — cache, or more technically, memoization. Basically, if the function is called with the same inputs, we directly return the result in the cache.
Correct! But how can we do that?
There are tons of articles about how to use a higher-order-function to memoize a function. I won’t cover how to implement that function in this article. Let’s use a popular npm package memoizee.
Let’s get familiar with the package from a tiny usage example:
const memoizee = require('memoizee');
function add(a: number, b: number): number {
console.log('add is called');
return a + b;
}
const memoizedAdd = memoizee(add);
memoizedAdd(1,2); // log "add is called"
memoizedAdd(1,2); // cache hit, not logging
memoizedAdd(1,3); // log "add is called"
With the knowledge of this package. What we can do is to memoize the getTitle
method by replacing the getTitle
method with:
getTitle = memoizee(function (framework: string) {
console.log('getTitle is called');
return `I love ${framework.toUpperCase()}`;
});
And it works! Check it out:
As we can see, the click of “Add” button won’t trigger the body of the getTitle
method. And the “Change Framework” button will only trigger the method once as there are only two possible values "Angular"
and "React"
and they are all cached.
However, the current solution seems to be not very readable.
Can we do better?
Yes. That’s where the TypeScript decorator should come in.
TypeScript Decorator
Let create a decorator memoize
:
import * as memoizee from 'memoizee';
export function memoize() {
return function(target, key, descriptor) {
const oldFunction = descriptor.value;
const newFunction = memoizee(oldFunction);
descriptor.value = function () {
return newFunction.apply(this, arguments);
};
};
};
And all we need to do is just apply the decorator @memoize()
:
@memoize()
getTitle(framework: string) {
console.log('getTitle is called');
return `I love ${framework.toUpperCase()}`;
}
It has exact same effect as the previous non-graceful solution, but it’s much more readable and, yeah fancy.
This might seem complex if you are new to TypeScript decorator, but basically this decorator modifies the old method and apply the higher-order-function memoizee
and replace the old method with the new memoized method. If you want to learn more about TypeScript decorator, Max, Wizard of the Web has a nice post about Implementing custom component decorator in Angular.
Some extra notes:
- Methods/functions to be memoized need to be pure.
- There are multiple configurations in the memoizee npm package and you can change your cache strategy by passing config object to
@memoize(config)
and modify your memoize decorator by taking inconfig
as a parameter and pass tomemoizee
function:
export function memoize(config?) {
return function(target, key, descriptor) {
const oldFunction = descriptor.value;
const newFunction = memoizee(oldFunction, config);
descriptor.value = function () {
return newFunction.apply(this, arguments);
};
};
};