DRY in Angular Templates
Content Projection im Praxiseinsatz
Martin Grotz
Angular
Open-source Framework
Webtechnologien & TypeScript
Multi-target: WebApps, MobileApps, SSR
Komponentenorientiert
Dependency Injection
Automatische Change Detection
Multi-target: Webapps, Server-side rendering, Mobile Apps via
Ionic
Besteht hauptsächlich aus drei Bausteinen (siehe nächste Folie)
Angular Bausteine
@Directive({selector: '[bold]'})
export class SomeDirective {
bold: boolean = false;
}
@Injectable()
export class SimpleService {
public shouldBeBold = true;
}
@Component(
{
selector: 'app-my-component',
template: `
<h1>My Component</h1>
<p>This text can be bold (or not)?</p>
`
}
)
export class MyComponent {}
Angular-Anwendung besteht aus drei Sachen:
Komponenten, die als View für die Darstellung zuständig sind
Direktiven, die das Verhalten von Komponenten beeinflussen
Services, die für Datenhaltung und -austausch zuständig sind
Angular macht viel über Decorators: Aspektorientierte
Programmierung
Dependency Injection & Input
@Directive({selector: '[bold]'})
export class SomeDirective {
@HostBinding('class.bold')
@Input()
bold: boolean = false;
}
@Injectable()
export class SimpleService {
public shouldBeBold = true;
}
@Component(
{
selector: 'app-my-component',
providers: [SimpleService ] ,
template: `
<style>.bold { font-weight: bold}</style>
<h1>My Component</h1>
<p [bold]="simpleService.shouldBeBold" >This text can be bold (or not)?</p>`
}
)
export class MyComponent {
constructor(public readonly simpleService: SimpleService ) {}
}
Dependency injection: Provider und automatische Injection in den
Konstruktor
Change Detection - Default
Probleme und Vorteile mit Default
Change Detection - OnPush
Probleme
Default vs OnPush
OnPush:
Mit OnPush:
Änderung an Input: Nur wo sich Objektreferenzen geändert haben, wird der Baum geprüft
Vergleich von Objektreferenzen, z.B. Änderung von Daten IM Array oder Objekt fällt nicht auf -> ggf. sowas wie Immutable.js oder Object.assign verwenden
Input ändert sich: Teilbaum wird geprüft
Event: Alles wird geprüft -> manchmal wird was nicht aktualisiert, bis man klickt -> OnPush Problem
bei bestimmten Funktionen (setTimeout(), Promise.resolve().then(), obs$.subscribe()) muss man bei OnPush manuell CD auslösen!
ng-content
@Component(
{
selector: 'outer-component',
template: `
<h1>...</h1>
<fancy-modal>
<p>
Lorem ipsum dolor sit amet.
Duis a ornare massa.
</p>
</fancy-modal>
`
}
)
export class OuterComponent {/* ... */}
@Component(
{
selector: 'fancy-modal',
template: `
<header>...</header>
<ng-content>
</ng-content>
<footer>...</footer>
`
}
)
export class FancyModal {/* ... */}
Statische Projektion
Zu projizierender Inhalt wird einfach zwischen die Tags des
Wrapper-Elements geschrieben
Element wird an einer Stelle ausgeschnitten und an anderer
wieder eingefügt
Beispiel: Wrapper-Komponente mit Funktionalität, wechselnder
Inhalt
ng-content Selektoren (Beispiele)
@Component(
{
/* ... */
template: `
<ng-content select="p" ></ng-content>
<ng-content select=".my-class" ></ng-content>
<ng-content select="[attr]" ></ng-content>
<ng-content></ng-content>
`
}
)
export class WithSelectors {/* ... */}
In ng-content kann mit select ein CSS-Selektor angegeben werden,
der dann nur passende Inhalte in diesem ng-content anzeigt
Wenn man keinen "Lumpensammler" ohne select hat, werden ggf.
nirgends passende Inhalte dann auch nirgends angezeigt
Angular Lifecycle Hooks
Hook
Was passiert
ngOnInit
Inputs initialisiert, Komponente initialisieren
ngAfterContentInit
Externer Content wurde projiziert und initialisiert
ngAfterViewInit
Eigenes Template und Kindkomponenten initialisiert
ngOnDestroy
Komponente wird gleich abgebaut
Lifecycle Reihenfolge
@Component(
{
selector: 'outer',
template: `
<middle>
<inner></inner>
</middle>
`
}
)
export class OuterComponent {/* ... */}
@Component(
{
selector: 'middle',
template: `<ng-content></ng-content>`
}
)
export class MiddleComponent {/* ... */}
@Component(
{
selector: 'inner',
template: `<p>...</p>`
}
)
export class InnerComponent {/* ... */}
ngOnInit
Inputs setzen, Komponente initialisieren
ngAfterContentInit
ng-content initialisiert
ngAfterViewInit
Restliche (Kind-)Elemente initialisiert
Outer wird initialisiert, dann ng-content aus Outer -> gibt es nicht, also sofort fertig
Dann "lesen von unten nach oben": Kindelemente von Outer machen: Middle
Middle ngOnInit, dann ng-content Inhalte -> Inner
Inner macht content durch
Middle fertig mit ngContent, danach kommt Inner mit View, dann ist Middle fertig mit allem
Am Schluss ist dann auch Outer fertig
Gefährlich: In Lifecycle-Hooks ChangeDetection mit detectChanges
erzwingen: Elternkomponenten sind noch nicht fertig
initialisiert, ggf. unerwartete undefined Werte bei Inputs,
ViewChild, ContentChild
ContentChildren
@ContentChild (TagDirective) tagDirective: TagDirective;
@ContentChildren (TagDirective) tagDirectives: QueryList<TagDirective>;
Use to get the QueryList of elements or directives from the content DOM. Any time a child element is added, removed, or moved, the query list will be updated, and the changes observable of the query list will emit a new value.
Content queries are set before the ngAfterContentInit callback is called.
Does not retrieve elements or directives that are in other components' templates, since a component's template is always a black box to its ancestors.
Doku:
https://angular.io/api/core/ContentChild
https://angular.io/api/core/ContentChildren
Variante, um genau ein Element (das erste gefundene!) zu selektieren: ContentChild
ContentChild ist erst nach AfterContentInit befüllt, falls
vorhanden
Projektion über mehrere Ebenen
@Component(
{
selector: 'layer1',
template: `
<layer2>
<div [tag] ="'tagged'">Cool content</div>
</layer2>
`
}
)
export class FirstLayerComponent {}
@Component(
{
selector: 'layer2 ',
template: `
<layer3>
<ng-content></ng-content>
</layer3>`
}
)
export class SecondLayerComponent {
@ContentChild(TagDirective ) tagDirective!: TagDirective;
ngAfterContentInit(): void {
console.log('SecondLayerComponent', this.tagDirective);
}
}
@Component(
{
selector: 'layer3 ',
template: `<ng-content></ng-content>`
}
)
export class ThirdLayerComponent {
@ContentChild(TagDirective ) tagDirective!: TagDirective;
ngAfterContentInit(): void {
console.log('ThirdLayerComponent', this.tagDirective);
}
}
Klappt nur bei Weitergabe #1, danach ist es undefined.
2x ng-content mit gleichem Selektor = 💥
@Component(
{
selector: 'outer-layer',
template: `
<inner-layer>
<p>💰💰💰</p>
</inner-layer>
`
}
)
export class OuterLayerComponent {}
@Component(
{
selector: 'inner-layer',
template: `
<div>
<h3>Money</h3>
<ng-content></ng-content>
</div>
<div *ngIf="false" >
<h3>More money</h3>
<ng-content></ng-content>
</div>
`
}
)
export class InnerLayerComponent {}
Verschieben im DOM, nur 1x möglich
Letztes ng-content gewinnt
Egal, ob das ng-content zur Laufzeit überhaupt da ist oder
nicht!
Injector (& Change Detection)
@Component(
{
selector: 'layer-one',
template: `
<layer-two>
<inner-text></inner-text>
</layer-two>` ,
}
)
export class LayerOneComponent {}
@Component(
{
selector: 'layer-two ',
template: `
<layer-three>
<ng-content></ng-content>
</layer-three>` ,
providers: [LayerTwoService]
}
)
export class LayerTwoComponent {}
@Component(
{
selector: 'layer-three ',
template: `<ng-content></ng-content> `,
providers: [LayerThreeService]
}
)
export class LayerThreeComponent {}
@Component(
{
selector: 'inner-text',
template: `<p>Are both services injected here?</p>`
}
)
export class InnerTextComponent {
constructor(
@Optional() layerTwoService: LayerTwoService ,
@Optional() layerThreeService: LayerThreeService
) {
console.log('injected services', {
layer2Service: layerTwoService,
layer3Service: layerThreeService});
}
}
DOM mit ng-content ist nicht gleich Angular Injector/CD Tree
Injector (& Change Detection)
@Component(
{
selector: 'layer-one',
template: `
<layer-two>
<inner-text></inner-text>
</layer-two>` ,
}
)
export class LayerOneComponent {}
@Component(
{
selector: 'layer-two ',
template: `
<layer-three>
<ng-content></ng-content>
</layer-three>` ,
providers: [LayerTwoService]
}
)
export class LayerTwoComponent {}
@Component(
{
selector: 'layer-three ',
template: `<ng-content></ng-content> `,
providers: [LayerThreeService]
}
)
export class LayerThreeComponent {}
@Component(
{
selector: 'inner-text',
template: `<p>Are both services injected here?</p>`
}
)
export class InnerTextComponent {
constructor(
@Optional() layerTwoService: LayerTwoService ,
@Optional() layerThreeService: LayerThreeService
) {
console.log('injected services', {
layer2Service: layerTwoService,
layer3Service: layerThreeService});
}
}
This is working as expected:
nodes are only "projected" (moved to a different location) but
everything else works relative to their source (= before
projection) location.
Angular wertet das Original-DOM aus und baut damit seinen internen Baum
Erst danach werden die DOM-Knoten verschoben
ng-content tag entfällt im DOM
ng-template
Große Schwester von ng-content
Kein Ausschneiden und Einfügen, sondern dynamisches Erzeugen aus
Vorlagen
Alles, was mit ng-content geht, geht auch mit ng-template, aber
ggf. mehr Code
Mehr Möglichkeiten als ng-content: Zum Beispiel löst es das Problem mit "kann nicht 2x eingefügt werden"
*ngIf; else
@Component(
{
template: `
<ng-container *ngIf ="!!name; else noNameGiven " >
<p>Hallo, {{name}}!</p>
</ng-container>
<ng-template #noNameGiven >
<p>Leider weiß ich deinen Namen noch nicht. Trotzdem: Hallo!</p>
</ng-template>
`
}
)
export class Component { ... }
ngTemplateOutlet
@Component(
{
template: `
<ng-template #listItem let-itemText >
<li>{{itemText }}</li>
</ng-template>
<ul>
<ng-template *ngFor="let item of ['Apple', 'Banana', 'Cookie'];"
[ngTemplateOutlet]="listItem "
[ngTemplateOutletContext]="{$implicit: item }">
</ng-template>
</ul>
`
}
)
export class TemplateOutletComponent {}
Mehrere Templates
@Directive({ selector: "ng-template[templateType]" })
export class TemplateTypeDirective {
@Input() templateType: string = "";
constructor(public readonly template: TemplateRef<any>) {}
}
Bestimmte Sachen werden von Angular automatisch provided und können überall im Konstruktor angefordert werden
ng-template -> TemplateRef injection
Mehrere Templates
@Component({
selector: "shopping-list",
template: `
<list-with-templates [items]="shoppingListEntries">
<ng-template [templateType]="'fruit'" let-cartEntry>
<li>🍉🍋🍍 {{ cartEntry.name }}</li>
</ng-template>
<ng-template [templateType]="'sweets'" let-cartEntry>
<li>🍩🍪🍬 {{ cartEntry.name }}</li>
</ng-template>
</list-with-templates>
`
})
export class ShoppingListComponent {
shoppingListEntries = [
{ name: "Apple", type: "fruit" },
{ name: "Banana", type: "fruit" },
{ name: "Cookie", type: "sweets" },
{ name: "Pork", type: "meat" },
];
}
Die Templates werden zwischen die Tags der "list-with-templates" geschrieben -> so wie auch für ng-content
Dadurch kann später mit @ContentChildren drauf zugegriffen werden
templateType "meat" hat kein passendes Template -> muss später irgendwie behandelt werden (oder auch nicht)
Beispiele: stillschweigend nicht anzeigen ODER Fehler werfen ODER rot blinkendes default template
Mehrere Templates
@Component({
selector: "list-with-templates",
template: `
<ul>
<ng-template
*ngFor="let item of items "
[ngTemplateOutlet]="templates .get(item.type) || null"
[ngTemplateOutletContext]="{ $implicit: item }"
>
</ng-template>
</ul>
`,
})
export class ListWithTemplatesComponent {
@Input() items : any[] = [];
@ContentChildren(TemplateTypeDirective) templateTypes !: QueryList<TemplateTypeDirective>;
templates = new Map<string, TemplateRef<any>>();
ngAfterContentInit(): void {
this.templateTypes .forEach((templateTypeDirective: TemplateTypeDirective) => {
this.templates .set(templateTypeDirective.templateType, templateTypeDirective.template);
}
);
}
}
Generische Listenkomponente: Weiß nichts von Einkaufslisten oder Früchten oder Süßigkeiten
Im Automotive-Projekt: Sehr viel Funktionalität, was drin steckt (Alben in Media, Suchergebnisse in Navi, ...) ist egal
Gibt es kein passendes Template, wird stillschweigend einfach
nichts angezeigt
If you are listening to QueryList's observable updates things
are not as clear cut: updates are often late, and already
removed things are still showing up - and the order is never
guaranteed!
Injector Tree
Hier wird nichts ausgeschnitten und verschoben
Injector verhält sich so, wie erwartet
Performance
Erfahrung aus der Praxis: Angular-Magie kann zu Performance-Problemen führen, wenn unbedacht benutzt
Nicht direkt content projection, aber auch wichtig
Besonders Problem, wenn man viele Elemente hat, Beispiel: Liste mit ng-template, viele Einträge im Input-Array
Performance: trackBy
@Component({
selector: "shopping-list",
template: `
<list-with-templates-trackBy
[items]="shoppingListEntries ">
...
</list-with-templates-trackBy>
`
})
export class ShoppingListWithTrackByComponent {
constructor(changeDetectorRef: ChangeDetectorRef ) {
timer(2_000, 2_000)
.subscribe(() => {
this.shoppingListEntries = this.shoppingListEntries
.map(x => Object.assign({}, x));
changeDetectorRef.markForCheck();
});
}
}
@Component({
selector: "list-with-templates-trackBy",
template: `
<ul>
<ng-template
*ngFor="let item of items; trackBy: trackByFn "
[ngTemplateOutlet]="templates.get(item.type) || null"
[ngTemplateOutletContext]="{ $implicit: item }"
>
</ng-template>
</ul>
`,
})
export class ListWithTemplatesTrackByComponent {
@Input() trackByFn : (index: number, item: any) => any = (_, item) => item ;
...
trackBy gibt dem ngFor Komparator eine Funktion, mit der dieser rausfinden kann, was sich geändert hat
Standard trackBy: Objektreferenz
RxJS + ChangeDetectorRef - notwendiges Drumherum, für Beispiel nicht direkt relevant
Herkunft ständig neuer Objekte:
Parsed JSON vom Server (z.B. wenn Eintrag dazu kommt wird mit default alles neu gemacht!)
Immutable.js
Performance: trackBy
@Component({
selector: "shopping-list",
template: `
<list-with-templates-trackBy
[trackByFn]="trackByName"
[items]="shoppingListEntries ">
...
</list-with-templates-trackBy>
`
})
export class ShoppingListWithTrackByComponent {
trackByName = (_index: number, item: any) => item.name;
constructor(changeDetectorRef: ChangeDetectorRef) {
timer(2_000, 2_000)
.subscribe(() => {
this.shoppingListEntries = this.shoppingListEntries
.map(x => Object.assign({}, x));
changeDetectorRef.markForCheck();
});
}
}
@Component({
selector: "list-with-templates-trackBy",
template: `
<ul>
<ng-template
*ngFor="let item of items; trackBy: trackByFn "
[ngTemplateOutlet]="templates.get(item.type) || null"
[ngTemplateOutletContext]="{ $implicit: item }"
>
</ng-template>
</ul>
`,
})
export class ListWithTemplatesTrackByComponent {
@Input() trackByFn ;
Angepasstes trackBy, z.B. auf eine id, oder hier den Namen
ngFor vergleicht, und nur, wenn sich die Rückgabe der trackBy-Funktion ändert wird die Komponente neu erzeugt
Änderungen innerhalb der Daten werden trotzdem erkannt (Einschränkung mit OnPush, Objektreferenzen, ChangeDetection beachten)
Performance Analyse
Browser-Debug-Tools, hier Chromium-basiert abgebildet
Flame Chart: Horizontal die Zeit, vertikal der Callstack
Falls Zeit ist:
Echte Aufnahme zeigen von https://www.code-days.de/codedays-2021/programm/programm.html reload?
Analyse: Ranzoomen an rechten Block mit den ganzen Forced Reflows: CSS lesen/schreiben nur in requestAnimationFrame und gruppieren gegen Layout Thrashing
Interaktiv
Performance Analyse
Niemals ohne OnPush arbeiten
Zerstören/Aufbauen teuer: custom trackBy
Komponenten/Direktiven möglichst schlank halten
Jede Direktive, jeder Input, jedes ngIf kostet
RxAngular
RxAngular Homepage
RxAngular offers a comprehensive toolset for handling fully reactive Angular applications with the main focus on runtime performance and template rendering.