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 al DOM sono problematici e a volte sono necessari. La chiave per utilizzare il DOM in sicurezza è ridurne al minimo l'utilizzo, posticipando il più possibile il suo utilizzo. 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 e future 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. È allettante utilizzare 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. Valuta bene quando e dove altri componenti (anche all'interno della stessa applicazione o libreria) potrebbero dover interagire con il comportamento o l'aspetto del tuo componente o personalizzarli, quindi esponi un modo sicuro e documentato per farlo. Utilizza funzionalità come l'iniezione di dipendenze gerarchica per rendere disponibile un'API per 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 di un altro elemento host e poi spostando o proiettando i contenuti al suo interno. Tuttavia, al giorno d'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();
    });
  }
}

Rimandare la manipolazione manuale del DOM

Dopo aver utilizzato le linee guida precedenti per ridurre al minimo la manipolazione diretta del DOM e accedere il più possibile, potresti avere alcune manipolazione 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). Anziché verificare in modo condizionale se stai eseguendo l'app nel browser o simulare 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 un layout personalizzato

A volte potrebbe essere necessario leggere o scrivere nel DOM per eseguire alcuni layout non ancora supportati 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 del DOM dopo la scrittura costringe il browser a ricalcolare il layout in modo sincrono, il che può influire notevolmente sulle prestazioni (vedi thrashing del layout). Pertanto, è importante suddividere attentamente 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 probabilmente utilizzerà due fasi. La fase EarlyRead viene utilizzata inizialmente 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 sfruttare al meglio queste funzionalità nelle tue applicazioni e librerie.