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 propri Segnali web essenziali e garantire un'esperienza eccezionale agli utenti finali. È in corso anche la ricerca di ulteriori 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 nuove funzionalità in arrivo: 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 viene idratato nel browser. Utilizzare le API ElementRef, Renderer2 o DOM per aggiungere, spostare o rimuovere manualmente i nodi dal DOM prima che l'idratazione possa introdurre incoerenze che impediscono il funzionamento di queste funzionalità.

Tuttavia, non tutte le operazioni manuali di manipolazione e accesso del DOM sono problematiche e talvolta sono necessarie. La chiave per utilizzare il DOM in modo sicuro consiste nel ridurre al minimo la necessità di tale DOM il più possibile e quindi rinviare l'utilizzo del DOM il più a lungo possibile. Le seguenti linee guida spiegano come raggiungere questo obiettivo e creano componenti Angular davvero universali e a prova di futuro che possono sfruttare appieno tutte le nuove funzionalità di Angular e quelle future.

Evita la manipolazione manuale del DOM

Il modo migliore per evitare i problemi causati dalla manipolazione manuale del DOM è, non sorprende, evitarlo del tutto quando possibile. Angular ha API e pattern integrati che possono manipolare la maggior parte degli aspetti del DOM: dovresti usarli invece di accedere direttamente al DOM.

Modifica dell'elemento DOM di un componente

Quando scrivi un componente o un'istruzione, potresti dover modificare l'elemento host (ovvero l'elemento DOM che corrisponde al selettore del componente o dell'istruzione) per aggiungere, ad esempio, una classe, uno stile o un attributo anziché impostare il targeting o introdurre un elemento wrapper. Si potrebbe avere la tentazione di usare ElementRef per modificare l'elemento DOM sottostante. Devi invece utilizzare le associazioni host per associare in modo dichiarativo i valori a un'espressione:

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

Proprio come per l'associazione di dati in HTML, puoi anche, ad esempio, associare gli attributi e gli stili, nonché cambiare 'true' con 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 in modo dinamico. Puoi anche associare un indicatore o 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()}`];
  });
}

Nelle applicazioni più complesse, si potrebbe avere la tentazione di ricorrere alla manipolazione manuale del DOM per evitare una ExpressionChangedAfterItHasBeenCheckedError. Puoi invece associare il valore a un indicatore come nell'esempio precedente. Questa operazione può essere eseguita in base alle esigenze e non richiede l'adozione di indicatori nell'intero codebase.

Modifica di elementi DOM al di fuori di un modello

Si potrebbe avere la tentazione di utilizzare il DOM per accedere a elementi che normalmente non sono accessibili, ad esempio quelli che appartengono ad altri componenti principali o secondari. Tuttavia, questo è soggetto a errori, viola l'incapsulamento e rende difficile cambiare o eseguire l'upgrade di questi componenti in futuro.

Dovresti invece considerare ogni altro componente come una scatola nera. Prenditi il tempo necessario per valutare quando e dove altri componenti (anche all'interno della stessa applicazione o libreria) potrebbero dover interagire o personalizzare il comportamento o l'aspetto del componente, quindi mostra un modo sicuro e documentato per farlo. Utilizza funzionalità come l'inserimento di dipendenze gerarchiche per rendere disponibile un'API a un sottoalbero quando le semplici proprietà @Input e @Output non sono sufficienti.

In passato, era comune implementare funzionalità come finestre di dialogo modali o descrizioni comando aggiungendo un elemento alla fine di <body> o qualche altro elemento host e quindi spostare o proiettare contenuti lì. Tuttavia, oggi è probabile che tu possa visualizzare 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();
    });
  }
}

Rimanda 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 averne ancora alcuni che sono inevitabili. In questi casi, è importante rinviarla il più a lungo possibile. I callback afterRender e afterNextRender sono un ottimo modo per farlo, poiché vengono eseguiti solo sul browser, dopo che Angular ha verificato la presenza di eventuali modifiche e le ha applicate al DOM.

Esegui JavaScript solo del browser

In alcuni casi avrai una libreria o un'API che funziona solo nel browser (ad esempio, una libreria di grafici, un certo utilizzo di IntersectionObserver e così via). Invece di controllare in modo condizionale se l'app è in esecuzione sul browser o di stuccare il comportamento sul server, puoi semplicemente utilizzare afterNextRender:

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

Esegui layout personalizzato

A volte potrebbe essere necessario leggere o scrivere nel DOM per eseguire layout non ancora supportati dai browser di destinazione, come il posizionamento di una descrizione comando. afterRender è un'ottima scelta per questo, poiché puoi essere certo che il DOM è in uno stato coerente. afterRender e afterNextRender accettano un valore phase di EarlyRead, Read o Write. La lettura del layout DOM dopo la scrittura obbliga il browser a ricalcolare in modo sincrono il layout, il che può influire notevolmente sulle prestazioni (vedi: thrashing del layout). Di conseguenza, è importante suddividere con attenzione la logica nelle fasi corrette.

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

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

Quindi, la fase Write utilizzerebbe 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 nostra logica nelle fasi corrette, Angular è in grado di gestire efficacemente la manipolazione del DOM in batch tra tutti gli altri componenti dell'applicazione, garantendo un impatto minimo sulle prestazioni.

Conclusione

Sono previsti numerosi nuovi ed entusiasmanti miglioramenti al rendering lato server di Angular all'orizzonte, con l'obiettivo di rendere più facile per te fornire un'ottima esperienza agli utenti. Ci auguriamo che i suggerimenti precedenti ti siano utili per sfruttarli al meglio nelle tue applicazioni e librerie.