Strona główna / Artykuły

Tajmerzy odliczające bez efektu dryfu w React: od setTimeout do czystego CSS

Porównaj setTimeout, requestAnimationFrame oraz technikę CSS bez użycia JavaScript do tworzenia liczników w React, włączając w to trik jednolitego opóźnienia, który zapewnia synchronizację cyfr.

Wydawca: REACTAPP.TOP17 min czytania4011 słów

Źródła: How I Build a Countdown Timer in React

Karta z kodem rabatowym z datą ważności wymaga widocznego zegara: godzin, minut i sekund odliczających czas do wygaśnięcia oferty. Ten element jest mały, ale ma duże znaczenie dla funkcjonowania produktu. Timer, który opóźnia się względem rzeczywistego czasu, skacze o dwa sekundy za każdym razem lub sprawia, że wentylator laptopa pracuje cicho, niszczy zaufanie użytkownika do wszystkiego wokół. Ten przewodnik przedstawia trzy sposoby na stworzenie takiego odliczania w React, wyjaśnia dokładnie, dlaczego pierwszy z nich się myli, a na końcu proponuje technikę opartą wyłącznie na CSS, która umożliwia obsługę setek timerów bez żadnego kodu JavaScript na każdą sekundę.

Problem w jednym zdaniu

Każdy kod rabatowy ma określoną claim_end_time. Wyświetlacz powinien pokazywać, ile czasu pozostało do tego momentu, w formacie HH:MM:SS, aktualizowany co sekundę, i powinien zatrzymać się na zerze po wygaśnięciu kodu rabatowego.

Matematyka jest prosta. Trudniejsze jest ustalenie jak i kiedy ponownie ją obliczyć, a ta decyzja wpływa na dokładność, koszt wykorzystania procesora oraz to, co można zrobić, gdy czas się skończy.

Podejście 1: łańcuch setTimeout i powody jego odchylenia

Instynktowna implementacja to funkcja, która aktualizuje wyświetlanie, a następnie umawia sobie ponowną ejecję po jednej sekundzie.

function tick() {
  updateDisplay();
  setTimeout(tick, 1000);
}
tick();

Czyta się to klarownie i wydaje się działać w szybkim teście. Problem leży w sposobie przekazywania timerów. JavaScript na stronie działa na pojedynczej głównej wątku, a pętla zdarzeń przekazuje mu po jednym zadaniu na raz. Odwołanie callbacka timera to po prostu kolejne zadanie, umieszczone w kolejce za obsługą kliknięć, odwołaniami sieciowymi, zadaniami renderowania oraz wszystkim tym, co React musi skonsolidować. Wartość 1000, którą podajesz, to wartość minimalna, a nie gwarancja. Jeśli wątek jest zajęty po upływie sekundy, callback czeka, więc może zostać wywołany dopiero po 1 050 ms lub 1 200 ms. Jeśli chcesz przypomnienie, dlaczego główny wątek zachowuje się w ten sposób, przeczytaj nasz artykuł na temat jednego wątku JavaScript w porównaniu z wieloprocesowym silnikiem przeglądarki.

To, co przekształca sporadyczne opóźnienia w widoczną dewiację, to łańcuchowanie. Każdy impuls planuje następny impuls w swoim wnętrzu, więc każde opóźnienie jest przenoszone na wszystkie kolejne impulsy. Nic nie przywraca sekwencji do zwykłych zegarów ścianowych. Pozwól takiej pętli działać przez dziesięć minut, porównaj ją z Date.now(), a zobaczysz, że jest w tyle.

Czasomierz, który zmniejsza licznik przy każdym impulsie, mierzy funkcje powrotne, a nie upływ czasu, a ograniczanie liczby kart w tle sprawia, że różnica staje się jeszcze większa.

Możesz to skompensować, mierząc, o ile później wywołana została każda funkcja powrotna, i odpowiednio skracając kolejne opóźnienie. To działa, ale w rezultacie musisz utrzymywać logikę korekcyjną, która przeciwstawia się platformie zamiast od niej korzystać. W obu przypadkach kod nadal jest wykonywany na głównej wątku co sekundę.

Podejście 2: requestAnimationFrame i ponowne obliczanie na podstawie zegara

requestAnimationFrame (rAF) prosi przeglądarkę o wywołanie Twojej funkcji tuż przed kolejnym odświeżeniem ekranu, co na większości monitorów następuje około 60 razy na sekundę. Jednak to częstotliwość nie jest najważniejsza. Kluczowa zmiana ma charakter koncepcyjny: przestajesz pytać „czy minęła kolejna sekunda?”, a zamiast tego pytasz „ile czasu pozostało w tym dokładnym momencie?”.

function updateTime() {
  const now = Date.now();
  const remaining = endTime - now;
  if (remaining <= 0) {
    display('00:00:00');
    return;
  }
  display(format(remaining));
  requestAnimationFrame(updateTime);
}
requestAnimationFrame(updateTime);

W każdym kadru czas pozostały jest obliczany od zera, przy użyciu Date.now() jako jedynej źródłowej informacji. Nie ma licznika kroków ani akumulowanych interwałów. Jeśli jeden kadr przychodzi późno, następny po prostu ponownie odczytuje czas i pokazuje prawidłową wartość. Błędy nie mogą się gromadzić, ponieważ nic nie jest przenoszone z jednego kadru na drugi poza samym czasem końcowym. Ten sam princip sprawdza się również w wersji z setTimeout: jeśli każda funkcja powrotna oblicza wartość na podstawie Date.now() zamiast odliczać wartość z licznika, wyświetlacz sam się koryguje, nawet gdy funkcje powrotne przychodzą późno.

Rozsądna zastrzeżenie: widoczna wartość zmienia się tylko raz na sekundę, więc po co wykonywać jakiekolwiek operacje 60 razy na sekundę? W React drogim elementem nie jest odejmowanie, lecz ponowne renderowanie wywołane przez setState. Dlatego pętla porównuje wartości na każdym kadru i aktualizuje stan tylko wtedy, gdy rzeczywiście zmienia się wartość po upływie pełnej sekundy.

Aktualizacja stanu tylko przy zmianie sekundy

Oto ta koncepcja wewnątrz komponentu. Należy zauważyć, że endTime to timestamp w systemie Unix wyrażony w sekundach, dlatego przed porównaniem z Date.now() mnoży się go przez 1000.

const updateTime = (): void => {
  const now = Date.now();
  const timeTillStop = endTime * 1000 - now;
  if (timeTillStop <= 0) {
    setTimeLeft({ hours: 0, minutes: 0, seconds: 0 });
    onNoCountdownTimeLeft?.();
    return;
  }
  const totalSeconds = Math.floor(timeTillStop / 1000);
  if (totalSeconds !== lastSecondsRef.current) {
    lastSecondsRef.current = totalSeconds;
    const hours = Math.floor(totalSeconds / 3600);
    const minutes = Math.floor((totalSeconds % 3600) / 60);
    const seconds = totalSeconds % 60;
    setTimeLeft({ hours, minutes, seconds });
  }
  animationRef.current = requestAnimationFrame(updateTime);
};

Dwie ref-y pełnią kluczową rolę. lastSecondsRef przechowuje ostatni wyrenderowany sekundę. Ponieważ jest to ref, zapisywanie do niego nigdy nie powoduje renderowania; jest to po prostu miejsce w pamięci, które przetrwa między kadrami. Dlatego pętla działa z taką samą częstotliwością jak klatki, podczas gdy React renderuje co najwyżej raz na sekundę. Opcjonalna funkcja zwrotna onNoCountdownTimeLeft jest wywoływana po wyczerpaniu czasu, dając komponentowi rodzicielskiemu możliwość ukrycia karty, ponownego pobrania danych lub wyświetlenia stanu „wygasłego”.

Druga ref, animationRef, przechowuje ID zwrócone przez requestAnimationFrame. Funkcja czyszczenia efektu wykorzystuje je do anulowania kolejnej klatki, gdy komponent jest usuwany lub zmienia się wartość endTime:

return () => {
  if (animationRef.current) {
    cancelAnimationFrame(animationRef.current);
  }
};

Bez tej czyszczenia pętla nadal byłaby uruchamiana po opuszczeniu strony przez użytkownika, wywołując setState w nieprzytwierdzonej komponencie i marnując zasoby CPU. Dzięki temu komponent działa poprawnie, jego renderowanie jest tanie i sam się koryguje.

Kilka praktycznych uwag. Przeglądarki wstrzymują wywołania rAF w ukrytych kartach, co jest tutaj wręcz przydatne: pętla przestaje kosztować cokolwiek w tle, a pierwszy kadr po ponownym pokazaniu karty odczytuje aktualny czas i od razu przechodzi do właściwej wartości. Porównanie na każdym kadrze jest zaniedbywalne przy jednym timerze, ale ma znaczenie przy dziesiątkach kart.

Podejście 3: odliczanie wyłącznie za pomocą CSS

Wersja oparta na rAF nadal wykonywa JavaScript na każdym kadrze. Trzecie podejście całkowicie to eliminuje: JavaScript oblicza jedną wartość przy przytwierdzeniu komponentu, a od tego momentu silnik animacji przeglądarki zajmuje się każdym krokiem odliczania.

Licznik odwrotny staje się animacją CSS, która przechodzi przez poszczególne cyfry, zaczynając się od ujemnego opóźnienia, dzięki czemu rozpoczyna się w środku sekwencji we właściwym momencie.

Jedno wyjaśnienie dotyczące wydajności przed kontynuacją. Można przeczytać, że animacje CSS zawsze działają na wątku kompozytora i dlatego nigdy nie mogą zostać zablokowane. Dotyczy to tylko niewielkiej grupy właściwości, głównie transform, opacity oraz niektórych filtrów. Animowanie zarejestrowanych własnych właściwości i generowanego contentu, jak to robi ta technika, jest obsługiwane przez silnik stylów przeglądarki na głównym wątku. Prawdziwa zaleta jest inna, ale nadal znacząca: nie ma timerów, żadnego stanu React ani renderowań na każdym kroku, a własny planer przeglądarki, który łączy postęp animacji z harmonogramem dokumentu, zapewnia poprawność wartości.

Aby to zadziałało, konieczna jest współpraca czterech funkcji CSS. Przejdziemy przez nie po kolei.

Krok 1: deklaracja liczb podlegających animacji za pomocą @property

Oczywistym pierwszym podejściem jest animowanie bezpośrednio właściwości content. Nie udaje się to, ponieważ content nie podlega interpolacji – przeglądarka nie wie, jak ciąg znaków „59” zamienia się w „58”. Potrzebna jest liczba, którą przeglądarka może animować, a zarejestrowana własna właściwość zapewnia dokładnie to.

@property --s {
  syntax: '<integer>';
  inherits: true;
  initial-value: 59;
}

@property rejestruje --s wraz z określonym typem. Deklaracja syntax: '<integer>' informuje przeglądarkę, że wartość to rzeczywisty liczebnik, a nie nieprzejrzysty token, co umożliwia jej interpolację, a tym samym animację. Ustawienie inherits: true pozwala potomkom, w tym pseudo-elementom ::after, na odczytanie wartości animowanej od ich rodzica. Oznacza to, że jedna animacja na kontenerze zegara wpływa na każdą cyfrę w jego obrębie. Aby dowiedzieć się więcej na temat zarejestrowanych właściwości oraz różnic między nimi a zwykłymi własnościami użytkownika, zapoznaj się z naszym przewodnikiem CSS custom properties at runtime and @property.

Każda jednostka ma swoją własną zarejestrowaną właściwość. Godziny zaczynają się od 99, co stanowi górny limit tego, co może pokazać ten zegar.

@property --s {
  syntax: '<integer>';
  inherits: true;
  initial-value: 59;
} /* seconds */
@property --m {
  syntax: '<integer>';
  inherits: true;
  initial-value: 59;
} /* minutes */
@property --h {
  syntax: '<integer>';
  inherits: true;
  initial-value: 99;
} /* hours   */

Krok 2: wyświetlenie liczby całkowitej jako tekstu wypełnionego zerami za pomocą counter()

Liczba całkowita musi nadal zostać przekształcona w tekst wypełniony zerami (05, a nie 5). Funkcja counter() wyświetla wartość licznika wewnątrz content, a jej wbudowany styl decimal-leading-zero dostarcza darmowe wypełnienie dwoma cyframi.

Mostem pomiędzy własnością niestandardową a licznikiem jest counter-reset: ustawia on nazwany licznik na aktualną wartość tej właściwości, a counter() odczytuje ją ponownie. Ten fragment to CSS, mimo że źródło określa go inaczej.

.seconds-digit::after {
  counter-reset: sc var(--s); /* set the "sc" counter = current --s value */
  content: counter(sc, decimal-leading-zero); /* render as "00"–"59" */
}
.minutes-digit::after {
  counter-reset: mc var(--m);
  content: counter(mc, decimal-leading-zero);
}
.hours-digit::after {
  counter-reset: hc var(--h);
  content: counter(hc, decimal-leading-zero);
}

Za każdym razem, gdy --s zmienia się na nową liczbę całkowitą, silnik stylów przelicza counter-reset, a content pseudo-elementu aktualizuje się zgodnie z tymi wartościami. W tej aktualizacji nie bierze udziału żaden skrypt.

Krok 3: sprawienie, by wartości zmieniały się krok po kroku za pomocą steps()

Domyślnie animacja zmienia się stopniowo pomiędzy klatkami kluczowymi. Zegar nie może przesuwać się płynnie; musi skakać od jednej cyfry do następnej. Funkcja sterowania czasem steps(N, end) dzieli całkowity czas trwania na N równych, dyskretnych skoków.

@keyframes tick-s {
  from {
    --s: 59;
  }
  to {
    --s: -1;
  }
}
animation: tick-s 60s steps(60, end) infinite;

Sześćdziesiąt skoków w ciągu sześćdziesięciu sekund oznacza jeden skok na sekundę: 59 na początku, 58 po jednej sekundzie, 0 po 59 sekundach oraz ponownie 59 na początku kolejnej iteracji.

Słowo kluczowe end (ekwiwalentne do jump-end) umieszcza każdy skok na końcu jego interwału. Dokładnie w momencie t = 0 animacja nadal pokazuje wartość from, ponieważ pierwszy skok jeszcze nie nastąpił. Ten detal ma znaczenie, gdy pojawiają się ujemne opóźnienia: opóźnienie trwające dokładnie t = 0 wyświetla wartość 59 zamiast 58.

Zakończenie klatek kluczowych na wartości -1 wydaje się dziwne, ale jest celowe. Animacja interpoluje wartości w zakresie 60 liczb całkowitych, a dzięki opcji jump-end wraca do wartości from zanim pojawi się ostateczna wartość. Dlatego widoczna sekwencja składa się dokładnie z 59 klatek, od 0 w dół, przy czystych krokach o wartości jedności.

Każda jednostka ma własną liczbę klatek kluczowych oraz kroków. Minuty obejmują 60 kroków po 60 sekund każdy, a godziny – 100 kroków po jednej godzinie każdy:

@keyframes tick-s {
  from {
    --s: 59;
  }
  to {
    --s: -1;
  }
} /* 60 steps × 1s  = 60s  */
@keyframes tick-m {
  from {
    --m: 59;
  }
  to {
    --m: -1;
  }
} /* 60 steps × 60s = 1hr  */
@keyframes tick-h {
  from {
    --h: 99;
  }
  to {
    --h: -1;
  }
} /* 100 steps × 1hr = 100hr */

Krok 4: rozpoczęcie w środku sekwencji z ujemnym opóźnieniem

To jest kluczowy trik. Gdy wartość animation-delay jest ujemna, przeglądarka traktuje animację tak, jakby rozpoczęła się tyle sekund wcześniej, więc zaczyna od punktu, który osiągnęłaby w tym czasie.

Załóżmy, że w bieżącej minucie pozostało 23 sekundy. Animacja sekund zaczyna się od wartości 59 i musi już obniżyć się o 36 kroków, aby pokazać 23, więc opóźnienie -36s umieszcza ją we właściwym miejscu. Ogólnie rzecz biorąc:

delay = -(59 - s)      where s = remaining seconds within the current minute

Aby zastosować to do wszystkich trzech jednostek, najpierw potrzebujesz całkowitego czasu pozostałego w sekundach, obliczonego na podstawie daty końcowej:

const remaining = endTime - Math.floor(Date.now() / 1000);

Tę wartość całkowitą następnie dzieli się na części odpowiadające poszczególnym jednostkom. Godziny są ograniczane do 99, ponieważ animacja godzin obejmuje tylko 100 kroków:

const s = remaining % 60;
const m = Math.floor(remaining / 60) % 60;
const h = Math.min(Math.floor(remaining / 3600), 99);

Pamiętaj o tym ograniczeniu jako o warunku koniecznym. Voucher wygasający za pięć dni pokazałby 99:59:59 i wydawałby się zablokowany, dopóki nie pozostanie mniej niż 100 godzin. Jeśli twoje oferty mogą trwać tak długo, dodaj jednostkę dni lub zmień format.

Zachowywanie synchronizacji cyfr przy użyciu jednego opóźnienia

Oczywistym następnym krokiem jest obliczenie oddzielnego opóźnienia na jednostkę. Prowadzi to do poważnego błędu: sekundy wracają do wartości 59, podczas gdy minuty pozostają na tym samym poziomie przez zbyt długi czas.

Dlaczego oddzielne opóźnienia się różnią

Weźmy przykład remaining = 125, co odpowiada 0 godzinom, 2 minutom i 5 sekundom. Obliczanie opóźnień dla sekund i minut niezależnie daje:

delayS = -(59 - 5)       = -54s     → seconds virtual start: now - 54s
delayM = -(59 - 2) × 60  = -3420s   → minutes virtual start: now - 3420s

Każda animacja mierzy swoje skoki względem prywatnego, wirtualnego czasu startu. Kroki sekundowe występują co sekundę, licząc od now - 54s; kroki minutowe występują co 60 sekund, licząc od now - 3420s. Ponieważ punkty początkowe są różne, granice przypadają na niepowiązane momenty zegara. Cyfra sekund osiąga tu kolejne przełamanie:

now - 54 + 60 = now + 6s

Cyfra minut natomiast zaczyna się zmieniać dopiero wtedy, gdy:

now - 3420 + (58 × 60) = now - 3420 + 3480 = now + 60s

Zatem sekundy przekraczają wartość sześciu sekund od teraz, podczas gdy minuty zmieniają się o pełną minutę. Przez 54 sekundy wyświetlacz pokazuje 01:59, mimo że rzeczywista wartość jest poniżej 01:00. Czas jest błędny przez większość każdej minuty.

Jedno wspólne źródło

Rozwiązaniem jest użycie wspólnego źródła dla wszystkich trzech animacji, uzyskiwane poprzez połączenie odstępów w jeden opóźnienie:

const delay = -((99 - h) * 3600 + (59 - m) * 60 + (59 - s));
//           = -(  99×3600      +   57×60       +   54   )
//           = -(356400 + 3420 + 54)
//           = -359874s

Teraz każda animacja mierzy czas od now - 359874s. Wszystkie jej granice odpowiadają wielokrotnościom czasu trwania pojedynczego kroku, liczonego od tego punktu początkowego. Sprawdź przykład: w oknie 60 sekund animacja sekund znajduje się na pozycji 54, więc jej następna granica przypada po 6 sekundach. W oknie 3600 sekund animacja minut znajduje się na pozycji 3474, a jej następna granica to 3480, również po 6 sekundach. Obie wartości zmieniają się jednocześnie w momencie now + 6s i pozostają zsynchronizowane co 60 sekund.

Jak jedna liczba koduje trzy cyfry

Łączny opóźnienie funkcjonuje jak liczba o zmieszanej podstawie, podobnie jak HH:MM:SS łączy trzy pola w jedną ciąg liter. Kodowanie polega na składaniu jej z trzech komponentów:

|delay| = (99 - h) × 3600    ← hours field,   each unit = 3600s
        + (59 - m) × 60      ← minutes field, each unit = 60s
        + (59 - s)           ← seconds field, each unit = 1s

Dekodowanie to proces, który każda animacja wykonuje w sposób domyślny, patrząc tylko na swoje własne okno czasowe:

seconds position = |delay| mod 60                 → display 59 - position
minutes position = floor((|delay| mod 3600) / 60) → display 59 - position
hours   position = floor(|delay| / 3600)          → display 99 - position

Uruchomienie przykładu za pomocą dekodera potwierdza to. 359,874 podzielone przez 60 daje 54, więc sekundy wyświetlają 59 − 54 = 5. 359,874 podzielone przez 3,600 daje 3,474, które po podzieleniu przez 60 i zaokrągleniu w dół wynosi 57, więc minuty wyświetlają 59 − 57 = 2. 359,874 podzielone przez 3,600 i zaokrąglenie w dół daje 99, więc godziny wyświetlają 99 − 99 = 0. Wyświetlanie to brzmi 00:02:05, co jest poprawne.

Animacje nie mogą się wzajemnie zakłócać, ponieważ każde pole odpowiada dokładnemu wielokrotności cyklu następnego pola: część dotycząca godzin jest zawsze wielokrotnością 3,600, a część dotycząca minut zawsze wielokrotnością 60, więc żadna z nich nie zakłóca pozostałych wartości.

Czyste zatrzymanie się na 00:00:00

Animacje tikające wykorzystują iterację infinite, więc same w sobie nigdy się nie zatrzymują. Gdy cykl sekund dobiega końca, wartość wraca do 59, a zegar, który osiągnie zero, chętnie rozpocznie ponowny odliczanie.

Rozwiązaniem jest kolejna animacja, która uruchamia się tylko raz, dokładnie w momencie wygaśnięcia opóźnienia, i ustawia wszystkie trzy właściwości na zero. Ten blok to również CSS, mimo swojego pierwotnego oznaczenia:

@keyframes freeze {
  to {
    --s: 0;
    --m: 0;
    --h: 0;
  }
}
.timer {
  animation-name: tick-s, tick-m, tick-h, freeze;
  animation-duration: 60s, 3600s, 360000s, 1ms;
  animation-timing-function: steps(60, end), steps(60, end), steps(100, end),
    step-start;
  animation-iteration-count: infinite, infinite, infinite, 1;
  animation-fill-mode: none, none, none, forwards;
}

JavaScript przypisuje wspólne opóźnienie trzem animacjom typu tick, a pozostały czas jako dodatnie opóźnienie animacji freeze:

el.style.animationDelay = `${delay}s, ${delay}s, ${delay}s, ${remaining}s`;

Tutaj współpracują trzy elementy:

  • step-start (to samo co steps(1, jump-start)) przechodzi bezpośrednio do końcowej klatki kluczowej zaraz po rozpoczęciu animacji, a nie na końcu jej 1 ms trwania, dzięki czemu wszystkie trzy wartości stają się zero w momencie wygaśnięcia opóźnienia.
  • animation-fill-mode: forwards sprawia, że końcowa klatka kluczowa pozostaje zastosowana po zakończeniu animacji, więc wartości zero utrzymują się nieograniczenie długo.
  • Kolejność w animation-name ma znaczenie: gdy kilka animacji ustawia tę samą właściwość, zwycięża ta wymieniona później. Ponieważ freeze znajduje się na końcu, jego wartość zero przykrywa animacje tick, które nadal się powtarzają, ale nie dają widocznego efektu.
  • Jeśli kod rabatowy wygasł już przy ładowaniu strony, pozostały czas wynosi 0, opóźnienie związane z zamrożeniem wynosi 0s, a zegar natychmiast pokazuje zerówkę.

    Jakie są koszty podejścia opartego na CSS

    Taki design bez JavaScript ma rzeczywiste ograniczenia, które powinny wpłynąć na twoją decyzję:

    • Brak mechanizmu wygaśnięcia. Nie ma żadnego callbacku, zdarzenia ani zmiany stanu, gdy zegar osiąga wartość zero. Jeśli wygaśnięcie musi uruchomić żądanie API, ukryć kartę lub wyświetlić coś innego, potrzebujesz oddzielnego, jednorazowego wywołania setTimeout, co przywraca część prostoty.
    • Ograniczony styl. Funkcja counter() generuje zwykły tekst. Czcionka, kolor i układ są w pełni pod twoją kontrolą, ale wszystko, co zależy od aktualnej wartości w JavaScript, takie jak zmiana koloru na czerwony po upływie 60 sekund lub dodanie klasy przy osiągnięciu określonego progu, wymaga dodatkowej pracy.
    • Dokładność do pełnych sekund. Pozostały czas jest zaokrąglany do pełnych sekund przy uruchomieniu, więc widoczne odmierzanie może odbiegać o maksymalnie jedną sekundę od rzeczywistych granic sekund. Ma to znaczenie głównie przy licznikach promocyjnych, ale nie jest to zegar o wysokiej dokładności.
    • Obsługa przeglądarek. Ta technika opiera się na @property oraz animowanych, zarejestrowanych właściwościach; przed jej użyciem sprawdź obsługę w aktualnych danych kompatybilnościowych dla swoich przeglądarek.

    Jako zamianę, w przypadku licznika tylko do odczytu, który musi jedynie odliczać i zatrzymywać się, jest on niezwykle wydajny: jeden zestaw animacji na kartę, brak JavaScriptu przy każdym odliczaniu, brak błędów w działaniu oraz nic do usuwania po zakończeniu użycia. Doskonale sprawdza się, gdy strona renderuje jednocześnie setki kart z voucherami.

    Komponent React do timera CSS

    wersja CSS zmienia kształt komponentu. Nie ma stanu, nie ma pętli animacji ani konieczności usuwania czegokolwiek. Komponent odczytuje wartość czasu raz, zapisuje jedną właściwość stylu i renderuje markup. Wymaga tylko dwóch hooków z React:

    import { useEffect, useRef } from 'react';
    

    Poniższy komponent oblicza pozostałe sekundy, dzieli je na jednostki, buduje wspólny opóźnienie i zapisuje cztery takie opóźnienia do elementu. Atrybut type jedynie zmienia między sobą dwa wizualne style cyfr.

    interface CSSCountdownTimerProps {
      endTime: number; // Unix timestamp in seconds, same as existing prop
      type?: 'solid' | 'regular';
    }export function CSSCountdownTimer({
      endTime,
      type = 'solid',
    }: CSSCountdownTimerProps) {
      const timerRef = useRef<HTMLDivElement>(null);  useEffect(() => {
        const el = timerRef.current;
        if (!el) return;    const now = Math.floor(Date.now() / 1000);
        const remaining = Math.max(0, endTime - now);    const s = remaining % 60;
        const m = Math.floor(remaining / 60) % 60;
        const h = Math.min(Math.floor(remaining / 3600), 99);    const delay = -((99 - h) * 3600 + (59 - m) * 60 + (59 - s));    el.style.animationDelay = `${delay}s, ${delay}s, ${delay}s, ${remaining}s`;    // No cleanup needed.
        // There are no JS timers, no rAF loops, no subscriptions.
        // CSS animations stop automatically when the element is removed from the DOM.
      }, [endTime]);  return (
        <div ref={timerRef} className={`timer ${type}`}>
          <span
            className={type === 'solid' ? 'digit hours-digit' : 'hours-digit'}
          />
          <span className="sep">:</span>
          <span
            className={type === 'solid' ? 'digit minutes-digit' : 'minutes-digit'}
          />
          <span className="sep">:</span>
          <span
            className={type === 'solid' ? 'digit seconds-digit' : 'seconds-digit'}
          />
        </div>
      );
    }
    

    Jak wskazują komentarze, nie jest potrzebna żadna czyszczenie pamięci: gdy element opuszcza DOM, jego animacje znikają razem z nim. Warto rozważyć dwie ulepszenia. Po pierwsze, ponieważ useEffect jest wykonywany po narysowaniu elementu przez przeglądarkę, pierwszy kadru może na krótko pokazać animacje w ich domyślnych pozycjach przed zastosowaniem opóźnienia; useLayoutEffect lub obliczenie opóźnienia podczas renderowania i przekazanie go jako styl wewnętrzny zapobiega temu efektowi migotania. Po drugie, ten efekt zależy od endTime, więc jeśli zmieni się właściwość, opóźnienia są ponownie obliczane, a animacje poprawnie przyjmują nowe pozycje.

    Wybór spośród trzech podejść

    Prosta zasada decyzyjna obejmuje większość sytuacji:

    • Jeśli po wygaśnięciu timera musi coś się wydarzyć, np. aktualizacja stanu, ponowne pobranie danych lub wywołanie API, użyj requestAnimationFrame w połączeniu z ponownym obliczaniem czasu na podstawie zegara. Dzięki temu masz pełną kontrolę i naturalne miejsce na funkcję powrotną po wygaśnięciu timera.
    • Jeśli strona pokazuje wiele timerów, a żaden z nich nie uruchamia żadnej logiki, skorzystaj z techniki CSS i pozwól przeglądarce wykonać tę pracę.
    • Jeśli używasz zwykłej łańcuchowej funkcji setTimeout, która zmniejsza licznik, zastąp ją innym rozwiązaniem. Jest ona niesprecyzyzowana i nadal wymaga pracy w głównej wątku co sekundę, więc łączy wady obu alternatyw. Obie metody można również połączyć: CSS do wyświetlania, jeden wspólny timer do obsługi logiki związanej z wygaśnięciem.

    Główne wnioski

    • Odroczenia timerów to wartości minimalne; obliczaj pozostały czas na podstawie Date.now() przy każdej aktualizacji zamiast liczyć kroki czasu, wtedy efekt odchylenia znika.
  • Z użyciem rAF przechowuj ostatni wyrenderowany sekundę w zmiennej referencyjnej i anuluj rysowanie klatki przy odłączeniu.
  • wersja CSS opiera się na czterech funkcjach: liczbach całkowitych @property, funkcji counter() do wypełniania tekstu, funkcji steps() do dyskretnych skoków oraz ujemnym opóźnieniu, aby rozpocząć działanie w środku sekwencji.
  • Dziel jedno opóźnienie pomiędzy wszystkie elementy, aby cyfry przeskakiwały razem, i dodaj animację freeze, aby utrzymać zera.