Angular Ivy Change Detection: Are you prepared?
Let’s see what Angular does for us.
Disclaimer: This is just my way of learning about Angular’s new renderer.
Evolution of the Angular view engine
While the significance of the new Ivy renderer has yet to fully unfold, many are wondering how it will work and the changes it has in store for us.
In this article, I’ll show Ivy change detection, show something that I’m really excited about, and build a simple app from scratch with instructions (similar to the Angular Ivy instructions).
First, introduce the app I will study next:
@Component({
selector: 'my-app',
template: `
<h2>Parent</h2>
<child [prop1]="x"></child>
`
})
export class AppComponent {
x = 1;
}
@Component({
selector: 'child',
template: `
<h2>Child {{ prop1 }}</h2>
<sub-child [item]="3"></sub-child>
<sub-child *ngFor="let item of items" [item]="item"></sub-child>
`
})
export class ChildComponent {
@Input() prop1: number;
items = [1, 2];
}
@Component({
selector: 'sub-child',
template: `
<h2 (click)="clicked.emit()">Sub-Child {{ item }}</h2>
<input (input)="text = $event.target.value">
<p>{{ text }}</p>
`
})
export class SubChildComponent {
@Input() item: number;
@Output() clicked = new EventEmitter();
text: string;
}Copy the code
I created an online demo to see how Ivy works behind the scenes: alexzuza.github. IO /ivy-cd/
The Demo uses the Angular 6.0.1 AOT compiler. You can click on any lifecycle block to jump to the corresponding code.
To run the change detection process, simply type something in the input box under sub-Child.
view
Of course, views are the main low-level abstraction in Angular.
For our example, we get something similar to the following:
Root view
|
|___ AppComponent view
|
|__ ChildComponent view
|
|_ Embedded view
| |
| |_ SubChildComponent view
|
|_ Embedded view
| |
| |_ SubChildComponent view
|
|_ SubChildComponent viewCopy the code
The view should describe the template and it should contain some data that reflects the structure of the template.
Let’s look at the ChildComponent view. It has the following templates:
<h2>Child {{ prop1 }}</h2>
<sub-child [item]="3"></sub-child>
<sub-child *ngFor="let item of items" [item]="item"></sub-child>Copy the code
The current view engine creates Nodes from the view definition factory and stores them in the Nodes array of the view definition.
Ivy creates LNodes from instructions, which are written to the ngComponentDef.template function, and stores them in the data array:
In addition to Nodes, the new view contains bindings in the data array (see Data [4], data[5], data[6] above). All bindings for a given view are stored, starting with bindingStartIndex, in the order they appear in the template.
Notice how I get the view instance from ChildComponent. Componentinstance. ngHostLNode contains references to component host nodes. (Another method is to inject ChangeDetectorRef)
In this way, Angular first creates the root view and locates the host element at index 0 of the Data array
RootView
data: [LNode]
native: root component selectorCopy the code
It then iterates through all the components and populates the Data array for each view.
Change detection
As you know, ChangeDetectorRef is just an abstract class with abstract methods like detectChanges, markForCheck, and so on.
When we ask about this dependency in the component constructor, we actually get a ViewRef instance that inherits from the ChangeDetectorRef class.
Now let’s look at the internal methods used to run change detection in Ivy. Some of these are available as public apis (markViewDirty and detectChanges), but I’m not sure about the others.
detectChanges
DetectChanges does change detection on component (and possibly its children) synchronization.
This function synchronously triggers change detection in the component. There should be little reason to call this function directly, and the preferred way to perform change detection is to use markDirty (see below) and wait for the scheduler to call this method at some future point. This is because a single user action typically invalidates many components, and it is inefficient to invoke change detection synchronously on each component. It is best to wait until all components are marked dirty and then perform a single change detection on all components.
tick
Used to perform change detection across the entire application.
This is equivalent to detectChanges, but called on the root component. In addition, the tick performs lifecycle hooks and conditionally checks components based on their ChangeDetectionStrategy and dirtiness.
export function tick<T>(component: T): void {
const rootView = getRootView(component);
const rootComponent = (rootView.context as RootContext).component;
const hostNode = _getComponentHostLElementNode(rootComponent);
ngDevMode && assertNotNull(hostNode.data, 'Component host node should be attached to an LView');
renderComponentOrTemplate(hostNode, rootView, rootComponent);
}Copy the code
scheduleTick
Used to schedule change detection for the entire application. Unlike TICK, scheduleTick combines multiple calls into a single change-detection run. When a view needs to be re-rendered, it is usually called indirectly by calling markDirty.
export function scheduleTick<T>(rootContext: RootContext) {
if (rootContext.clean == _CLEAN_PROMISE) {
let res: null|((val: null) => void);
rootContext.clean = new Promise<null>((r) => res = r);
rootContext.scheduler(() => {
tick(rootContext.component);
res !(null);
rootContext.clean = _CLEAN_PROMISE;
});
}
}Copy the code
markViewDirty(markForCheck)
Mark the current view and all ancestor views as dirty.
In early Angular 5, it only iterated up and enabled all parent view checks. Now notice that markForCheck does trigger the Ivy change detection cycle! 😮 😮 😮
export function markViewDirty(view: LView): void { let currentView: LView|null = view; while (currentView.parent ! = null) { currentView.flags |= LViewFlags.Dirty; currentView = currentView.parent; } currentView.flags |= LViewFlags.Dirty; ngDevMode && assertNotNull(currentView ! .context, 'rootContext'); scheduleTick(currentView ! .context as RootContext); }Copy the code
markDirty
Marks the component as dirty.
Components marked as dirty will be scheduled for change detection at some future time. Marking an already dirty component as dirty is a null operation. Only one outstanding change detection can be scheduled per component tree. (Two components bootstrapped with separate renderComponent will have separate schedulers)
export function markDirty<T>(component: T) {
ngDevMode && assertNotNull(component, 'component');
const lElementNode = _getComponentHostLElementNode(component);
markViewDirty(lElementNode.view);
}Copy the code
checkNoChanges
No change:)
When I was debugging the new change-detection mechanism, I noticed that I had forgotten to install zone.js. And, as you might have guessed, it has no dependencies, no Cdref.DetectChanges or Tick, and it still works perfectly.
Why is that?
You probably know that Angular only triggers change detection for onPush components (see my answer on StackOverflow).
The same rules apply to Ivy:
- One of the inputs has changed
Github.com/angular/ang…
- A binding event that is triggered by a component or its children
Github.com/angular/ang…
- Manually call markForCheck (now with the markViewDirty function (see below))
In SubChildComponent, there is the (input) Output binding. The second rule causes markForCheck to be called. Now that we know that this method actually calls change detection, it should be clear how it works without Zonejs.
What if the expression changes after detection?
Don’t worry, it’s still there
Change detection sequence
Since releasing Ivy, the Angular team has worked hard to ensure that the new engine handles all lifecycle hooks correctly in the right order. This means that the order of operations should be similar.
Max NgWizard K writes in his article (highly recommended reading it) :
As you can see, all the familiar operations are still here. But the order of operations seems to have changed. For example, it now looks like Angular checks child components first and then the embedded view. Since no compiler currently produces output suitable for testing my hypothesis, I can’t be sure.
Back to the child component of the demo:
<h2>Child {{ prop1 }}</h2>
<sub-child [item]="3"></sub-child>
<sub-child *ngFor="let item of items" [item]="item"></sub-child>Copy the code
I’m going to write a sub-child as a regular component before the other inline views.
Now watch it in action:
Angular checks the embedded view first and then the regular components. So there’s no change from the previous engine.
In any case, my demo has an optional “Run Angular compile” button so we can test other cases.
alexzuza.github.io/ivy-cd/
One-time string initialization
Imagine that we write a component that accepts colors as string input values. Now we want to pass this input as a constant string that never changes:
<comp color="#efefef"></comp>Copy the code
This is called one-time string initialization, as stated in the Angular documentation:
Angular sets it and then forgets about it.
To me, this means angular doesn’t do any extra checking on the binding. But what we actually see in angular5 is that it is checked during each change detection during the updateDirectives invocation.
See Netanel Basal’s article on this topic for Angular’s @Attribute decorator
Now let’s see how it looks in the new engine:
var _c0 = ["color", "#efefef"]; AppComponent. NgComponentDef = i0. ɵ defineComponent ({type: AppComponent, selectors: [[" my - app "]],... Class ɵE(0, "child", _c0);} // Class = new typicalmodule (new typicalmodule) {class = new typicalmodule (new typicalmodule); > ========== used only in create mode i0.typicale (); } if (rf & 2) { ... }}})Copy the code
As you can see, the Angular compiler stores constants outside of the code that creates and updates components and uses them only in create mode.
Angular no longer creates text nodes for containers
Update: github.com/angular/ang…
Even if you don’t know how Angular ViewContainer works in the engine, you might notice the following image when you open DevTools:
In production mode, we only see <! – >.
This is Ivy’s output:
I can’t be 100% sure, but it seems that we will have this result once Ivy becomes stable.
So for query in the following code, Angular returns NULL
@Component({ ... , template: '<ng-template #foo></ng-template>' }) class SomeComponent { @ViewChild('foo', {read: ElementRef}) query; }Copy the code
The ElementRef should no longer be read using a local element pointing to the annotated DOM node in the container
All-new Incremental DOM (IDOM)
A long time ago, Google released a so-called Incremental DOM library.
The library focuses on building DOM trees and allows dynamic updates. It cannot be used directly, but rather as a compilation target for the template engine. And Ivy seems to have something in common with the Incremental DOM library.
Let’s build a simple app from scratch that will help us understand how IDOM rendering works. Demo
Our app will have counters and will print out the username entered through the input element.
Assuming the page already has and
<input type="text" value="Alexey">
<button>Increment</button>Copy the code
All we need to do is render dynamic HTML that looks like this:
<h1>Hello, Alexey</h1>
<ul>
<li>
Counter: <span>1</span>
</li>
</ul>Copy the code
To render this, let’s write elementOpen, elementClose, and the text “Instructions” (I call it that because Angular uses a name like Ivy to be considered a special type of virtual CPU).
First, we need to write special assistants to traverse the node tree:
// The current nodes being processed
let currentNode = null;
let currentParent = null;
function enterNode() {
currentParent = currentNode;
currentNode = null;
}
function nextNode() {
currentNode = currentNode ?
currentNode.nextSibling :
currentParent.firstChild;
}
function exitNode() {
currentNode = currentParent;
currentParent = currentParent.parentNode;
}Copy the code
Now let’s write instructions:
function renderDOM(name) {
const node = name === '#text' ?
document.createTextNode('') :
document.createElement(name);
currentParent.insertBefore(node, currentNode);
currentNode = node;
return node;
}
function elementOpen(name) {
nextNode();
const node = renderDOM(name);
enterNode();
return currentParent;
}
function elementClose(node) {
exitNode();
return currentNode;
}
function text(value) {
nextNode();
const node = renderDOM('#text');
node.data = value;
return currentNode;
}Copy the code
In other words, these functions simply traverse the DOM node and insert the node at its current location. In addition, the text command sets the data property so that we can see the browser’s text value.
We want our elements to remain in some state, so we introduce NodeData:
const NODE_DATA_KEY = '__ID_Data__'; class NodeData { // key // attrs constructor(name) { this.name = name; this.text = null; } } function getData(node) { if (! node[NODE_DATA_KEY]) { node[NODE_DATA_KEY] = new NodeData(node.nodeName.toLowerCase()); } return node[NODE_DATA_KEY]; }Copy the code
Now, let’s change the renderDOM function so that we don’t add new elements to the DOM when the current position is already the same:
const matches = function(matchNode, name/*, key */) { const data = getData(matchNode); return name === data.name // && key === data.key; }; function renderDOM(name) { if (currentNode && matches(currentNode, name/*, key */)) { return currentNode; }... }Copy the code
Note my comment /*, key */. It would be nice if elements had keys to distinguish them. See also Google. Making. IO/incremental…
After that, let’s add the logic that will be responsible for updating the text node:
function text(value) { nextNode(); const node = renderDOM('#text'); // update // checks for text updates const data = getData(node); if (data.text ! == value) { data.text = (value); node.data = value; } // end update return currentNode; }Copy the code
We can do the same thing for element nodes.
Then, let’s write the patch function, which will need DOM elements, the update function, and some data (which will be used by the update function) :
function patch(node, fn, data) {
currentNode = node;
enterNode();
fn(data);
exitNode();
};Copy the code
Finally, let’s test the instructions:
function render(data) {
elementOpen('h1');
{
text('Hello, ' + data.user)
}
elementClose('h1');
elementOpen('ul')
{
elementOpen('li');
{
text('Counter: ')
elementOpen('span');
{
text(data.counter);
}
elementClose('span');
}
elementClose('li');
}
elementClose('ul');
}
document.querySelector('button').addEventListener('click', () => {
data.counter ++;
patch(document.body, render, data);
});
document.querySelector('input').addEventListener('input', (e) => {
data.user = e.target.value;
patch(document.body, render, data);
});
const data = {
user: 'Alexey',
counter: 1
};
patch(document.body, render, data);Copy the code
The results can be found here.
You can also verify that the code updates only text nodes whose content has changed by using a browser tool:
So the main idea of IDOM is to use the real DOM to compare to the new tree.
The full text. Thanks for reading.