DEV Community

Nigro Simone
Nigro Simone

Posted on • Updated on

Come creare in Angular una direttiva per dichiarare variabili nei template HTML dei componenti

A volte sarebbe utile poter dichiarare variabili nei template HTML dei componenti, ad esempio, immaginiamo di avere un osservabile, il cui valore va visualizzato in più punti del nostro template, una delle possibili soluzioni è quella di sottoscrivere più volte l'osservabile, usando la pipe async, degradando però le performance a causa delle molteplici sottoscrizioni:

import { Component } from '@angular/core';
import { Observable, timer } from 'rxjs';

@Component({
  selector: 'app-root',
  template: `
    <ul>
      <li>{{ timer$ | async }}</li><!-- prima sottoscrizione -->
      <li>{{ timer$ | async }}</li><!-- seconda sottoscrizione -->
    </ul>
  `,
})
export class AppComponent {
  public timer$: Observable<number> = timer(3000, 1000);
}
Enter fullscreen mode Exit fullscreen mode

oppure si potrebbe fare una singola sottoscrizione e gestire il tutto con un'unica proprietà esposta al template, con un notevole aumento del codice necessario a gestire il tutto:

import { Component, OnInit } from '@angular/core';
import { Observable, timer, Subscription } from 'rxjs';

@Component({
  selector: 'app-root',
  template: `
    <ul>
      <li>{{ time }}</li>
      <li>{{ time }}</li>
    </ul>
  `,
})
export class AppComponent implements OnInit, OnDestroy {

  public time: number;
  private timer$: Observable<number> = timer(3000, 1000);
  private subscription: Subscription;

  ngOnInit(){
    // singola sottoscrizione
    this.subscription = this.$timer.subscribe(value => this.time = value);
  }

  ngOnDestroy(){
    this.subscription.unsubscribe();
  }
}
Enter fullscreen mode Exit fullscreen mode

L'idea è quella di avere una direttiva che ci dia la possibilità di dichiarare delle variabili direttamente nel codice HTML ed ottenere quindi questo risultato finale:

import { Component } from '@angular/core';
import { Observable, timer } from 'rxjs';

@Component({
  selector: 'app-root',
  template: `
    <!-- singola sottoscrizione -->
    <ng-container *ngLet="timer$ | async as time">
      <ul>
        <li>{{ time }}</li>
        <li>{{ time }}</li>
      </ul>
    </ng-container>
  `,
})
export class AppComponent {
  public timer$: Observable<number> = timer(3000, 1000);
}
Enter fullscreen mode Exit fullscreen mode

Vediamo quindi come realizzarla!

Partiamo con il creare una direttiva che accetti un valore in input:

import { Directive, Input } from '@angular/core';

@Directive({
    selector: '[ngLet]'
})
export class NgLetDirective {

    @Input()
    set ngLet(value: any) {

    }
}
Enter fullscreen mode Exit fullscreen mode

l'idea è che il valore passato in input sia accessibile all'interno del tag HTML sul quale la direttiva è poggiata, ad esempio:

<div *ngLet="1 + 1 as sum">
  {{ sum }}
</div>
Enter fullscreen mode Exit fullscreen mode

per ottenerlo dobbiamo rendere la nostra direttiva strutturale, questo ci permetterà di associare alla struttura (il tag sul quale è poggiata) un "contesto" che potrà avere uno stato interno e quindi memorizzare un valore che sarà poi accessibile tramite un alias con as:

<div *ngLet="1 + 1 as sum">{{ sum }}</div>
Enter fullscreen mode Exit fullscreen mode

o una dichiarazione implicita con let:

<div *ngLet="1 + 1; let sum">{{ sum }}</div>
Enter fullscreen mode Exit fullscreen mode

Essendo la nostra direttiva "strutturale", abbiamo il compito di creare, all'interno della vista corrente, il template sul quale la direttiva è poggiata, dobbiamo quindi farci iniettare la vista corrente e la referenza al template:

import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';

@Directive({
    selector: '[ngLet]'
})
export class NgLetDirective {

    constructor(
       private viewContainer: ViewContainerRef, 
       private templateRef: TemplateRef<any>) { }

    @Input()
    set ngLet(value: any) {

    }
}
Enter fullscreen mode Exit fullscreen mode

A questo punto possiamo dichiarare il nostro contesto e associarlo alla referenza del template sul quale la direttiva è poggiata e creare quindi il template all'interno della vista corrente:

import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';

@Directive({
    selector: '[ngLet]'
})
export class NgLetDirective {

    private context = { ngLet: null, $implicit: null };

    constructor(
       private viewContainer: ViewContainerRef, 
       private templateRef: TemplateRef<any>) { }

    @Input()
    set ngLet(value: any) {
        this.context.$implicit = this.context.ngLet = value;
        this.viewContainer.createEmbeddedView(this.templateRef, this.context);
    }
}
Enter fullscreen mode Exit fullscreen mode

E' importante notare che il contesto, prevede due proprietà:

{ 
   ngLet: any;
   $implicit: any;
}
Enter fullscreen mode Exit fullscreen mode

dove $implicit ci permette di supportare la dichiarazione implicita con let, esempio:

<div *ngLet="1 + 1; let sum">{{ sum }}</div>
Enter fullscreen mode Exit fullscreen mode

mentre la proprietà ngLet ci permette di accedere al contesto tramite un alias con as, esempio:

<div *ngLet="1 + 1 as sum">{{ sum }}</div>
Enter fullscreen mode Exit fullscreen mode

A questo punto dobbiamo però evitare di creare il template all'interno della vista ogni volta che il setter (set ngLet(value: any)) viene invocato, quindi aggiungiamo un flag hasViewper creare una sola volta il template:

import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';

@Directive({
    selector: '[ngLet]'
})
export class NgLetDirective {

    private context = { ngLet: null, $implicit: null };
    private hasView: boolean = false;

    constructor(
       private viewContainer: ViewContainerRef, 
       private templateRef: TemplateRef<any>) { }

    @Input()
    set ngLet(value: any) {
        this.context.$implicit = this.context.ngLet = value;
        if (!this.hasView) {
            this.viewContainer.createEmbeddedView(this.templateRef, this.context);
            this.hasView = true;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Potremmo essere soddisfatti del risultato già così, ma la nostra direttiva avrebbe una grave pecca, il valore restituito non sarebbe tipicizzato e quindi risulterebbe nel template come un any.

Passiamo quindi alla tipicizzazione della direttiva e al pieno supporto con Ivy (il rendering engine di Angular) che ci segnalerà in fase di sviluppo eventuali anomalie nell'uso improprio del tipo di dato all'interno del template.

Quando la nostra direttiva sarà creata, Angular passerà alla classe il tipo di dato utilizzato nel template come tipo generico, esempio:

<div *ngLet="1 as value">{{ value }}</div>
Enter fullscreen mode Exit fullscreen mode

dato che 1 è un number il tipo passato alla classe sarà number:

new NgLetContext<number>();
Enter fullscreen mode Exit fullscreen mode

quindi possiamo prevedere che la nostra classe accetti un tipo generico che chiameremo T, che a sua volta utilizzeremo per tipicizzare tutto il nostro contesto:

import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';

interface NgLetContext<T> {
    ngLet: T;
    $implicit: T;
}

@Directive({
    selector: '[ngLet]'
})
export class NgLetDirective<T> {

    private context: NgLetContext<T | null> = { ngLet: null, $implicit: null };
    private hasView: boolean = false;

    constructor(
       private viewContainer: ViewContainerRef, 
       private templateRef: TemplateRef<NgLetContext<T>>) { }

    @Input()
    set ngLet(value: T) {
        this.context.$implicit = this.context.ngLet = value;
        if (!this.hasView) {
            this.viewContainer.createEmbeddedView(this.templateRef, this.context);
            this.hasView = true;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Tuttavia questo non basta a fare in modo che Ivy faccia un controllo del tipo passato e quindi ottenere tutti i vantaggi di aver tipicizzato il nostro contesto. Dobbiamo segnale al compilatore che vogliamo il controllo del tipo e per farlo dobbiamo aggiungere una proprietà statica:

static ngTemplateGuard_ngLet: 'binding';
Enter fullscreen mode Exit fullscreen mode

dobbiamo anche implementare un metodo che possa essere usato dal compilatore per controllare che il tipo sia valido, in questo caso il metodo tornerà sempre true perchè siamo sicuri che il contesto (ctx) sarà sempre del tipo T passato alla classe:

static ngTemplateContextGuard<T>(dir: NgLetDirective<T>, ctx: any): ctx is NgLetContext<Exclude<T, false | 0 | '' | null | undefined>> {
    return true;
}
Enter fullscreen mode Exit fullscreen mode

il risultato finale è quindi questo:

import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';

interface NgLetContext<T> {
    ngLet: T;
    $implicit: T;
}

@Directive({
    selector: '[ngLet]'
})
export class NgLetDirective<T> {

    private context: NgLetContext<T | null> = { ngLet: null, $implicit: null };
    private hasView: boolean = false;

    constructor(
       private viewContainer: ViewContainerRef, 
       private templateRef: TemplateRef<NgLetDirective<T>>) { }

    @Input()
    set ngLet(value: T) {
        this.context.$implicit = this.context.ngLet = value;
        if (!this.hasView) {
            this.viewContainer.createEmbeddedView(this.templateRef, this.context);
            this.hasView = true;
        }
    }

    static ngTemplateGuard_ngLet: 'binding';

    static ngTemplateContextGuard<T>(dir: NgLetDirective<T>, ctx: any): ctx is NgLetContext<Exclude<T, false | 0 | '' | null | undefined>> {
        return true;
    }
}
Enter fullscreen mode Exit fullscreen mode

Guarda la DEMO su stackblitz:

LINK

questa direttiva è disponibile su:

Discussion (0)