Here I would like to explore some aspects of how the use of the new Ivy compiler (which appeared in Angular 9) can greatly facilitate the rejection of the use of zone.js.
By abandoning this library, I was able to significantly increase the performance of the Angular application running under heavy load. At the same time, I managed to implement the mechanisms I needed using TypeScript decorators, which led to very small additional costs of system resources.
Please note that the approach to optimizing Angular applications, presented in this article, is possible only because Angular Ivy and AOT are enabled by default. This article is written for educational purposes, it is not aimed at promoting the approach presented in it to the development of Angular projects.
Why might you need to use Angular without zone.js?
Before we continue, let's ask one important question: “Is it worth getting rid of zone.js, given that this library helps us to re-render templates with little effort?” Of course, this library is very useful. But, as usual, you have to pay for everything.
If your application has specific performance requirements, disabling zone.js can help meet those requirements. An example of an application in which performance is crucial is a project whose interface is updated very often. In my case, such a project turned out to be a real-time trading application. Its client part constantly receives messages via the WebSocket protocol. The data from these messages should be displayed as quickly as possible.
Remove zone.js from Angular
Angular can very easily be made to work without zone.js. To do this, you must first comment out or delete the corresponding import command, which is located in the
polyfills.ts
file.
Commented out zone.js import command
Next - you need to equip the root module with the following options:
platformBrowserDynamic() .bootstrapModule(AppModule, { ngZone: 'noop' }) .catch(err => console.error(err));
Angular Ivy: detect changes yourself with detectChanges and ɵmarkDirty
Before we can start creating a TypeScript decorator, we need to learn about how Ivy allows you to invoke the process of detecting component changes, making it dirty, and bypassing zone.js and DI.
Two additional functions are now available to us, exported from
@angular/core
. These are
ɵdetectChanges
and
ɵmarkDirty
. These two functions are still intended for internal use and are unstable - at the beginning of their names is the symbol
ɵ
.
Let's look at how to use these features.
▍ ɵmarkDirty Function
This function allows you to mark a component, making it “dirty,” that is, in need of re-rendering. She, if the component was not marked “dirty” before its invocation, plans to launch the change detection process.
import { ɵmarkDirty as markDirty } from '@angular/core'; @Component({...}) class MyComponent { setTitle(title: string) { this.title = title; markDirty(this); } }
▍ ɵdetectChanges Function
Angular internal documentation states that, for performance reasons, you should not use
ɵdetectChanges
. Instead, it is recommended to use the
ɵmarkDirty
function. The
ɵdetectChanges
function synchronously invokes the process of detecting changes in a component and its subcomponents.
import { ɵdetectChanges as detectChanges } from '@angular/core'; @Component({...}) class MyComponent { setTitle(title: string) { this.title = title; detectChanges(this); } }
Automatically detect changes using TypeScript decorator
Although the features provided by Angular increase the usability of development by letting the DI go around, the programmer can still be frustrated by the fact that he needs to import and call these functions himself to start the change detection process.
In order to simplify the automatic start of change detection, you can write a TypeScript decorator, which will independently solve this problem. Of course, there are some limitations, which we will discuss below, but in my case, this approach turned out to be exactly what I needed.
▍Introducing the @observed decorator
In order to detect changes, making as little effort as possible, we will create a decorator that can be applied in three ways. Namely, it is applicable to the following entities:
- To synchronous methods.
- Observable objects.
- To ordinary objects.
Consider a couple of small examples. In the following code fragment, we apply the
@observed
decorator to the
state
object and to the
changeTitle
method:
export class Component { title = ''; @observed() state = { name: '' }; @observed() changeTitle(title: string) { this.title = title; } changeName(name: string) { this.state.name = name; } }
- To check for changes to the
state
object, we use a proxy object that intercepts changes to the object and calls the procedure for detecting changes. - We override the
changeTitle
method by applying a function that first calls this method and then starts the change detection process.
And here is an example with
BehaviorSubject
:
export class AppComponent { @observed() show$ = new BehaviorSubject(true); toggle() { this.show$.next(!this.show$.value); } }
In the case of Observable objects, using a decorator looks a bit more complicated. Namely, you need to subscribe to the observed object and mark the component as “dirty” in the subscription, but you also need to clear the subscription. In order to do this, we reassign
ngOnInit
and
ngOnDestroy
to subscribe and for its subsequent cleaning.
▍Creating a decorator
Here is the
observed
decorator signature:
export function observed() { return function( target: object, propertyKey: string, descriptor?: PropertyDescriptor ) {} }
As you can see,
descriptor
is an optional parameter. This is because we need the decorator to be applied to both methods and properties. If the parameter exists, it means that the decorator is applied to the method. In this case, we do this:
- Save the
descriptor.
propertydescriptor.
value
. - We redefine the method as follows: call the original function, and then call
markDirty(this)
in order to start the change detection process. Here's what it looks like:
if (descriptor) { const original = descriptor.value; // descriptor.value = function(...args: any[]) { original.apply(this, args); // markDirty(this); }; } else { // }
Next, you need to check with what type of property we are dealing with. It can be an Observable object or a regular object. Here we will use another internal Angular API. It, I believe, is not intended for use in regular applications (sorry!).
We are talking about the
ɵcmp
property, which gives access to the properties processed by Angular after they are defined. We can use them to override the methods of the
onInit
and
onDestroy
.
const getCmp = type => (type).ɵcmp; const cmp = getCmp(target.constructor); const onInit = cmp.onInit || noop; const onDestroy = cmp.onDestroy || noop;
In order to mark a property as one to be monitored, we use
ReflectMetadata
and set its value to
true
. As a result, we will know that we need to observe the property when the component is initialized:
Reflect.set(target, propertyKey, true);
Now it's time to override the
onInit
hook and check the properties when creating the component instance:
cmp.onInit = function() { checkComponentProperties(this); onInit.call(this); };
We define the
checkComponentProperties
function, which will bypass the properties of the component, filtering them according to the value set previously using
Reflect.set
:
const checkComponentProperties = (ctx) => { const props = Object.getOwnPropertyNames(ctx); props.map((prop) => { return Reflect.get(target, prop); }).filter(Boolean).forEach(() => { checkProperty.call(ctx, propertyKey); }); };
The
checkProperty
function will be responsible for decorating individual properties. First, we check if the property is an Observable or a regular object. If this is an Observable object, we subscribe to it and add the subscription to the list of subscriptions stored in the component for its internal needs.
const checkProperty = function(name: string) { const ctx = this; if (ctx[name] instanceof Observable) { const subscriptions = getSubscriptions(ctx); subscriptions.add(ctx[name].subscribe(() => { markDirty(ctx); })); } else { // } };
If the property is an ordinary object, then we will convert it to a Proxy object and call
markDirty
in its
handler
function:
const handler = { set(obj, prop, value) { obj[prop] = value; ɵmarkDirty(ctx); return true; } }; ctx[name] = new Proxy(ctx, handler);
Finally, you need to clear the subscription after destroying the component:
cmp.onDestroy = function() { const ctx = this; if (ctx[subscriptionsSymbol]) { ctx[subscriptionsSymbol].unsubscribe(); } onDestroy.call(ctx); };
The possibilities of this decorator cannot be called comprehensive. They do not cover all the options for its possible application that may appear in a large application. For example, these are calls to template functions that return Observable objects. But I'm working on it.
Despite this, the above decorator is enough for my small project. You will find its full code at the end of the material.
Analysis of application acceleration results
Now that we’ve talked a bit about Ivy’s internal mechanisms, and how to create a decorator using these mechanisms, it's time to test what we’ve got in a real application.
I, in order to find out the effect of getting rid of zone.js on the performance of Angular applications, used my Cryptofolio hobby project.
I applied the decorator to all the necessary links used in the templates and disabled zone.js. For example, consider the following component:
@Component({...}) export class AssetPricerComponent { @observed() price$: Observable<string>; @observed() trend$: Observable<Trend>; // ... }
Two variables are used in the template:
price
(the asset price will be located here) and
trend
(this variable can take values
up
,
stale
and
down
, indicating the direction of the price change). I decorated them with
@observed
.
▍ Project bundle size
To begin, let's take a look at how much the size of the project bundle has decreased while getting rid of zone.js. Below is the result of building the project with zone.js.
Result of building a project with zone.js
And here is the assembly without zone.js.
The result of building a project without zone.js
Pay attention to the
polyfills-es2015.xxx.js
. If the project uses zone.js, then its size is approximately 35 Kb. But without zone.js - only 130 bytes.
▍Booting
I researched two application options using Lighthouse. The results of this study are given below. It should be noted that I would not take them too seriously. The fact is that, trying to find the average values, I got significantly different results by performing several measurements for the same application version.
Perhaps the difference in evaluating the two application options depends only on the size of the bundles.
So, here is the result obtained for an application that uses zone.js.
Analysis results for an application that uses zone.js
And here is what happened after analyzing the application in which zone.js is not used.
Analysis results for an application that does not use zone.js
▍ Performance
And now we got to the most interesting. This is the performance of an application running under load. We want to learn about how the processor feels when the application displays price updates for hundreds of assets several times per second.
In order to load the application, I created 100 entities that provide conditional data at prices that change every 250 ms. If the price rises, it is displayed in green. If reduced - red. All this could well seriously load my MacBook Pro.
It should be noted that while working in the financial sector on several applications designed for high-frequency transmission of data fragments, I have come across a similar situation many times.
To analyze how different versions of the application use processor resources, I used the Chrome developer tools.
Here's what the application that uses zone.js looks like
System load created by an application that uses zone.js
And here is how an application works in which zone.js is not used.
System load created by an application that does not use zone.js
We analyze these results, paying attention to the processor load graph (yellow):
- As you can see, an application that uses zone.js constantly loads the processor by 70-100%! If you keep the browser tab open for a long time, creating such a load on the system, then the application running in it may well fail.
- And the version of the application where zone.js is not used creates a stable load on the processor in the range from 30 to 40%. Wonderful!
Please note that these results were obtained with the Chrome Developer Tools window open, which also puts a strain on the system and slows down the application.
▍ load increase
I tried to make sure that every entity responsible for updating the price would produce 4 more updates every second in addition to what it already produces.
Here is what we managed to find out about the application in which zone.js is not used:
- This application normally coped with the load, now using about 50% of the processor resources.
- He managed to load the processor as much as the application with zone.js, only when prices were updated every 10 ms (new data, as before, came from 100 entities).
▍ Performance Analysis with Angular Benchpress
The performance analysis that I conducted above cannot be called particularly scientific. For a more serious study of the performance of various frameworks, I would recommend using this benchmark . For research, Angular should choose the usual version of this framework and its version without zone.js.
I, inspired by some ideas of this benchmark, created a project that performs heavy calculations. I tested its performance with Angular Benchpress .
Here is the code of the tested component:
@Component({...}) export class AppComponent { public data = []; @observed() run(length: number) { this.clear(); this.buildData(length); } @observed() append(length: number) { this.buildData(length); } @observed() removeAll() { this.clear(); } @observed() remove(item) { for (let i = 0, l = this.data.length; i < l; i++) { if (this.data[i].id === item.id) { this.data.splice(i, 1); break; } } } trackById(item) { return item.id; } private clear() { this.data = []; } private buildData(length: number) { const start = this.data.length; const end = start + length; for (let n = start; n <= end; n++) { this.data.push({ id: n, label: Math.random() }); } } }
I launched a small set of benchmarks using Protractor and Benchpress. The operations were performed a specified number of times.
Benchpress in action
results
Here is a sample of the results obtained using Benchpress.
Benchpress Results
Here is an explanation of the indicators presented in this table:
-
gcAmount
: gc operations volume (garbage collection), Kb. -
gcTime
: gc operation time, ms. -
majorGcTime
: time of the main operations gc, ms. -
pureScriptTime
: script execution time in ms, excluding gc operations and rendering. -
renderTime
: rendering time, ms. -
scriptTime
: script execution time taking into account gc operations and rendering.
Now we will consider the analysis of the implementation of some operations in various application variants. Green shows the results of an application that uses zone.js, orange shows the results of an application without zone.js. Please note that only the rendering time is analyzed here. If you are interested in all test results, check here .
Test: creating 1000 lines
In the first test, 1000 lines are created.
Test results
Test: creating 10,000 rows
As the load on applications grows, so does the difference in their performance.
Test results
Test: appending 1000 lines
In this test, 1000 lines are appended to 10,000 lines.
Test results
Test: delete 10,000 rows
Here, 10,000 lines are created, which are then deleted.
Test results
TypeScript Decorator Source Code
Below is the source code of the TypeScript decorator discussed here. This code can also be found here .
// tslint:disable import { Observable, Subscription } from 'rxjs'; import { Type, ɵComponentType as ComponentType, ɵmarkDirty as markDirty } from '@angular/core'; interface ComponentDefinition { onInit(): void; onDestroy(): void; } const noop = () => { }; const getCmp = <T>(type: Function) => (type as any).ɵcmp as ComponentDefinition; const subscriptionsSymbol = Symbol('__ng__subscriptions'); export function observed() { return function( target: object, propertyKey: string, descriptor?: PropertyDescriptor ) { if (descriptor) { const original = descriptor.value; descriptor.value = function(...args: any[]) { original.apply(this, args); markDirty(this); }; } else { const cmp = getCmp(target.constructor); if (!cmp) { throw new Error(`Property ɵcmp is undefined`); } const onInit = cmp.onInit || noop; const onDestroy = cmp.onDestroy || noop; const getSubscriptions = (ctx) => { if (ctx[subscriptionsSymbol]) { return ctx[subscriptionsSymbol]; } ctx[subscriptionsSymbol] = new Subscription(); return ctx[subscriptionsSymbol]; }; const checkProperty = function(name: string) { const ctx = this; if (ctx[name] instanceof Observable) { const subscriptions = getSubscriptions(ctx); subscriptions.add(ctx[name].subscribe(() => markDirty(ctx))); } else { const handler = { set(obj: object, prop: string, value: unknown) { obj[prop] = value; markDirty(ctx); return true; } }; ctx[name] = new Proxy(ctx, handler); } }; const checkComponentProperties = (ctx) => { const props = Object.getOwnPropertyNames(ctx); props.map((prop) => { return Reflect.get(target, prop); }).filter(Boolean).forEach(() => { checkProperty.call(ctx, propertyKey); }); }; cmp.onInit = function() { const ctx = this; onInit.call(ctx); checkComponentProperties(ctx); }; cmp.onDestroy = function() { const ctx = this; onDestroy.call(ctx); if (ctx[subscriptionsSymbol]) { ctx[subscriptionsSymbol].unsubscribe(); } }; Reflect.set(target, propertyKey, true); } }; }
Summary
Although I hope that you liked my story about optimizing the performance of Angular projects, I also hope that I did not incline you to rush to remove zone.js from your project. The strategy described here should be the very last resort that you can resort to in order to increase the performance of your Angular application.
First you need to try such approaches as using the OnPush change detection strategy, applying
trackBy
, disabling components, executing code outside zone.js, blacklisting zone.js events (this list of optimization methods can be continued). The approach shown here is quite expensive, and I'm not sure that everyone is willing to pay such a high price for performance.
In fact, development without zone.js may not be the most attractive thing. Perhaps this is not only for the person who is involved in the project, which is under his full control. That is - it is the owner of dependencies and has the ability and time to bring everything to its proper form.
If it turned out that you tried everything and believe that zone.js is the bottleneck of your project, then maybe you should try to speed up Angular by detecting changes yourself.
I hope this article allowed you to see what Angular expects in the future, what Ivy is capable of, and what can be done with zone.js to maximize application speed.
Dear readers! How do you optimize your Angular projects that need maximum performance?