Uruchamianie efektów po zakończeniu automatycznego przewijania

Gdy programowo przewijasz do sekcji lub elementu (zwłaszcza za pomocą behavior: 'smooth'), natychmiastowe wywołanie działania, takiego jak miganie stanu wyróżnienia, rozpoczęcie animacji wejścia lub przeniesienie fokusu, powoduje, że efekt jest uruchamiany, gdy cel jest nadal poza ekranem lub w trakcie przewijania.

Wcześniej deweloperzy musieli zgadywać czas przewijania za pomocą setTimeout() (który uruchamia się zbyt wcześnie przy długich przewijaniach i zbyt późno przy krótkich) lub ręcznie opakowywać detektor zdarzeń scrollend w Promise z zegarem zastępczym (ponieważ scrollend nie uruchamia się, gdy cel jest już widoczny i nie następuje przewijanie).

Metody automatycznego przewijania w Element (scrollIntoView(), scroll(), scrollTo(), scrollBy()) i Window (scroll(), scrollTo(), scrollBy()) zwracają obiekt Promise, który jest rozwiązywany po zakończeniu operacji przewijania. Jeśli element docelowy znajduje się już w żądanej pozycji przewijania i nie trzeba go przewijać, zwracany element Promise jest natychmiast rozwiązywany bez zawieszania się.

Implementacja

  1. Oznacz moduł obsługi jako async: użyj funkcji async w detektorze zdarzeń lub module obsługi nawigacji, który wyzwala przewijanie.
  2. Bezpośrednie oczekiwanie na metodę przewijania: wywołaj await element.scrollIntoView({ behavior: 'smooth', ... }) (lub await element.scrollTo(...), await window.scrollTo(...) itd.). Wykonywanie jest wstrzymywane, dopóki przeglądarka nie zakończy animacji przewijania i nie ustali się pozycja przewijania.
  3. Zastosuj efekt po przewinięciu: przełącz klasę wyróżnienia, rozpocznij animację lub przenieś zaznaczenie po zakończeniu wyrażenia await.

Przerwane przewijanie i zarządzanie skupieniem uwagi

  • Obsługa szybkiego lub przerywanego przewijania: jeśli nowe przewijanie rozpocznie się w tym samym kontenerze przewijania, zanim zakończy się trwające płynne przewijanie, przeglądarka przerywa trwające płynne przewijanie i rozwiązuje (nie odrzuca) wszystkie oczekujące obietnice przewijania w tym kontenerze. Jeśli chcesz pominąć efekt po przewinięciu w przypadku zastąpionych przewinięć, gdy użytkownik szybko klika, śledź przyrostowy identyfikator operacji i wróć do początku po await, jeśli rozpoczęto nowsze przewijanie:
let currentScrollId = 0;
const sections = document.querySelectorAll('section');

async function scrollToAndHighlight(targetElement) {
  const scrollId = ++currentScrollId;
  sections.forEach((sec) => sec.classList.remove('highlight'));

  await targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });

  // Abort follow-up effect if another scroll was triggered while awaiting
  if (scrollId !== currentScrollId) return;

  targetElement.classList.add('highlight');
}
  • Ustawianie fokusu na elemencie docelowym po przewinięciu: gdy po wywołaniu await targetElement.scrollIntoView(...) przenosisz fokus klawiatury na element docelowy (lub nagłówek w nim za pomocą tabindex="-1"), przekaż { preventScroll: true } do focus(), aby wywołanie fokusu nie zainicjowało drugiego, konkurencyjnego przewijania:
await targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
targetHeading.focus({ preventScroll: true });

Ograniczenia i sprawdzone metody

  • OBOWIĄZKOWO: poczekaj na Promise zwracane bezpośrednio przez scrollIntoView(), scroll(), scrollTo() lub scrollBy(), zamiast zgadywać czas trwania przewijania za pomocą setTimeout().
  • NIE umieszczaj scrollend w ręcznym new Promise() jako podstawowej implementacji – scrollend nie uruchamia się, gdy cel jest już widoczny i nie ma odległości przewijania, podczas gdy w takim przypadku obietnice metody przewijania są automatycznie rozwiązywane.
  • DO: Pass { preventScroll: true } when calling .focus() after awaiting a scroll method so the browser does not jump or scroll a second time.
  • DO: Guard post-scroll effects with a request token if rapid user interactions can trigger overlapping płynne przewijanie on the same container, since interrupting a płynne przewijanie resolves (rather than rejects) the previous promise.
  • Znany błąd Chrome związany z głównym elementem przewijającym: w Chrome występuje błąd, który powoduje, że obiekt Promise zwracany przez scrollIntoView() jest rozwiązywany zbyt wcześnie, gdy przewijany jest główny element przewijający dokumentu. Oczekiwanie na scrollIntoView() w zagnieżdżonym kontenerze przewijania działa zgodnie z oczekiwaniami. Nie umieszczaj treści w kontenerze przewijania, takim jak div z overflow-y: auto, aby obejść ten błąd.

Strategie zastępcze

W przeglądarkach, które nie obsługują obietnic metod przewijania, scrollIntoView(), scroll(), scrollTo() i scrollBy() zwracają undefined zamiast Promise.

  • Progresywne ulepszanie (domyślne działanie await): ponieważ await undefined to prawidłowy kod JavaScript, który jest natychmiast rozwiązywany w następnym mikrozadaniu, zapisanie await element.scrollIntoView({ behavior: 'smooth' }) nie spowoduje błędu w nieobsługiwanych przeglądarkach. Zamiast tego kod po przewinięciu jest uruchamiany, gdy tylko rozpocznie się przewijanie. W przypadku niekrytycznych elementów wizualnych, takich jak tymczasowe podświetlenie, często jest to wystarczające.
  • Wierne działanie zastępcze (scrollend + limit czasu): jeśli Twój cel Baseline nie obsługuje scroll-promises, a działanie po przewinięciu musi poczekać, aż przewijanie zakończy się w starszych przeglądarkach, sprawdź, czy zwracana wartość to Promise (result instanceof Promise). Jeśli jest to undefined, poczekaj na zdarzenie scrollend w kontenerze przewijania z krótkim limitem czasu działania zastępczego na wypadek, gdyby element był już widoczny i nie nastąpiło przewijanie:
function scrollIntoViewAsync(element, options, scroller = window) {
  const result = element.scrollIntoView(options);

  // Feature detect: in supporting browsers, scrollIntoView() returns a Promise
  if (result instanceof Promise) {
    return result;
  }

  // Fallback for browsers where scrollIntoView() returns undefined
  return new Promise((resolve) => {
    let timeoutId;

    const finish = () => {
      clearTimeout(timeoutId);
      scroller.removeEventListener('scrollend', finish);
      scroller.removeEventListener('scroll', onScroll);
      resolve();
    };

    const onScroll = () => {
      // Restart the idle timer on every scroll event. If scrollend never fires
      // (browsers without scrollend support), this resolves once scrolling goes idle.
      clearTimeout(timeoutId);
      timeoutId = setTimeout(finish, 100);
    };

    scroller.addEventListener('scrollend', finish, { once: true });
    scroller.addEventListener('scroll', onScroll, { passive: true });

    // If no scroll event fires within the idle window (e.g., already in view), resolve.
    // 100ms is an example value; it only needs to exceed the gap between scroll events.
    timeoutId = setTimeout(finish, 100);
  });
}