Inicio / Artículos / Solucionar las transiciones de altura en Vue para componentes web renderizados de forma asíncrona

Solucionar las transiciones de altura en Vue para componentes web renderizados de forma asíncrona

Aprenda por qué la transición de expansión en Vue se anima de 0px a 0px alrededor de los Web Components actualizados de forma perezosa, y cómo ResizeObserver retrasa la animación hasta que exista una altura real.

1144 palabras

Un componente reutilizable de expansión/contracción basado en <Transition> de Vue suele medir la altura de un elemento en el hook enter y anima desde cero hasta ese valor. Eso funciona bien para elementos nativos y componentes normales de Vue, pero puede fallar silenciosamente cuando el hijo es un Web Component cargado dinámicamente: el panel aparece sin ninguna animación. Esta guía explica el problema de sincronización detrás de ese fallo, por qué hacer consultas periódicas es solo una solución parcial, y cómo utilizar ResizeObserver para que la transición comience exactamente cuando el contenido tenga un tamaño real.

Por qué la animación se ejecuta de 0px a 0px

Supongamos que los mensajes de error se muestran mediante un elemento de notificación implementado como un elemento personalizado y envuelto en la transición de expansión:

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

Cuando showError pasa a ser true, Vue inserta <notification-wrapper> en el DOM y llama de inmediato al hook enter de la transición. Esto funciona bien para un componente Vue, ya que su marcado ya existe en ese momento. Sin embargo, muchos componentes Web se actualizan de forma asíncrona: la etiqueta llega al DOM como un elemento vacío e desconocido, y solo más tarde, una vez que se ha cargado su definición y se ha renderizado su shadow DOM, adquiere contenido y altura.

El verdadero orden de los eventos es el siguiente:

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

La transición realiza su medición en el paso tres, mientras que el elemento sigue siendo una estructura vacía. Animación correctamente de 0px al valor medido de 0px, pero un instante después el componente se renderiza con sus 160px completos sin ninguna transición. Para el usuario parece como si la animación nunca se hubiera ejecutado.

La solución temporal de muestreo y su costo

La solución obvia es esperar hasta que el elemento indique una altura antes de realizar la medición. Un bucle colocado antes del cálculo de la altura hace esto verificando una vez por frame:

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

Esto funciona. Cada iteración espera al siguiente frame de animación y vuelve a preguntar si scrollHeight sigue siendo cero. La desventaja es que consulta repetidamente el diseño de cada frame hasta que algo cambia, y si el contenido nunca adquiere altura, por ejemplo porque el componente no se carga, el bucle sigue ejecutándose mientras exista el elemento. También convierte a enter en una función asíncrona, lo que dificulta su comprensión. El navegador ya cuenta con un mecanismo de notificación para exactamente esta situación, por lo que no hay necesidad de hacerle la misma pregunta sesenta veces por segundo.

Separar la medición de la animación

Antes de pasar a un observador, es útil reestructurar ligeramente el código de transición. En una implementación típica, enter() mide el elemento y ejecuta la animación al mismo tiempo. Dado que la medición podría tener que esperar, es mejor separar estas dos tareas.

El código de la animación permanece exactamente como estaba; simplemente se traslada a una función auxiliar llamada animateEnter(). La nueva enter() tendrá entonces una sola responsabilidad: decidir cuándo comenzar:

  • Cuando ya se pueda medir el elemento, llamar inmediatamente a animateEnter().
  • De lo contrario, esperar hasta que sea posible y luego llamar a animateEnter().

Esta refactorización no modifica el comportamiento en los casos comunes, y proporciona a la lógica de espera un lugar claro e independiente.

Esperar la altura real con ResizeObserver

ResizeObserver informa cuando cambia el tamaño de un elemento, que es precisamente la señal necesaria aquí. Con la animación extraída, el nuevo enter() se vuelve breve:

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

Analícelo paso a paso. La ruta rápida se encarga de los elementos nativos y los componentes ya renderizados: si scrollHeight es positivo, la animación comienza de inmediato, al igual que antes. De lo contrario, se adjunta un observador al elemento. Su función de callback verifica nuevamente la altura y devuelve antes si esta sigue siendo cero; esta protección es importante porque el observador también se dispara una vez al inicio de la observación, cuando el elemento aún puede estar vacío. Una vez que aparece la altura real, el observador se desconecta por sí mismo, de modo que deja de dispararse ante cambios posteriores en el tamaño, y la animación comienza en el siguiente fotograma de animación, dando al navegador la oportunidad de establecer el diseño antes de que comience la transición.

Solo ha cambiado el momento en que comienza la animación. La animación en sí permanece intacta.

Casos límite que deben manejarse en producción

Hay algunas situaciones que vale la pena abordar según cómo se utilice el componente:

  • Si el elemento se elimina u oculta antes de que adquiera altura, el observador sigue estando conectado. Desconectarlo en los procesos de finalización o cancelación de la transición evita que quede un observador activo.
  • El observador reacciona al tamaño del elemento que está monitoreando. Si el paso beforeEnter establece el elemento en height: 0, verifica que el elemento observado pueda realmente cambiar de tamaño en ese estado, o observa su contenido interno en su lugar.
  • Cuando una transición es controlada exclusivamente por ganchos de JavaScript, Vue espera que indique su finalización a través del callback done pasado a enter. Asegúrese de que esa señal se genere aún después de un inicio diferido.
  • Por qué el enfoque basado en observadores es más robusto

    En comparación con el método de consulta periódica, la versión ResizeObserver tiene varias ventajas:

    • No existe un bucle por frame que verifique el diseño.
    • Maneja componentes Web que se actualizan de forma asíncrona.
    • También gestiona componentes Vue cargados de forma perezosa.
    • Se ocupa de imágenes y otro contenido cuyo tamaño cambia después de la primera renderización.
    • La transición permanece genérica, sin conocer qué elemento contiene.

    Ese último punto es el más valioso. El componente expand no necesita saber si su hijo es un div nativo, un componente Vue o un elemento personalizado; espera hasta que el contenido envuelto indique un tamaño distinto de cero y luego ejecuta la animación.

    Conclusión

    El enfoque original de medir primero y luego animar sigue siendo perfectamente adecuado para la mayoría de las aplicaciones Vue, donde el contenido ya está dispuesto en el momento en que se ejecuta enter. El ajuste es importante cuando la renderización es asíncrona y el diseño no está listo en el instante en que Vue inserta el elemento. La lección general para las primitivas de interfaz reutilizables es evitar suposiciones sobre cuándo estará disponible el diseño: en su lugar, hay que reaccionar ante la señal del propio navegador. Se trata de un pequeño cambio en el código que mejora notablemente la fiabilidad, y cada caso límite como este encontrado en una integración real hace que el componente sea más robusto para cada proyecto que lo utilice posteriormente.

    Lecturas relacionadas