Accueil / Articles / Résolution des transitions de hauteur de Vue pour les composants web affichés asynchronement

Résolution des transitions de hauteur de Vue pour les composants web affichés asynchronement

Découvrez pourquoi la transition d’expansion de Vue passe de 0px à 0px autour des Web Components mis à niveau de manière différée, et comment ResizeObserver retarde l’animation jusqu’à ce que la hauteur réelle soit disponible.

1144 mots

Un composant réutilisable d’expansion/fermeture basé sur <Transition> de Vue mesure généralement la hauteur d’un élément dans le hook enter et anime sa taille de zéro à cette valeur. Cette méthode fonctionne bien pour les éléments natifs et les composants Vue ordinaires, mais elle peut échouer silencieusement lorsque l’élément enfant est un composant Web chargé dynamiquement : le panneau apparaît sans aucune animation. Ce guide explique le problème de synchronisation à l’origine de cet échec, pourquoi la surveillance périodique n’est qu’une solution partielle, et comment utiliser ResizeObserver afin que la transition commence précisément lorsque le contenu a une taille réelle.

Pourquoi l’animation se déroule de 0px à 0px

Supposons que des messages d’erreur soient affichés par un élément de notification mis en œuvre sous forme d’élément personnalisé et intégré dans la transition d’expansion :

<transition-expand>
  <notification-wrapper v-if="showError" />
</transition-expand>

Lorsque showError devient true, Vue insère <notification-wrapper> dans le DOM et appelle immédiatement l’hook enter de la transition. Cela fonctionne bien pour les composants Vue, car leur balisage existe déjà à ce moment-là. Cependant, de nombreux Web Components sont mis à jour de manière asynchrone : le tag apparaît dans le DOM en tant qu’élément vide et inconnu, et ce n’est que plus tard, une fois sa définition chargée et son shadow DOM rendu, qu’il acquiert du contenu et de la hauteur.

L’ordre réel des événements est le suivant :

v-if becomes true
        ↓
Vue inserts the element
        ↓
Transition enter() runs
        ↓
Height is still 0px
        ↓
Web Component finishes rendering
        ↓
Actual height becomes 160px

La transition effectue ses mesures à l’étape trois, alors que l’élément n’est encore qu’une coquille vide. Elle anime donc fidèlement de 0px à la valeur mesurée, qui reste également 0px, et un instant plus tard le composant s’affiche à sa taille complète de 160px sans aucune transition. Pour l’utilisateur, il semble que l’animation n’ait jamais eu lieu.

La solution de contournement basée sur les sondages et son coût

La solution évidente consiste à attendre que l’élément indique une certaine hauteur avant de mesurer. Une boucle placée avant le calcul de la hauteur permet cela en vérifiant une fois par frame :

while (element.scrollHeight === 0) {
  await new Promise(requestAnimationFrame)
}

Cette méthode fonctionne effectivement. Chaque itération attend le prochain frame d’animation et vérifie à nouveau si scrollHeight reste nul. L’inconvénient est qu’elle interroge en permanence le layout à chaque frame jusqu’à ce que quelque chose change, et si le contenu ne gagne jamais de hauteur, par exemple parce que le composant échoue à se charger, la boucle continue de fonctionner tant que l’élément existe. De plus, cela transforme enter en fonction asynchrone, ce qui rend l’hook plus difficile à comprendre. Le navigateur dispose déjà d’un mécanisme de notification pour cette situation précise, il n’est donc pas nécessaire de lui poser la même question soixante fois par seconde.

Séparer la mesure de l’animation

enter() mesure à la fois l’élément et exécute l’animation. Comme cette mesure peut devoir attendre, il est préférable de séparer ces deux tâches.

animateEnter(). La nouvelle version de enter() n’a alors qu’une seule responsabilité : déterminer quand commencer :

  • Lorsque l’élément peut déjà être mesuré, appeler immédiatement animateEnter().
  • Sinon, attendre que ce soit possible, puis appeler animateEnter().

Attente de la hauteur réelle avec ResizeObserver

ResizeObserver signale lorsque la taille d’un élément change, ce qui correspond précisément au signal nécessaire ici. Une fois l’animation extraite, la nouvelle fonction enter() devient courte :

function enter(element: HTMLElement) {
  if (element.scrollHeight > 0) {
    animateEnter(element)
    return
  }
  const observer = new ResizeObserver(() => {
    if (element.scrollHeight === 0)
      return
    observer.disconnect()
    requestAnimationFrame(() => {
      animateEnter(element)
    })
  })
  observer.observe(element)
}

Examinons cela pas à pas. Le chemin rapide gère les éléments natifs et les composants déjà rendus : si scrollHeight est positif, l’animation démarre immédiatement, comme avant. Sinon, un observateur est attaché à l’élément. Sa fonction de rappel vérifie à nouveau la hauteur et revient immédiatement si elle reste nulle ; cette protection est importante car l’observateur se déclenche également dès le début de l’observation, lorsque l’élément peut encore être vide. Une fois que la vraie hauteur apparaît, l’observateur se déconnecte, de sorte qu’il ne se déclenche plus en cas de changements ultérieurs de taille, et l’animation commence au prochain cadre d’animation, donnant ainsi au navigateur le temps de finaliser l’agencement avant le début de la transition.

Seul le moment du démarrage de l’animation a changé. L’animation elle-même est restée inchangée.

Cas limites à gérer en production

Quelques situations méritent d’être prises en compte en fonction de la manière dont le composant est utilisé :

  • Si l’élément est supprimé ou caché avant d’avoir acquis de la hauteur, l’observateur reste attaché. Le déconnexion lors du traitement des sorties ou des annulations de la transition évite qu’un observateur persiste.
  • L’observateur réagit à la taille de l’élément qu’il surveille. Si votre étape beforeEnter fixe l’élément à height: 0, assurez-vous que l’élément observé puisse réellement changer de taille dans cet état, ou observez plutôt son contenu interne.
  • Lorsqu’une transition est entièrement gérée par des hooks JavaScript, Vue attend que vous signaliez sa fin à l’aide de la fonction de rappel done transmise à enter. Assurez-vous que ce signal est toujours déclenché même en cas de démarrage différé.
  • Pourquoi l’approche basée sur les observateurs est plus fiable

    Par rapport à la méthode de sondage, la version utilisant ResizeObserver présente plusieurs avantages :

    • Aucun boucle par frame pour vérifier le layout.
    • Elle gère les composants Web qui se mettent à jour de manière asynchrone.
    • Elle prend également en charge les composants Vue chargés de manière différée.
    • Elle s’occupe des images et d’autres contenus dont la taille change après le premier rendu.
    • La transition reste générique, sans aucune connaissance de ce qu’elle entoure.

    Ce dernier point est le plus précieux. Le composant d’expansion n’a pas besoin de savoir si son enfant est un div natif, un composant Vue ou un élément personnalisé ; il attend que le contenu encapsulé indique une taille différente de zéro avant d’exécuter l’animation.

    En résumé

    L’approche originale consistant à mesurer d’abord puis à animer reste tout à fait adaptée pour la plupart des applications Vue, où le contenu est déjà disposé au moment où l’événement enter se produit. L’ajustement devient important lorsque le rendu est asynchrone et que la mise en page n’est pas prête au moment où Vue insère l’élément. La leçon générale à retenir pour les primitives UI réutilisables est d’éviter de supposer quand la mise en page sera disponible : il faut plutôt réagir au signal fourni par le navigateur lui-même. Il s’agit d’une petite modification de code qui améliore considérablement la fiabilité, et chaque cas limite comme celui-ci découvert lors d’une intégration réelle rend le composant plus fiable pour chaque projet qui l’utilisera par la suite.