Головна / Статті / Виправлення переходів висоти у Vue для асинхронно відображуваних веб-компонентів

Виправлення переходів висоти у Vue для асинхронно відображуваних веб-компонентів

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

1144 слів

Компонент для розширення/згортання, який можна використовувати багаторазово та побудований на <Transition> з Vue, зазвичай вимірює висоту елемента у хуку enter та анімує її від нуля до цього значення. Це добре працює з корінними елементами та звичайними компонентами Vue, але може мовчки зазнати невдачі, коли дочірнім елементом є динамічно завантажений Web Component: панель з’являється без жодної анімації. У цьому посібнику пояснюється проблема таймінгу, яка стоїть за цією невдачею, чому опитування є лише частковим рішенням, та як використовувати ResizeObserver, щоб перехід починався саме тоді, коли у контенту є реальний розмір.

Чому анімація відбувається від 0px до 0px

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

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

Коли значення showError стає true, Vue додає до DOM елемент <notification-wrapper> та негайно викликає функцію 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 на асинхронну, що ускладнює розуміння її функціонування. Браузер вже має механізм сповіщень саме для такої ситуації, тому немає потреби ставити йому одне й те саме запитання шістдесят разів на секунду.

Розділення вимірювання та анімації

Перш ніж перейти на використання observer, корисно трохи переструктурувати код для переходу. У типовій реалізації функція 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-примітивів полягає у тому, щоб не припускати, коли буде готовий макет: слід реагувати на власні сигнали браузера. Це невелика зміна коду, яка значно покращує надійність, а кожен подібний крайній випадок у реальній інтеграції робить компонент міцнішим для кожного проекту, який буде ним користуватися далі.