Главная / Статьи / Исправление анимаций высоты в Vue для асинхронно отрисовываемых веб-компонентов

Исправление анимаций высоты в Vue для асинхронно отрисовываемых веб-компонентов

Узнайте, почему анимация расширения в Vue происходит от 0px до 0px при ленивой замене Web Components, и как ResizeObserver откладывает анимацию до тех пор, пока не будет известна реальная высота.

1144 слов

Компонент для расширения/сжатия, который можно использовать повторно и основан на <Transition> из Vue, обычно определяет высоту элемента в хуке enter и анимирует её от нуля до этого значения. Этот подход хорошо работает с нативными элементами и обычными компонентами Vue, но может тихо сбоить, когда дочерним элементом является динамически загружаемый веб-компонент: панель появляется без какой-либо анимации. В этом руководстве объясняется проблема с временем, лежащая в основе такого сбоя, почему метод опроса является лишь частичным решением, и как использовать ResizeObserver, чтобы анимация начиналась ровно тогда, когда у контента появляется реальный размер.

Почему анимация происходит от 0px до 0px

Предположим, что сообщения об ошибках отображаются с помощью элемента уведомлений, реализованного как пользовательский элемент и помещённого в анимацию расширения/сжатия:

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

Когда значение showError становится true, Vue вставляет элемент <notification-wrapper> в DOM и сразу же вызывает хук enter перехода. Для компонента Vue это работает нормально, поскольку его маркировка уже существует на тот момент. Однако многие веб-компоненты обновляются асинхронно: тег появляется в DOM как пустой, неизвестный элемент, и только позже, после загрузки его определения и отрисовки теневого DOM, он получает содержимое и высоту.

Фактическая последовательность событий выглядит следующим образом:

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

На третьем этапе переход измеряет размеры элемента, который на тот момент всё ещё является пустой оболочкой. Он верно анимирует его от 0px до полученных размеров 0px, а сразу после этого компонент отрисовывается в полном размере 160px без какой-либо анимации. Для пользователя кажется, что анимация вообще не происходила.

Решение с опросом состояния и его издержки

Очевидным решением является ожидание до тех пор, пока элемент не сообщит о своей высоте, прежде чем производить измерения. Для этого перед вычислением высоты используется цикл, который проверяет состояние каждую кадровую процедуру:

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

Этот метод действительно работает. Каждая итерация ожидает следующей кадровой процедуры анимации и снова проверяет, равна ли scrollHeight нулю. Недостаток заключается в том, что цикл постоянно запрашивает информацию о макете на каждой кадровой процедуре до тех пор, пока что-то не изменится. Если содержимое так и не приобретает высоту, например из-за невозможности загрузки компонента, цикл будет продолжать работать до тех пор, пока элемент существует. Кроме того, это превращает функцию enter в асинхронную, что затрудняет понимание её работы. У браузера уже существует механизм уведомлений именно для таких ситуаций, поэтому нет необходимости задавать ему один и тот же вопрос шестьдесят раз в секунду.

Разделение измерений и анимации

Прежде чем перейти на использование обозревателя, полезно немного изменить структуру кода для перехода. В типичной реализации функция enter() одновременно измеряет элемент и запускает анимацию. Теперь, когда измерение может потребовать ожидания, эти два действия лучше разделить.

Сам код анимации остается прежним; он просто переходит в вспомогательную функцию с именем animateEnter(). Теперь у новой функции enter() есть только одна задача — определять момент начала:

  • Если элемент уже можно измерить, сразу же вызывается animateEnter().
  • В противном случае необходимо дождаться возможности измерения, а затем вызвать animateEnter().

Эта рефакторинг не меняет поведение в обычных случаях и предоставляет логике ожидания четкое, изолированное место.

Ожидание реальной высоты с ResizeObserver

ResizeObserver сообщает о изменении размера элемента, что именно то сигнал, который здесь нужен. После извлечения анимации новая функция enter() становится короткой:

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

Рассмотрим её по порядку. Быстрый путь предназначен для нативных элементов и уже отрисованных компонентов: если scrollHeight имеет положительное значение, анимация начинается сразу, как и раньше. В противном случае к элементу привязывается наблюдатель. Его функция-обратный вызов снова проверяет высоту и возвращает раньше, если она всё ещё равна нулю; этот механизм защиты важен, потому что наблюдатель также срабатывает сразу при начале отслеживания, когда элемент ещё может быть пустым. Как только появляется реальная высота, наблюдатель отключается, поэтому он больше не срабатывает при последующих изменениях размера, и анимация начинается на следующем кадре, давая браузеру возможность устабилизировать макет перед началом перехода.

Изменился только момент запуска анимации. Сама анимация осталась без изменений.

Крайние случаи, которые необходимо учитывать в производственной среде

В зависимости от способа использования компонента стоит учесть несколько ситуаций:

  • Если элемент удаляется или скрывается до того, как он приобретёт высоту, наблюдатель всё равно остаётся привязанным. Отключение его в обработчиках завершения перехода или отмены предотвращает сохранение ненужного наблюдателя.
  • Наблюдатель реагирует на размер элемента, за которым он следит. Если шаг beforeEnter устанавливает для элемента значение height: 0, убедитесь, что элемент действительно может изменять размер в этом состоянии, или вместо этого наблюдайте за его внутренним содержимым.
  • Когда переход полностью управляется хуками JavaScript, Vue ожидает, что вы сообщите об окончании через обратный вызов done, переданный в функцию enter. Убедитесь, что этот сигнал всё равно будет отправляться даже при отложенном запуске.
  • Почему подход с обзервером более надёжен

    По сравнению с методом опроса, версия с ResizeObserver имеет несколько преимуществ:

    • Отсутствует цикл проверки макета на каждой кадре.
    • Он справляется с Web Components, которые обновляются асинхронно.
    • Он также работает с Vue-компонентами, загружаемыми по мере необходимости.
    • Он справляется с изображениями и другим контентом, размер которых меняется после первой отрисовки.
    • Переход остаётся универсальным, без знания того, что он оборачивает.

    Последний момент является самым важным. Компонент expand не должен знать, является ли его дочерний элемент нативным div, компонентом Vue или пользовательским элементом; он ждет, пока обернутый контент сообщит о размере, отличном от нуля, а затем запускает анимацию.

    Заключение

    Исходный подход «измерить, затем анимировать» по-прежнему вполне подходит для большинства приложений Vue, где макет контента формируется к моменту выполнения функции enter. Корректировка становится важной, когда отрисовка происходит асинхронно и макет еще не готов в момент вставки элемента Vue. Общий урок для повторно используемых UI-примитивов заключается в том, чтобы не предполагать момент появления макета: вместо этого следует реагировать на сигналы самого браузера. Это небольшая изменение кода, которое значительно повышает надежность, и каждый подобный крайний случай, обнаруженный в реальной интеграции, делает компонент более надежным для каждого проекта, использующего его далее.