Accès sécurisé au DOM avec Angular SSR

Gerald Monaco
Gerald Monaco

L'année dernière, Angular a ajouté de nombreuses nouvelles fonctionnalités, telles que l'hydratation et les vues différables, pour aider les développeurs à améliorer leurs Signaux Web essentiels et à offrir une expérience de qualité à leurs utilisateurs finaux. Des recherches sur d'autres fonctionnalités liées au rendu côté serveur qui s'appuient sur cette fonctionnalité sont également en cours, telles que le streaming et l'hydratation partielle.

Malheureusement, un modèle peut empêcher votre application ou votre bibliothèque de tirer pleinement parti de toutes ces fonctionnalités nouvelles et à venir: la manipulation manuelle de la structure DOM sous-jacente. Angular nécessite que la structure du DOM reste cohérente entre le moment où un composant est sérialisé par le serveur et jusqu'à ce qu'il soit hydraté dans le navigateur. L'utilisation des API ElementRef, Renderer2 ou DOM pour ajouter, déplacer ou supprimer manuellement des nœuds dans le DOM avant l'hydratation peut entraîner des incohérences qui empêchent ces fonctionnalités de fonctionner.

Cependant, toutes les opérations manuelles de manipulation et d'accès DOM ne sont pas problématiques, et sont parfois nécessaires. Pour utiliser le DOM en toute sécurité, il est essentiel d'en limiter au maximum l'utilisation et de reporter son utilisation le plus longtemps possible. Les consignes suivantes expliquent comment y parvenir et créer des composants Angular véritablement universels et évolutifs, capables de tirer pleinement parti de toutes les nouvelles et futures fonctionnalités d'Angular.

Éviter la manipulation DOM manuelle

Sans surprise, le meilleur moyen d'éviter les problèmes causés par la manipulation DOM manuelle est de les éviter autant que possible. Angular dispose d'API et de modèles intégrés capables de manipuler la plupart des aspects du DOM. Il est préférable de les utiliser plutôt que d'accéder directement au DOM.

Modifier l'élément DOM propre à un composant

Lorsque vous écrivez un composant ou une directive, vous devrez peut-être modifier l'élément hôte (c'est-à-dire l'élément DOM qui correspond au sélecteur du composant ou de la directive), par exemple pour ajouter une classe, un style ou un attribut, plutôt que de cibler ou d'introduire un élément wrapper. Il est tentant de simplement atteindre ElementRef pour modifier l'élément DOM sous-jacent. Utilisez plutôt des liaisons d'hôte pour lier les valeurs de manière déclarative à une expression:

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

Comme pour la liaison de données en HTML, vous pouvez également lier des attributs et des styles, et remplacer 'true' par une autre expression qu'Angular utilisera pour ajouter ou supprimer automatiquement la valeur selon les besoins.

Dans certains cas, la clé doit être calculée de façon dynamique. Vous pouvez également établir une liaison avec un signal ou une fonction qui renvoie un ensemble ou un mappage de valeurs:

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

Dans les applications plus complexes, il peut être tentant d'utiliser la manipulation DOM manuelle pour éviter une ExpressionChangedAfterItHasBeenCheckedError. Au lieu de cela, vous pouvez lier la valeur à un signal, comme dans l'exemple précédent. Vous pouvez le faire si nécessaire, sans avoir à adopter les signaux dans l'ensemble de votre codebase.

Modifier des éléments DOM en dehors d'un modèle

Il est tentant d'utiliser le DOM pour accéder à des éléments qui ne sont normalement pas accessibles, tels que ceux qui appartiennent à d'autres composants parents ou enfants. Cependant, cette méthode est sujette aux erreurs, ne respecte pas l'encapsulation et rend difficile la modification ou la mise à niveau de ces composants à l'avenir.

Le composant doit considérer tous les autres éléments comme une boîte noire. Prenez le temps de déterminer quand et où d'autres composants (même dans la même application ou la même bibliothèque) peuvent avoir besoin d'interagir avec le comportement ou l'apparence de votre composant, ou de les personnaliser, puis présentez une méthode sûre et documentée pour le faire. Utilisez des fonctionnalités telles que l'injection de dépendances hiérarchique pour rendre une API disponible dans une sous-arborescence lorsque les propriétés @Input et @Output simples ne sont pas suffisantes.

Auparavant, il était courant d'implémenter des fonctionnalités telles que les boîtes de dialogue modales ou les info-bulles en ajoutant un élément à la fin de <body> ou d'un autre élément hôte, puis en y déplaçant ou en projetant du contenu. Cependant, de nos jours, vous pouvez probablement afficher un simple élément <dialog> dans votre modèle:

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

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

Reporter la manipulation DOM manuelle

Après avoir suivi les consignes précédentes pour limiter au maximum les manipulations directes du DOM et l'accès à ces ressources, il se peut qu'il en reste quelques-unes qui sont inévitables. Dans ce cas, il est important de différer le processus le plus longtemps possible. Les rappels afterRender et afterNextRender constituent un excellent moyen de procéder, car ils ne s'exécutent que dans le navigateur, une fois qu'Angular a recherché les modifications et les a validées dans le DOM.

Exécuter JavaScript pour navigateur uniquement

Dans certains cas, vous disposez d'une bibliothèque ou d'une API qui ne fonctionne que dans le navigateur (par exemple, une bibliothèque de graphiques, une certaine utilisation de IntersectionObserver, etc.). Au lieu de vérifier de manière conditionnelle si vous exécutez le navigateur dans le navigateur ou de tester le comportement du bouchon sur le serveur, vous pouvez simplement utiliser afterNextRender:

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

Effectuer une mise en page personnalisée

Parfois, vous devrez peut-être lire ou écrire dans le DOM pour effectuer une mise en page qui n'est pas encore compatible avec vos navigateurs cibles, comme le positionnement d'une info-bulle. afterRender est un excellent choix pour cela, car vous pouvez être certain que le DOM est dans un état cohérent. afterRender et afterNextRender acceptent une valeur phase de EarlyRead, Read ou Write. La lecture de la mise en page DOM après son écriture oblige le navigateur à recalculer la mise en page de manière synchrone, ce qui peut sérieusement affecter les performances (voir la section sur le thrashing de la mise en page). Il est donc important de diviser soigneusement votre logique en phases correctes.

Par exemple, un composant d'info-bulle qui souhaite afficher une info-bulle par rapport à un autre élément de la page utilisera probablement deux phases. La phase EarlyRead doit d'abord être utilisée pour acquérir la taille et la position des éléments:

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

Ensuite, la phase Write utilisera la valeur précédemment lue pour repositionner l'info-bulle:

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 },
);

En divisant notre logique en différentes phases, Angular est en mesure de manipuler efficacement le DOM par lot sur tous les autres composants de l'application, ce qui réduit au maximum l'impact sur les performances.

Conclusion

Nous prévoyons d'apporter de nombreuses nouvelles améliorations intéressantes au rendu Angular côté serveur afin de vous permettre d'offrir plus facilement une expérience de qualité à vos utilisateurs. Nous espérons que les conseils précédents vous aideront à en tirer pleinement parti dans vos applications et vos bibliothèques.