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 irregolari, per aiutare gli sviluppatori a migliorare i propri Core Web Vitals e garantire un'esperienza ottimale ai propri utenti finali. È in corso anche la ricerca di ulteriori funzionalità relative al rendering lato server basate su questa funzionalità, come lo streaming e l'idratazione parziale.

Sfortunatamente, esiste un pattern che potrebbe impedire all'applicazione o alla 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 idratato nel browser. L'utilizzo delle API ElementRef, Renderer2 o 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 realmente universali e a prova di futuro che possano sfruttare appieno tutte le funzionalità nuove e future di Angular.

Evita la manipolazione manuale del DOM

Come prevedibile, il modo migliore per evitare i problemi causati dalla manipolazione manuale del DOM è evitarlo del tutto, se possibile. Angular dispone di API e pattern integrati che possono manipolare la maggior parte degli aspetti del DOM: è preferibile utilizzarli anziché 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 selector del componente o dell'istruzione) per aggiungere, ad esempio, 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. Devi utilizzare invece 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 con l'associazione di dati in HTML, puoi anche, ad esempio, eseguire l'associazione ad attributi e stili e modificare 'true' in un'espressione diversa che Angular utilizzerà per aggiungere o rimuovere automaticamente il valore in base alle necessità.

In alcuni casi, la chiave dovrà essere calcolata in modo dinamico. Puoi anche associarti 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()}`];
  });
}

Nelle applicazioni più complesse, potresti avere la tentazione di richiedere la manipolazione manuale del DOM per evitare un ExpressionChangedAfterItHasBeenCheckedError. Puoi invece associare il valore a un indicatore come nell'esempio precedente. Questa operazione può essere eseguita se necessario e non richiede l'adozione di indicatori nell'intero codebase.

Modifica degli elementi DOM al di fuori di un modello

Potresti tentare di utilizzare il DOM per accedere a elementi che non sono normalmente accessibili, ad esempio quelli che appartengono ad altri componenti principali o secondari. Tuttavia, è soggetta a errori, viola l'incapsulamento e complica la modifica o l'upgrade di questi componenti in futuro.

Il tuo componente deve invece considerare ogni altro componente come un riquadro nero. Prenditi il tempo necessario per valutare quando e dove altri componenti (anche all'interno della stessa applicazione o libreria) potrebbero dover interagire con il comportamento o dell'aspetto del componente o personalizzarne l'aspetto, quindi esponi un modo sicuro e documentato per farlo. Utilizza funzionalità come l'inserimento delle dipendenze gerarchiche per rendere un'API disponibile in un sottoalbero quando le proprietà @Input e @Output semplici non sono sufficienti.

In passato, era comune implementare funzionalità come le finestre di dialogo modali o le descrizioni comando aggiungendo un elemento alla fine di <body> o di qualche altro elemento host e quindi spostando o proiettando i contenuti lì. Tuttavia, al giorno d'oggi probabilmente puoi eseguire il rendering di un semplice elemento <dialog> nel 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 seguito le linee guida precedenti per ridurre al minimo la manipolazione diretta e l'accesso del DOM, potresti averne degli elementi inevitabili. In questi casi, è importante differire 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 le modifiche e ne ha eseguito il commit nel DOM.

Esegui JavaScript solo browser

In alcuni casi avrai una libreria o un'API che funziona solo nel browser (ad esempio, una libreria di grafici, un utilizzo parziale di IntersectionObserver e così via). Invece di verificare in modo condizionale se è in esecuzione sul browser o di eseguire la simulazione del comportamento del server sul server, puoi utilizzare semplicemente 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 potresti dover 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 scopo, poiché hai la certezza che il DOM si trovi in uno stato coerente. afterRender e afterNextRender accettano un valore phase pari a EarlyRead, Read o Write. La lettura del layout DOM dopo averlo scritto obbliga il browser a ricalcolare in modo sincrono il layout, il che può influire seriamente 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 rispetto a un altro elemento della pagina utilizzerà probabilmente due fasi. La fase EarlyRead viene utilizzata prima per acquisire le dimensioni e la posizione degli elementi:

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

Quindi, la fase Write userebbe 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 eseguire efficacemente la manipolazione in batch del DOM su ogni altro componente dell'applicazione, garantendo un impatto minimo sulle prestazioni.

Conclusione

All'orizzonte ci sono molti nuovi ed entusiasmanti miglioramenti al rendering lato server di Angular, con l'obiettivo di semplificare l'offerta di un'esperienza eccezionale per gli utenti. Ci auguriamo che i suggerimenti precedenti ti siano utili per sfruttarli appieno nelle tue applicazioni e librerie.