Come creare in Angular una direttiva per dichiarare variabili nei template HTML dei componenti
Tags: angular, javascript, typescript
Published: 2022-04-04 20:14:03

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:

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

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:

```ts
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();
  }
}
```

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:

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

Vediamo quindi come realizzarla!

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

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

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

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

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

```html
<div *ngLet="1 + 1 as sum">
  {{ sum }}
</div>
```

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`:

```html
<div *ngLet="1 + 1 as sum">{{ sum }}</div>
``` 

o una dichiarazione implicita con `let`:

```html
<div *ngLet="1 + 1; let sum">{{ sum }}</div>
```

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:

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

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:

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

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

```ts
{ 
   ngLet: any;
   $implicit: any;
}
```

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

```html
<div *ngLet="1 + 1; let sum">{{ sum }}</div>
```

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

```html
<div *ngLet="1 + 1 as sum">{{ sum }}</div>
```

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_ `hasView`per creare una sola volta il template:

```ts
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;
        }
    }
}
```

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:

```html
<div *ngLet="1 as value">{{ value }}</div>
```

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

```ts
new NgLetContext<number>();
```

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:

```ts
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;
        }
    }
}
```

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:

```ts
static ngTemplateGuard_ngLet: 'binding';
```

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:

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

il risultato finale è quindi questo:

```ts
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;
    }
}
```

Guarda la DEMO su stackblitz:

{% embed https://stackblitz.com/edit/dev-post-ng-let?embed=1&file=src/app/app.component.ts %}

[LINK]( https://stackblitz.com/edit/dev-post-ng-let?file=src/app/app.component.ts)

questa direttiva è disponibile su:
- NPM https://www.npmjs.com/package/ng-let
- GitHub https://github.com/nigrosimone/ng-let