DRY in Angular Templates

Content Projection im Praxiseinsatz

Martin Grotz

Mathema Logo CodeDaysDigital 2021 Logo

Angular

  • Open-source Framework
  • Webtechnologien & TypeScript
  • Multi-target: WebApps, MobileApps, SSR
  • Komponentenorientiert
  • Dependency Injection
  • Automatische Change Detection

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 {}
          

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) {}
  }

Change Detection - Default

Change Detection - OnPush

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 {/* ... */}
              

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 {/* ... */}
      

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

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

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);
    }
}
          

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 {}

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});
    }
}

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.

ng-template

*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>) {}
}
          

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" },
      ];
}
          

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);
      }
    );
  }
}
        

Injector Tree

Performance

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;
  ...
        

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;
        

Performance Analyse

Performance Analyse

Doku-Links

Danke