Opt-Out Zone.js
Published on February 27, 2023
When I started working with Angular, I wasn’t even aware of the different change detection strategies.
I wrote my angular app and stumbled on performance problems, when trying to solve the performance problems I stumbled on the OnPush
change detection strategy.
When working with OnPush
I started digging in to Zone.js and discovered that angular can work without zone.js in a manual CD (In this article CD refers to Change Detection) mode.
You can set your entire tree of components to work without Zone.js or you can work in an “Hybrid” mode where some components are working without Zone.js and some are working with Zone.js (in OnPush
or default).
Default and OnPush CD
Up until now we learned about 2 CD strategies, the default and OnPush
.
Let’s do a short recap of those 2 strategies:
Default CD
To describe the default CD strategy in a sentence: When something happens, all components are marked as dirty, and binded template values are recalculated from root component and down the tree (certain dom elements that are connected to the binded values might get updated).
OnPush CD
An instance of a component is represented by a ComponentRef
that instance holds and instance of a class representing the view that is called ViewRef
.
The ViewRef
has a dirty flag that represents if the component template binded values needs to be recalculated (and view might need to be updated).
With the default all the dirty flag of all the components are marked as dirty when something happens, with OnPush
that flag is marked as dirty only on certain conditions (and when those conditions happen it doesn’t mean the dirty is marked only on one component):
- Event like a button click will cause CD from the root component and down the component where the event happened. it will then go down until a child with
OnPush
is found (if all components are inOnPush
it will be from the root component until the component that triggered the event). - input change - if the parent is going through CD and changing the input of the child, then even if the child is in
OnPush
change detection will be triggered. async
pipe - even if something should not trigger CD, but it is changing something that is connected in the template in anasync
pipe, it will trigger CD from top to the component with theasync
pipe.- calling
detectChanges()
ormarkForCheck()
on theChangeDetectorRef
service.markForCheck()
will mark as dirty from the root component to the current component,detectChanges()
will go down the tree from the component that called it and down the tree until it stumbles a child withOnPush
strategy.
Zoneless
We actually have a 3rd change detection strategy, and that is going zoneless on certain parts of the component tree (or all your component tree).
In this mode you have to manually trigger CD by calling detectChanges()
either directly or by using some sort Pipe
or Directive
that calls detectChanges
for you.
Another way you can trigger CD in zoneless mode is by using ApplicationRef.tick()
that will trigger CD in the entire components in the tree of components.
The result of a zoneless strategy is that the amount of times a component will be marked as dirty will be reduced, and the amount of times the template will be recalculated will be reduced as well.
recommendations
We highly recommend not to use the default automatic CD strategy.
I would start by setting all the components to the OnPush strategy (you can set angular cli to create new component in OnPush
, and there is also a lint rule to enforce components are in OnPush
).
After that is out of the way and all your components are in the OnPush
you can start playing with the zoneless strategy on part of your tree, extending that part according to performance bottlenecks you might encounter.
setting angular to work zoneless
We are going to do the following:
- start a new angular application
- remove zone.js from the bundle
- tell angular to opt-out of zone.js
Generate a new angular application
When asked we will choose not to add Angular routing.
And when asked which styling format we will choose CSS.
We are not going to focus on styling or routing in this lesson.
After generating out project is done you can open the project in your IDE.
Comment Zone.js
Open the file src/polyfills.ts
and comment the line that imports zone.js.
Opt-Out Zone.js
In the entry point file src/main.ts
you can call bootstrapModule
with an options to set ngZone
as noop.
Check that angular is not working with Zone.js
Let’s check that our angular app is now working without Zone.js.
In the app.component.ts
we will create a simple counter component that increments a counter by one every time a button is clicked:
Launch the app with ng serve
and you can click the button and see that the view is not updated with the new value of the counter.
Manually trigger CD
If we actually want the view to be updated with the new counter value, we will have to manually trigger CD.
You can also inject the NgZone
service and you will notice that in the current application setup that service is the NoopNgZone
service.
How Change Detection works without Zone.js
What we are going to create to check how CD works when you are zoneless, is a components tree that is made from 3 components:
AppComponent
- the root componentChildComponent
- is a component that is placed by theAppComponent
GrandChildComponent
- is a component that is placed by theChildComponent
based on this simple app we will try to understand how CD works when you are zoneless.
Take a look at the editor above, the following app is zoneless, we have 3 components and the ChildComponent
has 2 buttons to trigger CD.
The 2 buttons trigger CD in different ways, one will use ChangeDetectorRef.detectChanges()
and the other button will use ApplicationRef.tick()
.
When CD happens the template binded values needs to be recalculated so we have a log()
method in each component that will log to the console when the component is being recalculated.
When you click the button that trigger CD with detectChanges()
in the ChildComponent
you will see that the ChildComponent
and the GrandComponent
are being recalculated, but the AppComponent
is not being recalculated. When calling detectChanges()
on the ChangeDetectorRef
service it will trigger CD from the component that called it and down the tree of components.
Is it always that detectChanges()
will go down the tree? Go to the editor and try and set the GrandComponent
to changeDetection: ChangeDetectionStrategy.OnPush
and see what happens when you click the button that triggers CD with detectChanges()
.
detectChanges()
works the same here like OnPush
it will go down the tree if it stumbles on an OnPush
component (and non of the other conditions that trigger CD in OnPush are happening) it will stop there. Remember that if the component is not in OnPush
it simply means that if my parent is marked as dirty I will be marked as dirty as well, with the OnPush
the parent marked as dirty does not necessarily mean that the child will be marked as dirty as well.
When you click the button that triggers CD with ApplicationRef.tick()
you will see that the CD starts from the AppComponent
and down the tree of components until it reaches the component that triggered the tick()
method, from there if the GrandComponent
is in OnPush
it will stop there (if not the GrandComponent
will be marked as dirty).
So even if you work in zoneless there are still benefits to setting all your components to OnPush
strategy if you want to minimize the components that will recalculate their template binded values.
Play a bit with the playground we created above to fully understand how tick()
and detectChanges()
works in a angular app (zoneless or not)
to summarize if all components are in OnPush
and you are working in zoneless mode, then you will have to trigger the CD manually with detectChanges()
or tick()
.
tick()
will go from the root component down the tree to the component that called tick()
and stop at that component (remember that all the components are set to OnPush
).
detectChanges()
will trigger CD in the component that called it and only on that component (in this case where all are in OnPush
).
bundle size reduce
a benefit of working without zone.js is that the bundle size will be reduced.
Let’s examine how much reduction we get when we remove zone.js from the bundle.
So what we are going to do is build our angular app with zone.js and check the bundle size, and build without zone.js and check the bundle size and compare between the 2. We will build the same application we created above with the 3 components to check the change detection. That app already has zone.js removed, so to build that app we can run:
After the build is done it prints the file size for us, we got the following result:
Names | Raw Size | Estimated Transfer Size |
---|---|---|
main | 89.13 kB | 26.69 kB |
runtime | 912 bytes | 515 bytes |
polyfills | 132 bytes | 100 bytes |
styles | 0 bytes | - |
Now let’s restore zone.js to our bundle and check the file size again:
Names | Raw Size | Estimated Transfer Size |
---|---|---|
main | 89.12 kB | 26.68 kB |
runtime | 1.06 kB | 607 bytes |
polyfills | 33.09 kB | 10.63 kB |
styles | 0 bytes | - |
The main benefit in terms of file size is in the polyfills
file, the polyfills
file is reduced by a little more than 32 kB when zone.js is removed from the bundle.
Reduction in bundle size is not that significant.
Library support in zoneless
If we are working with zoneless and we are using 3rd party library that is relying on zone.js for change detection, we might encounter problems.
We will examine the problem and the solution to the problem in the next example.
In the following example we removed zone.js, we then installed @angular/material and we are trying to use angular material checkbox component and see how it works in zoneless.