Part 3 Angular Change Detection - The cycle and the job
When I use the word change detection, this is not specific enough in
Angular. It can be referred as change detection cycle, or it can be referred
as change detection job. The cycle refer to the process of recursive
updating the hierachy of angular views. In the context of each component, the
job refers to the work of comparing the current state and previous state of
the component, updating the view of the component when there is a difference.
In the last post, I mentioned. Zone.js will call applicationRef.tick()
, and it
will start a change detection cycle. During the cycle, the job can
happen depending on some condition. But in the cycle, what does it actually do?
This is a pretty complicated process. Here is some very high-level pseudo code,
and it omits lots of details, let’s just focus on the high-level process.
function application_ref_tick() {
refreshView(rootView);
}
function refreshView(view) {
if (view.template_function && (view.always_check || view.is_dirty)) {
// aka dirty checking, change detection
view.tempalte_function(flags.update);
}
// ngOnInit, ngDoCheck, ngOnChanges for child views
execute_pre_order_hooks_of_child_components(view);
// ngOnConentInit, ngOnContentChecked for child views
execute_content_hooks_of_child_components(view);
for (let view of view.Children) {
if (view.always_check || view.is_dirty) {
refreshView(view);
}
}
// ngAfterViewChecked for child views
exeucte_view_check_hooks(view);
}
view.detectChanges() = {
refereshView(view);
}
In Angular runtime, each component instance is associated with a lview
array
and tview
array. Let’s simplify with a view object. What do we take away from
the code?
-
the
refreshView
function calls itself recursively from the top view to the leaf views. -
The actual change detection job (aka dirty-checking) is calling the template function in update mode, it may or may not happen in the cycle.
-
For each view, its life cycle hooks are called when refreshing its parent view.
-
Change detection is executed only if a view is marked always_check, is_dirty.
-
If a view is not marked always_check or is_dirty, its parent view will not refresh it. But its life cycle hooks are still being called by its parent view.
-
When a cycle start, if all components are marked always checked, all the cycle hooks and all the template functions of all components will be called.
It seems to be very low efficiency. But in a lot of cases, it is ok, because this cycle is running very fast. The following cases can make an angular app very slow.
- The component tree gets bigger, like thousands of them
- There are some events triggered very fast, such as keyboard events
- Some setTimeIntervals fired aggressively.
- The DOM update requires a heavy repaint.
So let’s revisit the best practices in the first post and the rationale behind them.
Why Use OnPush Component everywhere?
OnPush component is the component with always_check flags equal to false.
When Angular binds events such as click
, keypress
to a model method,
Angular wraps the handler first in a higher order function. So that when
those events trigger, angular will the following method first.
markViewDirty(startView);
What it does is to find the start view inside which the event is generated, and
mark the start view and its ancestors dirty. If a OnPush
component is not in
this hierarchy, the component is not dirty-checked.
In the following examples, all the components are OnPush
components.
Click on the tick()
button on all components, and pay attention to the output of the console.
- click on child2, it will only trigger dirty checking on root, and child2.
- click on child1, it will only trigger dirty checking on root, and child1
- click on grandson, it will only trigger dirty checking on root, and child1, and grandson.
Why use observable everywhere?
In my first post of this series, I showcase that after changing a component to
OnPush
, all the state changes inside setTimeout, setInterval, and promise
will not be detected anymore? Isn’t that Zone.js all ready monkey-patched
them to trigger the change detection cycle
? Yes, they did trigger the cycle.
But in the cycle, they are not dirty-checked, because the view is OnPush
.
Unlike event handlers for DOM events, such as click, Angular cannot
patch the callback in these api to mark the containing view dirty.
That is why containing view is not dirty-checked.
The proposed solution is that use observable? So the setInterval
is changed to
interval
observable. But this observable is still based on setInterval
,
if we manually subscribe it, the state change inside the call back will
not be dirty-checked. This is the same for observable created by httpClient
.
So the solution is to use async
pipe to subscribe observables in the template
directly. Why this jobs. Here is a code snippet from async
` pipe
private _updateLatestValue(async: any, value: Object): void {
if (async === this._obj) {
this._latestValue = value;
// Note: `this._ref` is only cleared in `ngOnDestroy` so is known to be available when a
// value is being updated.
this._ref!.markForCheck();
}
}
It jobs because it marks the view as dirty after subscription. Our manual subscription
of observable will also job when we add the call markForCheck(
)`.
Why separate big components into smaller ones?
Besides the benefit from the design perspective, this can reduce change detection. If lots of widgets live in the same component, every change detection cycle caused by one widget will cause the change detection of all other widgets.
Grouping different widgets in OnPush
components will reduce this unnecessary
change detection for other widgets
Why “Micro” Optimization?
In the timer examples, although the interval
observable does job,
it still causes a lot of change detection cycles
, which can affect the whole
component tree. The best way to optimize it is to run the setTimeout outside of
Zone.js, and call cd.detectChanges()
to trigger local dirty checking. By doing
this we can avoid triggering any change detection cycle
. Here
is the factory app.