Accesso sicuro al DOM con Angular SSR

Gerald Monaco
Gerald Monaco

Nell'ultimo anno, Angular ha acquisito molte nuove funzionalità, come l'idratazione e le visualizzazioni differibili, per aiutare gli sviluppatori a migliorare i Core Web Vitals e garantire un'esperienza eccezionale agli utenti finali. Sono in corso anche ricerche su altre funzionalità correlate al rendering lato server che si basano su questa funzionalità, come lo streaming e l'idratazione parziale.

Purtroppo, esiste un pattern che potrebbe impedire alla tua applicazione o libreria di sfruttare appieno tutte queste funzionalità nuove e future: la manipolazione manuale della struttura DOM sottostante. Angular richiede che la struttura del DOM rimanga coerente dal momento in cui un componente viene serializzato dal server fino a quando non viene attivato nel browser. L'utilizzo di ElementRef, Renderer2 o delle API DOM per aggiungere, spostare o rimuovere manualmente i nodi dal DOM prima dell'idratazione può introdurre incoerenze che impediscono il funzionamento di queste funzionalità.

Tuttavia, non tutte le manipolazioni e gli accessi manuali del DOM sono problematici e talvolta sono necessari. La chiave per utilizzare il DOM in modo sicuro è ridurre al minimo la necessità di utilizzarlo il più possibile e quindi rinviarlo il più a lungo possibile. Le seguenti linee guida spiegano come raggiungere questo obiettivo e creare componenti Angular veramente universali e adatti al futuro che possono sfruttare appieno tutte le funzionalità nuove ed emergenti di Angular.

Evita la manipolazione manuale del DOM

Il modo migliore per evitare i problemi causati dalla manipolazione manuale del DOM è, non a caso, evitarla del tutto, ove possibile. Angular dispone di API e pattern integrati che possono manipolare la maggior parte degli aspetti del DOM: ti consigliamo di utilizzarli anziché accedere direttamente al DOM.

Mutare l'elemento DOM di un componente

Quando scrivi un componente o una direttiva, potresti dover modificare l'elemento host (ovvero l'elemento DOM corrispondente al selettore del componente o della direttiva) per, ad esempio, aggiungere una classe, uno stile o un attributo, anziché scegliere come target o introdurre un elemento wrapper. Potresti avere la tentazione di raggiungere ElementRef per modificare l'elemento DOM sottostante. Dovresti invece utilizzare le associazioni all'host per associare in modo dichiarativo i valori a un'espressione:

@Component({
  selector: 'my-component',
  template: `...`,
  host: {
    '[class.foo]': 'true'
  },
})
export class MyComponent {
  /* ... */
}

Come per il collegamento dati in HTML, puoi anche, ad esempio, eseguire il collegamento ad attributi e stili e modificare 'true' in un'espressione diversa che Angular utilizzerà per aggiungere o rimuovere automaticamente il valore in base alle esigenze.

In alcuni casi, la chiave dovrà essere calcolata dinamicamente. Puoi anche eseguire il binding a un indicatore o a una funzione che restituisce un insieme o una mappa di valori:

@Component({
  selector: 'my-component',
  template: `...`,
  host: {
    '[class.foo]': 'true',
    '[class]': 'classes()'
  },
})
export class MyComponent {
  size = signal('large');
  classes = computed(() => {
    return [`size-${this.size()}`];
  });
}

In applicazioni più complesse, potrebbe essere allettante ricorrere alla manipolazione manuale del DOM per evitare un ExpressionChangedAfterItHasBeenCheckedError. In alternativa, puoi associare il valore a un indicatore come nell'esempio precedente. Questa operazione può essere eseguita in base alle esigenze e non richiede l'adozione degli indicatori nell'intera base di codice.

Modificare gli elementi DOM esterni a un modello

È allettante provare a utilizzare il DOM per accedere a elementi normalmente non accessibili, ad esempio quelli che appartengono ad altri componenti principali o secondari. Tuttavia, questo approccio è soggetto a errori, viola l'incapsulamento e rende difficile modificare o eseguire l'upgrade di questi componenti in futuro.

Il componente deve invece considerare tutti gli altri componenti come scatole nere. Prenditi il tempo necessario per valutare quando e dove altri componenti (anche all'interno della stessa applicazione o libreria) potrebbero aver bisogno di interagire o personalizzare il comportamento o l'aspetto del tuo componente, quindi esponi un modo sicuro e documentato per farlo. Utilizza funzionalità come l'inserimento delle dipendenze gerarchiche per rendere disponibile un'API in un sottoalbero quando non sono sufficienti proprietà @Input e @Output semplici.

In passato, era comune implementare funzionalità come finestre di dialogo modali o descrizioni comando aggiungendo un elemento alla fine del <body> o di un altro elemento host e poi spostando o proiettando i contenuti al suo interno. Tuttavia, al giorno d'oggi è probabile che tu possa eseguire il rendering di un semplice elemento <dialog> nel tuo modello:

@Component({
  selector: 'my-component',
  template: `<dialog #dialog>Hello World</dialog>`,
})
export class MyComponent {
  @ViewChild('dialog') dialogRef!: ElementRef;

  constructor() {
    afterNextRender(() => {
      this.dialogRef.nativeElement.showModal();
    });
  }
}

Rimandare la manipolazione manuale del DOM

Dopo aver utilizzato le linee guida precedenti per ridurre al minimo la manipolazione diretta del DOM e l'accesso il più possibile, potresti avere alcune restrizioni inevitabili. In questi casi, è importante posticipare il più a lungo possibile. I callback afterRender e afterNextRender sono un ottimo modo per farlo, in quanto vengono eseguiti solo nel browser, dopo che Angular ha controllato eventuali modifiche e le ha applicate al DOM.

Eseguire JavaScript solo nel browser

In alcuni casi, una libreria o un'API funziona solo nel browser (ad esempio una libreria di grafici, alcuni utilizzi di IntersectionObserver e così via). Invece di verificare in modo condizionale se è in esecuzione sul browser o di bloccare il comportamento del server, puoi utilizzare afterNextRender:

@Component({
  /* ... */
})
export class MyComponent {
  @ViewChild('chart') chartRef: ElementRef;
  myChart: MyChart|null = null;
  
  constructor() {
    afterNextRender(() => {
      this.myChart = new MyChart(this.chartRef.nativeElement);
    });
  }
}

Layout personalizzato

A volte potrebbe essere necessario leggere o scrivere nel DOM per eseguire un layout non ancora supportato dai browser di destinazione, ad esempio il posizionamento di una descrizione comando. afterRender è un'ottima scelta per questo, in quanto puoi avere la certezza che il DOM sia in uno stato coerente. afterRender e afterNextRender accettano un valore phase pari a EarlyRead, Read o Write. La lettura del layout DOM dopo la scrittura costringe il browser a ricalcolare il layout in modo sincrono, il che può influire seriamente sulle prestazioni (vedi thrashing del layout). Pertanto, è importante suddividere attentamente la logica nelle fasi corrette.

Ad esempio, un componente della descrizione comando che vuole mostrare una descrizione comando relativa a un altro elemento della pagina probabilmente utilizzerà due fasi. La fase EarlyRead viene utilizzata innanzitutto per acquisire le dimensioni e la posizione degli elementi:

afterRender(() => {
    targetRect = targetEl.getBoundingClientRect();
    tooltipRect = tooltipEl.getBoundingClientRect();
  }, { phase: AfterRenderPhase.EarlyRead },
);

La fase Write utilizzerà quindi il valore letto in precedenza per riposizionare effettivamente la descrizione comando:

afterRender(() => {
    tooltipEl.style.setProperty('left', `${targetRect.left + targetRect.width / 2 - tooltipRect.width / 2}px`);
    tooltipEl.style.setProperty('top', `${targetRect.bottom - 4}px`);
  }, { phase: AfterRenderPhase.Write },
);

Suddividendo la logica nelle fasi corrette, Angular è in grado di eseguire in batch la manipolazione del DOM in tutti gli altri componenti dell'applicazione, garantendo un impatto minimo sulle prestazioni.

Conclusione

Sono in arrivo molti nuovi e interessanti miglioramenti al rendering lato server di Angular, con l'obiettivo di semplificare l'offerta di un'esperienza eccezionale agli utenti. Ci auguriamo che i suggerimenti precedenti ti siano utili per sfruttarli appieno nelle tue applicazioni e librerie.