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

Gerald Monaco
Gerald Monaco

L'année dernière, Angular a ajouté un grand nombre de nouvelles fonctionnalités, telles que l'hydratation et les vues différables, pour aider les développeurs à améliorer leurs Core Web Vitals et à offrir une expérience de qualité aux 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é, comme le streaming et l'hydratation partielle.

Malheureusement, il existe un modèle qui peut empêcher votre application ou votre bibliothèque de profiter pleinement 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 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 en toute sécurité, il est essentiel de minimiser au maximum vos besoins, puis d'en reporter l'utilisation 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

Sans surprise, le meilleur moyen d'éviter les problèmes causés par la manipulation manuelle du DOM est de l'éviter complètement autant que 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.

Modifier 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. Vous pourriez être tenté de n'autoriser que ElementRef à modifier l'élément DOM sous-jacent. Utilisez plutôt des liaisons d'hôte pour lier de manière déclarative les valeurs à une expression:

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

Tout comme avec la liaison de données dans HTML, vous pouvez, par exemple, lier des attributs et des styles, et remplacer 'true' par une expression différente qu'Angular ajoutera ou supprimera automatiquement si nécessaire.

Dans certains cas, la clé devra être calculée de manière dynamique. Vous pouvez également créer une liaison à 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 de recourir à une 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.

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

Il peut être tentant d'essayer 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, 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 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 proposez une méthode sûre et documentée pour le faire. Utilisez des fonctionnalités telles que l'injection de dépendances hiérarchiques pour rendre une API disponible pour une sous-arborescence lorsque de simples propriétés @Input et @Output ne suffisent pas.

Auparavant, 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 projetant le 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();
    });
  }
}

Reporter la manipulation manuelle du DOM

Après avoir suivi les consignes précédentes pour limiter autant que possible la manipulation directe du DOM et l'accès à ceux-ci, il est possible qu'il en reste une partie qui sera inévitable. Dans ce cas, il est important de différer la diffusion 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 navigateur ou de bouchonner 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 aurez parfois besoin de lire ou d'é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 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 sert d'abord à acquérir la taille et la position des éléments:

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

Ensuite, la phase Write utilise la valeur lue précédemment 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 est en mesure de traiter efficacement la manipulation 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 prometteuses sont prévues pour le rendu côté serveur dans Angular. L'objectif est 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 bibliothèques !