- Angular Elements, Part I: A Dynamic Dashboard In Four Steps With Web Components
- Angular Elements, Part II: Lazy And External Web Components
- Angular Elements, Part III: Angular Elements without Zone.js
- Angular Elements, Part IV: Content Projection with Slots in Angular Elements (>=7)
- Angular Elements, Part V: Your Options For Building Angular Elements With The CLI
Since the release of Version 6, creating Web Components with Angular has become remarkably straightforward. More precisely, we should use the term Custom Elements — a standard that falls under the Web Components umbrella and enables the creation of our own HTML elements.
Still, Angular relies on zone.js for change tracking, and in most scenarios we prefer not to impose that dependency on the consumers of our widgets.
In this concise piece, I will outline why removing zone.js is advisable and how to handle the implications. The demonstration I’m referencing is available in my github repo. Be sure to check out the noop-zone branch.
Why zone.js may be unsuitable for Custom Elements
Generally speaking, we aim to keep our custom elements as lightweight as possible regarding bundle size. The forthcoming ngIvy view engine will contribute significantly here, as it generates more tree-shakable code, effectively allowing Angular to "eliminate itself" largely during compilation.
Another strategy for reducing bundle size involves reusing Angular packages across multiple Angular Elements and the host application. After a compelling exchange with Angular's Rob Wormald, I developed ngx-build-plus — a simple CLI extension designed to put this concept into practice.
However, in either scenario, we cannot eliminate zone.js, a library Angular has depended on for change detection from the outset. This library monkey-patches numerous browser objects so it stays informed of all events, allowing Angular to subsequently verify the displayed components for updates.
While this approach offers convenience within an Angular application, carrying such a dependency for a custom element is less than ideal, particularly when the host application isn’t built with Angular: not every end user wants browser objects patched, and in many instances, zone.js exceeds the custom element’s own size.
Removing zone.js
Eliminating zone.js proves to be the simplest step. Simply configure the noop zone (a no-operation zone) when bootstrapping the Angular application:
platformBrowserDynamic()
.bootstrapModule(
AppModule, { ngZone: 'noop' })
.catch(err => console.log(err));
Nevertheless, managing the outcomes of dropping zone.js is more challenging, since without this library we must manually initiate change detection.
Manually Triggering Change Detection
For my demonstrations, I’ve employed a straightforward Angular component that presents three numeric values:
@Component({
[...]
})
export class ExternalDashboardTileComponent {
@Input() a: number;
@Input() b: number;
@Input() c: number;
more(): void {
this.a = Math.round(Math.random() * 100);
this.b = Math.round(Math.random() * 100);
this.c = Math.round(Math.random() * 100);
}
}
It also exposes a more method that refreshes these values. For simplicity’s sake, I’m utilizing random numbers here.
The values are shown in a table, and the method is wired to the button’s click event:
<table class="table table-condensed">
<tr>
<td>A</td>
<td>{{a}}</td>
</tr>
<tr>
<td>B</td>
<td>{{b}}</td>
</tr>
<tr>
<td>C</td>
<td>{{c}}</td>
</tr>
</table>
<button class="btn btn-default btn-sm" (click)="more()">More</button>
With zone.js active, Angular automatically runs change detection after the click event, thus refreshing the bound values. But without zone.js, Angular remains unaware of the click event. Consequently, we must trigger change detection manually.
This can be achieved by invoking the markForCheck method on the current ChangeDetectorRef:
@Component({
[...]
})
export class ExternalDashboardTileComponent {
@Input() a: number;
@Input() b: number;
@Input() c: number;
constructor(private cd: ChangeDetectorRef) {
}
more(): void {
this.a = Math.round(Math.random() * 100);
this.b = Math.round(Math.random() * 100);
this.c = Math.round(Math.random() * 100);
this.cd.markForCheck();
}
}
Given that this is a quite explicit approach, it’s easy to overlook the method call at the appropriate moment. Hence, I’ll present an alternative in the following section.
Push Pipe
An alternative, more declarative method for triggering change detection relies on Observables. Each time a new value emerges, a pipe can signal Angular to verify changes. While Angular provides the async pipe for such scenarios, it also requires zone.js.
What we truly need is a tailored async pipe. A prototypical (!) version comes from Fabian Wiles, an active member of the community. He refers to it as the push pipe.
To leverage it, we must introduce an Observable. In my example, I’ve placed it directly within the component. In a more sophisticated scenario, a service would supply it. To facilitate direct notification, I’m also using a BehaviorSubject:
@Component({
[...]
})
export class ExternalDashboardTileComponent implements OnInit {
@Input() a: number;
@Input() b: number;
@Input() c: number;
private statsSubject = new BehaviorSubject<Stats>(null);
public stats$ = this.statsSubject.asObservable();
[...]
}
To manage just one Observable for all three values, I group them using a Stats class:
class Stats {
constructor(
readonly a: number,
readonly b: number,
readonly c: number
) { }
}
Once Angular has created the component, we must publish the three numeric values for the first time:
ngOnInit(): void {
this.statsSubject.next(new Stats(this.a, this.b, this.c));
}
Following each modification, we repeat this process:
more(): void {
this.a = Math.round(Math.random() * 100);
this.b = Math.round(Math.random() * 100);
this.c = Math.round(Math.random() * 100);
this.statsSubject.next(new Stats(this.a, this.b, this.c));
}
In the template, we can subscribe to the Observable using the new push pipe. In the listing below, I’m employing an ngIf for this purpose. The as clause assigns the received object to the stats template variable.
<div class="content" *ngIf="stats$ | push as stats">
<div style="height:200px;">
<br>
<table class="table table-condensed">
<tr>
<td>A</td>
<td>{{stats.a}}</td>
</tr>
<tr>
<td>B</td>
<td>{{stats.b}}</td>
</tr>
<tr>
<td>C</td>
<td>{{stats.c}}</td>
</tr>
</table>
<button class="btn btn-default btn-sm" (click)="more()">More</button>
</div>
</div>
Additionally, we can now shift to OnPush, given that we’re depending solely on Observables and Immutables:
@Component({
[...],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ExternalDashboardTileComponent implements OnInit {
[...]
}
