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

Gerald Monaco
Gerald Monaco

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

Malheureusement, un modèle peut empêcher votre application ou votre bibliothèque de profiter pleinement de toutes ces nouvelles fonctionnalités à venir: la manipulation manuelle de la structure DOM sous-jacente. Angular nécessite que la structure du DOM reste cohérente depuis la sérialisation d'un composant par le serveur 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 du DOM avant l'hydratation peut entraîner des incohérences qui empêchent ces fonctionnalités de fonctionner.

Cependant, les opérations manuelles de manipulation du DOM et d'accès ne sont pas toutes problématiques, et elles sont parfois nécessaires. Pour utiliser le DOM de manière sécurisée, vous devez réduire au maximum votre besoin de celui-ci, puis le différer le plus longtemps possible. Les consignes suivantes expliquent comment y parvenir et créer des composants Angular véritablement universels et pérennes qui exploitent pleinement toutes les fonctionnalités nouvelles et à venir d'Angular.

Éviter la manipulation manuelle du DOM

Le meilleur moyen d'éviter les problèmes causés par la manipulation manuelle du DOM est, sans surprise, de ne pas la faire du tout dans la mesure du possible. Angular dispose d'API et de modèles intégrés qui peuvent manipuler la plupart des aspects du DOM. Il est préférable de les utiliser plutôt que d'accéder directement au DOM.

Muter l'élément DOM d'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 correspondant au sélecteur du composant ou de la directive) pour ajouter, par exemple, une classe, un style ou un attribut, plutôt que de cibler ou d'introduire un élément wrapper. Il est tentant de simplement utiliser ElementRef pour modifier l'élément DOM sous-jacent. Utilisez plutôt des liaisons d'hôte pour lire de manière déclarative les valeurs à une expression:

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

Tout comme pour la liaison de données en HTML, vous pouvez également, par exemple, lier des attributs et des styles, et remplacer 'true' par une autre expression qu'Angular utilisera pour ajouter ou supprimer automatiquement la valeur si nécessaire.

Dans certains cas, la clé doit être calculée de manière dynamique. Vous pouvez également vous lier à un signal ou à une fonction qui renvoie un ensemble ou une carte 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 de recourir à la manipulation manuelle du DOM pour éviter une ExpressionChangedAfterItHasBeenCheckedError. À la place, vous pouvez lier la valeur à un signal, comme dans l'exemple précédent. Vous pouvez le faire selon vos besoins et vous n'avez pas besoin d'adopter des signaux sur l'ensemble de votre codebase.

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

Il est tentant d'essayer d'utiliser le DOM pour accéder à des éléments normalement inaccessibles, tels que ceux qui appartiennent à d'autres composants parents ou enfants. Cependant, ce type de composant est sujet aux erreurs, ne respecte pas l'encapsulation et complique la modification ou la mise à niveau de ces composants à l'avenir.

À la place, le composant doit considérer tous les autres composants comme une boîte noire. Prenez le temps de réfléchir à quand et où d'autres composants (même au sein de la même application ou bibliothèque) peuvent avoir besoin d'interagir avec le comportement ou l'apparence de votre composant, ou de le personnaliser, puis exposez un moyen sûr et documenté de le faire. Utilisez des fonctionnalités telles que l'injection de dépendance hiérarchique pour rendre une API disponible pour un sous-arbre lorsque les propriétés @Input et @Output simples ne suffisent pas.

Historiquement, il était courant d'implémenter des fonctionnalités telles que des boîtes de dialogue modales ou des 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 y projetant du contenu. Toutefois, 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();
    });
  }
}

Différer la manipulation manuelle du DOM

Après avoir suivi les consignes précédentes pour minimiser la manipulation directe du DOM et accéder autant que possible, il est possible que vous ne puissiez pas éviter certaines manipulations. Dans ce cas, il est important de le différer 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 du code JavaScript uniquement dans le navigateur

Dans certains cas, vous disposerez d'une bibliothèque ou d'une API qui ne fonctionne que dans le navigateur (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 code dans le navigateur ou de simuler le comportement 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

Vous devrez parfois 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 sûr que le DOM est dans un état cohérent. afterRender et afterNextRender acceptent une valeur phase de EarlyRead, Read ou Write. Lire la mise en page DOM après l'avoir écrite oblige le navigateur à recalculer la mise en page de manière synchrone, ce qui peut sérieusement affecter les performances (voir dégradation de la mise en page). Il est donc important de diviser soigneusement votre logique en phases appropriées.

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 est d'abord 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 phases appropriées, Angular peut effectuer efficacement la manipulation du DOM par lot sur tous les autres composants de l'application, ce qui garantit un impact minimal sur les performances.

Conclusion

De nombreuses améliorations intéressantes sont à venir pour le rendu côté serveur Angular, afin de vous permettre de proposer plus facilement une expérience de qualité à vos utilisateurs. Nous espérons que les conseils précédents vous aideront à exploiter pleinement ces fonctionnalités dans vos applications et bibliothèques.