Strona główna / Artykuły / Poprawa przejść wysokości w Vue dla komponentów sieciowych renderowanych asynchronicznie

Poprawa przejść wysokości w Vue dla komponentów sieciowych renderowanych asynchronicznie

Dowiedz się, dlaczego animacja rozszerzania w Vue zmienia wartość od 0px do 0px w przypadku komponentów Web Components zaktualizowanych w tle, oraz jak ResizeObserver opóźnia animację do chwili, gdy istnieje rzeczywista wysokość.

1144 słów

Komponent rozszerzalny/zwijalny, który można wielokrotnie używać i który opiera się na <Transition> z Vue, zazwyczaj mierzy wysokość elementu w hooku enter i animuje ją od zera do tej wartości. Działa to dobrze dla elementów natywnych i zwykłych komponentów Vue, ale może powodować bezgłośne problemy, gdy dzieckiem jest dynamicznie ładowany komponent Web: panel pojawia się bez żadnej animacji. Ten przewodnik wyjaśnia problem z czasem realizacji tej animacji, dlaczego metoda polling jest tylko częściowym rozwiązaniem oraz jak używać ResizeObserver, aby przejście rozpoczęło się dokładnie w momencie, gdy treść ma rzeczywitą wielkość.

Dlaczego animacja odbywa się od 0px do 0px

Załóżmy, że komunikaty o błędach są wyświetlane przez element powiadamiania zaimplementowany jako element niestandardowy i otoczony animacją rozszerzania:

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

Gdy showError przyjmuje wartość true, Vue wstawia element <notification-wrapper> do DOM i natychmiast wywołuje funkcję enter związanej z przejściem. Dla komponentu Vue to nie stanowi problemu, ponieważ jego struktura markup już wtedy istnieje. Jednak wiele komponentów Web Components jest aktualizowanych asynchronicznie: tag trafia do DOM jako pusty, nieznany element, a dopiero później, po załadowaniu jego definicji i wyrenderowaniu shadow DOM, otrzymuje treść oraz wysokość.

Rzeczywisty kolejność zdarzeń wygląda następująco:

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

Przejście dokonuje pomiarów na trzecim kroku, gdy element jest jeszcze pustą strukturą. Sumiennie animuje się od wartości 0px do zmierzonej wartości 0px, a chwilę później komponent jest wyświetlany w pełnej wysokości 160px bez żadnego przejścia. Dla użytkownika wygląda to tak, jakby animacja w ogóle nie została uruchomiona.

Rozwiązanie obejścia poprzez pomiary i jego koszt

Oczywistym rozwiązaniem jest czekanie, aż element pokaże określoną wysokość, zanim przystąpi się do pomiarów. Pętla umieszczona przed obliczaniem wysokości robi to poprzez sprawdzanie co kadencję:

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

To rzeczywiście działa. Każda iteracja czeka na następną kadencję animacji i ponownie sprawdza, czy scrollHeight nadal wynosi zero. Wadą jest to, że pętla wielokrotnie sprawdza układ co kadencję, dopóki coś się nie zmieni, a jeśli treść nigdy nie zyska wysokości – na przykład dlatego, że komponent nie został załadowany – pętla będzie działać tak długo, jak długo element istnieje. Ponadto zamienia enter w funkcję asynchroniczną, co utrudnia zrozumienie mechanizmu działania. Przeglądarka posiada już mechanizm powiadamiania właśnie w takich sytuacjach, więc nie ma potrzeby zadawania jej tej samej pytania sześćdziesiąt razy na sekundę.

Rozdzielenie pomiarów od animacji

Zanim przejdziemy na mechanizm obserwatora, pomocne jest lekkie przekształcenie kodu odpowiedzialnego za przejście. W typowej implementacji funkcja enter() zarówno mierzy element, jak i uruchamia animację. Ponieważ pomiar może wymagać czekania, te dwie czynności lepiej jest rozdzielić.

Sam kod animacji pozostaje dokładnie taki sam; przenosi się on po prostu do funkcji pomocniczej o nazwie animateEnter(). Nowa funkcja enter() ma zatem jedną rolę – decydowanie o momencie rozpoczęcia:

  • Gdy element może już zostać zmierzony, należy natychmiast wywołać animateEnter().
  • w przeciwnym razie należy poczekać, aż to się stanie, a następnie wywołać animateEnter().

To przeredagowanie nie zmienia zachowania w przypadku standardowym i zapewnia logice czekania jasne, oddzielone miejsce realizacji.

Czekanie na rzeczywistą wysokość za pomocą ResizeObserver

ResizeObserver zgłasza zmianę rozmiaru elementu, co jest dokładnie sygnałem potrzebnym w tym przypadku. Po wyodrębnieniu animacji nowa funkcja enter() staje się krótka:

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

Przeanalizujmy to krok po kroku. Szybsza ścieżka obsługuje elementy natywne oraz komponenty już wyrenderowane: jeśli scrollHeight ma wartość dodatnią, animacja rozpoczyna się natychmiast, tak jak wcześniej. W przeciwnym razie do elementu dodawany jest obserwator. Jego funkcja powrotna ponownie sprawdza wysokość i wraca wcześnie, gdy nadal wynosi zero; ta ochrona jest ważna, ponieważ obserwator uruchamia się również od razu po rozpoczęciu obserwacji, gdy element może jeszcze być pusty. Gdy pojawi się rzeczywista wysokość, obserwator odłącza się, dzięki czemu nie będzie dalej uruchamiany przy kolejnych zmianach rozmiaru, a animacja zaczyna się w następnym kadrze animacji, dając przeglądarce szansę na ustabilizowanie układu przed rozpoczęciem przejścia.

Tylko moment rozpoczęcia animacji uległ zmianie. Sama animacja pozostała nienaruszona.

Sytuacje krytyczne do obsługi w produkcji

Zaleca się uwzględnienie kilku scenariuszy w zależności od sposobu używania komponentu:

  • Jeśli element zostanie usunięty lub ukryty zanim uzyska wysokość, obserwator nadal jest przypisany. Rozłączenie go podczas obsługi zakończenia lub anulowania przejścia zapobiega pozostawieniu aktywnego obserwatora.
  • Obserwator reaguje na rozmiar elementu, który monitoruje. Jeśli krok beforeEnter ustala dla elementu wartość height: 0, upewnij się, że element ten rzeczywiście może zmieniać rozmiar w tym stanie, albo zamiast tego obserwuj jego zawartość.
  • Gdy przejście jest w całości sterowane za pomocą hooków JavaScript, Vue oczekuje, że poinformujesz o jego zakończeniu poprzez funkcję zwrotną done przekazaną do enter. Upewnij się, że ten sygnał nadal jest wysyłany po opóźnionym rozpoczęciu.
  • Dlaczego podejście oparte na obserwatorach jest bardziej niezawodne

    W porównaniu z metodą polling, wersja wykorzystująca ResizeObserver ma kilka zalet:

    • Nie ma pętli sprawdzającej układ na każdym kadru.
    • Zarządza komponentami Web Components, które są aktualizowane asynchronicznie.
    • Rozwiązuje również problemy z komponentami Vue ładowanymi w tle.
    • Sporządzają sobie z obrazami i innym treścią, których rozmiar zmienia się po pierwszym renderowaniu.
    • Przejście pozostaje uniwersalne, bez konieczności znajomości tego, co jest w nim zawarte.

    Ostatni punkt jest najcenniejszy. Komponent expand nie musi wiedzieć, czy jego dzieckiem jest standardowy div, komponent Vue czy element niestandardowy; czeka, aż zawartość otoczona nim poinformuje o rozmiarze różnym od zera, a dopiero wtedy uruchamia animację.

    Podsumowanie

    Pierwotne podejście polegające na pomiarze, a następnie animacji pozostaje w pełni wystarczające dla większości aplikacji Vue, gdzie treść jest już ułożona w momencie wykonywania funkcji enter. Korekta ta ma znaczenie, gdy renderowanie jest asynchroniczne i układ nie jest gotowy w chwili, gdy Vue wstawia element. Ogólna lekcja dotycząca ponownego wykorzystywanych prostych elementów interfejsu polega na unikaniu założeń co do momentu dostępności układu – należy raczej reagować na sygnały samego przeglądarki. Jest to niewielka zmiana w kodzie, która znacząco poprawia niezawodność, a każdy taki przypadek krawędziowy znaleziony w rzeczywistej integracji sprawia, że komponent staje się bardziej odporny w każdym projekcie, który go następnie wykorzysta.

    Powiązane materiały