Головна / Статті

Таймери зворотного відліку без дрифту у React: від setTimeout до чистого CSS

Порівняйте setTimeout, requestAnimationFrame та CSS-техніку без використання JavaScript для створення таймерів зворотного відліку у React, включаючи трик єдиного затримки, який забезпечує синхронізацію цифр.

Видавець: REACTAPP.TOP17 хв читання4011 слів

Джерела: How I Build a Countdown Timer in React

Картка-купон із терміном дії потребує видимих циферблатів, які відображають години, хвилини та секунди до закінчення акції. Цей елемент є дуже малим, але він впливає на сприйняття цінності продукту. Таймер, який відставає від реального часу, раптово проскакує на дві секунди або змушує вентилятор ноутбука працювати без зупинки, підриває довіру користувача до всього навколо. У цьому посібнику розглядаються три способи створення такого обліку часу в React, пояснюється, чому перший спосіб відставає, а також наводиться техніка, яка дозволяє керувати сотнями таймерів лише за допомогою CSS, без жодного JavaScript на кожен крок.

Проблема в одному реченні

Кожен купон має значення claim_end_time. Відображення має показувати, скільки часу залишилося до цього моменту у форматі HH:MM:SS, оновлюючись щосекунди, та має зупинитися при значенні нуля після закінчення терміну дії купона.

Арифметика тут проста. Складною є частина, пов’язана з вирішенням питань як та коли її перерахувати, і саме цей вибір визначає точність, енергоспоживання процесора та можливості, які є у вас, коли закінчується час.

Підхід 1: ланцюжок setTimeout та причини його відхилень

Інстинктивна реалізація — це функція, яка оновлює відображення, а потім знову заплановує виконання себе через одну секунду.

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

Цей код читається зрозуміло та, схоже, працює під час швидкого тестування. Проблема полягає у способі передачі таймерів. JavaScript на сторінці виконується на єдиній основній смузі, а цикл подій передає йому по одному завданню за раз. Колбек таймера є просто ще одним завданням, яке входить у чергу після обробників кліків, колбеків мережевих операцій, завдань на відображення та всього іншого, що обробляє React. Значення 1000, яке ви передаєте, є мінімальним терміном, а не гарантованим результатом. Якщо смуга зайнята на момент закінчення цього часу, колбек буде чекати, і його виконання може відбутися через 1 050 мс або 1 200 мс. Якщо ви хочете згадати, чому основна смуга поводиться саме так, ознайомтесь з нашою статтею Про єдину смугу JavaScript та багатопроцесний двигун браузера.

Те, що перетворює часові затримки в помітне відхилення, — це ланцюгова структура. Кожен імпульс заплановує наступний імпульс всередині себе, тож будь-яка затримка передається усім наступним імпульсам. Ніщо не може повернути цю послідовність у відповідність до часу з годинника. Запустіть такий цикл на десять хвилин, порівняйте його з Date.now(), і ви побачите, що він відстає.

Таймер, який зменшує лічильник при кожному імпульсі, вимірює кількість викликів функцій, а не пройдений час, а обмеження кількості використовуваних вкладок у фоновому режимі ще більше збільшує цю різницю.

Ви можете компенсувати це, вимірюючи, наскільки пізно виконується кожен виклик функції, та відповідно скорочуючи наступну затримку. Це працює, але вам доводиться підтримувати логіку корекції, яка протистоїть платформі замість того, щоб користуватися її можливостями. У будь-якому разі код все одно виконується на основній смузі кожну секунду.

Підхід 2: requestAnimationFrame та перерахунок на основі часу

requestAnimationFrame (rAF) просить браузер викликати вашу функцію безпосередньо перед наступним оновленням зображення, що на більшості дисплеїв відбувається приблизно 60 разів на секунду. Однак частота не є ключовою складовою. Важливою є концептуальна зміна: ви перестаєте запитувати «Чи минула ще одна секунда?», а замість цього запитуєте «Скільки часу залишилося саме у цю мить?»

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

На кожному кадрі залишковий час обчислюється заново, використовуючи Date.now() як єдине джерело інформації. Немає лічильника кадрів чи накопичених інтервалів. Якщо один кадр надходить з запізненням, наступний просто знову читає значення з годинника та відображає правильне число. Помилки не можуть накопичуватися, оскільки з одного кадру на інший передається лише сам час завершення. Той самий принцип діє й у версії з setTimeout: якщо кожен копі-функція обчислює значення заново за допомогою Date.now() замість того, щоб зменшувати лічильник, відображення самостійно коригується навіть у разі запізнення копі-функцій.

Правомірна заперечення: видиме значення змінюється лише один раз на секунду, тож навіщо виконувати якісь операції 60 разів на секунду? У React дорогим є не саме віднімання, а перерендеринг, який викликається функцією setState. Тож цикл порівнює значення на кожному кадрі та оновлює стан лише тоді, коли значення справді змінюється протягом усієї секунди.

Оновлення стану лише тоді, коли змінюється секунда

Ось ця ідея реалізована всередині компонента. Зверніть увагу, що endTime — це Unix-часовий маркер у секундах, тому його помножують на 1000 перед порівнянням із Date.now().

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

Два рефи виконують основну роботу. lastSecondsRef зберігає останню відрендеровану секунду. Оскільки це реф, запис у нього ніколи не спричиняє перерендерування; це просто блок пам’яті, який зберігається між кадрами. Тому цикл виконується зі швидкістю кадрів, тоді як React рендерується щонайбільше один раз на секунду. Опційна функція-колбек onNoCountdownTimeLeft викликається після того, як час закінчується, надаючи батьківському елементу можливість приховати картку, знову завантажити дані або показати стан „термін дії закінчився“.

Другий реф, animationRef, зберігає ID, який повертає requestAnimationFrame. Функція очищення ефекту використовує його для скасування наступного кадру, коли компонент демонтовується або змінюється endTime:

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

Без цього очищення цикл буде продовжувати працювати навіть після того, як користувач відійде, викликаючи setState у немонтованому компоненті та марнуючи ресурси CPU. З ним компонент працює точно, його відображення є дешевим, а також він самостійно коригується.

Кілька практичних зауважень. Браузери припиняють виконання функцій rAF у прихованих вкладках, що насправді допомагає тут: цикл більше не спричиняє жодних витрат на фоні, а перша кадр після того, як вкладка стає видимою, зчитує значення таймера та одразу переходить до правильного значення. Порівняння на кожному кадрі є незначним для одного таймера, але сумується при десятках карток.

Підхід 3: відлік часу, створений виключно за допомогою CSS

Версія з rAF все одно виконує JavaScript на кожному кадрі. Третій підхід повністю усуває це: JavaScript обчислює одне значення під час монтажу компонента, а з того моменту двигун анімацій браузера керує кожним кроком часу.

Облік часу перетворюється на анімацію CSS, яка по черзі відображає цифри; вона починається з негативної затримки, щоб розпочатися посеред послідовності у правильний момент.

Одне уточнення щодо продуктивності перед тим, як рухатися далі. Можна прочитати, що анімації CSS завжди виконуються на потоці композиції тому ніколи не можуть бути заблоковані. Це справедливо лише щодо невеликої кількості властивостей, переважно transform, opacity та деяких фільтрів. Анімування зареєстрованих користувацьких властивостей та генерованого content, як це робить ця техніка, обробляється двигуном стилів браузера на основному потоці. Справжня перевага інша, але все одно значна: немає таймерів, немає стану React та немає відрендеровувань за кожну ітерацію, а власний планувальник браузера, який пов’язує прогрес анімації з часовою шкалою документа, підтримує правильність значень.

Щоб це спрацювало, необхідна співпраця чотирьох функцій CSS. Ми розглянемо їх по одній.

Крок 1: оголошення цілих чисел, які можна анімувати, за допомогою @property

Очевидною першою спробою є пряма анімування властивості content. Це не спрацьовує, оскільки content не піддається інтерполяції: браузер не розуміє, як рядок „59“ перетворюється на „58“. Вам потрібне число, яке браузер може анімувати, і саме реєстрована власна властивість забезпечує це.

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

@property реєструє --s разом із типом. Зазначення syntax: '<integer>' повідомляє браузеру, що значення є справжнім цілим числом, а не непрозорим токеном, що дозволяє його вставляти та, отже, анімувати. Встановлення inherits: true дозволяє нащадкам, включаючи псевдоелементи ::after, читати анімоване значення з їхнього батька. Це означає, що одна анімація контейнера таймера керує кожною цифрою всередині нього. Щоб детальніше дізнатися про зареєстровані властивості та їхні відмінності від звичайних користувацьких властивостей, перегляньте наш посібник CSS користувацькі властивості у часі виконання та @property.

Кожна одиниця має свою власну зареєстровану властивість. Години починаються з 99, що є верхньою межею для відображення цим таймером.

@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   */

Крок 2: відображення цілого числа у форматі з заповненням за допомогою counter()

Ціле число все ще має перетворитися на текст із заповненням нулями (05, а не 5). Функція counter() відображає значення лічильника всередині content, а її вбудований стиль decimal-leading-zero безкоштовно забезпечує двозначне заповнення.

Зв’язок між власною властивістю та лічильником створюється за допомогою counter-reset: він встановлює іменований лічильник у поточне значення властивості, а counter() знову його читає. Цей фрагмент є CSS, хоча в джерелі його інакше позначено.

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

Щоразу, коли --s змінюється на нове ціле число, движок стилів перераховує значення counter-reset, і content псевдоелемента оновлюється відповідно. У цьому оновленні не бере участі жоден скрипт.

Крок 3: зміна значень за допомогою steps()

За замовчуванням анімація плавно переходить між ключовими кадрами. Годинник не повинен „пливти“ — він має різко змінювати цифру. Функція таймінгу steps(N, end) ділить тривалість на N рівних, дискретних кроків.

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

Шістдесят кроків протягом шістдесяти секунд означають один крок на секунду: 59 на початку, 58 через одну секунду, 0 через 59 секунд, і знову 59 при початку наступної ітерації.

Ключове слово end (еквівалентне jump-end) розміщує кожен крок у кінці свого інтервалу. Рівно при t = 0 анімація все ще відображає своє значення from, оскільки перший крок ще не відбувся. Цей момент має значення, коли з’являються від’ємні затримки: затримка, яка закінчується рівно при t = 0, відображає 59 замість 58.

Завершення ключових кадрів на значенні -1 здається дивним, але це навмисно. Анімація інтерполюється в діапазоні з 60 цілих чисел, і через параметр jump-end вона повертається до значення from ще до того, як з’явиться остаточне значення. Тож видима послідовність складається саме з 59 кроків від 0, з кроком одиниця.

Кожна одиниця має власні ключові кадри та кількість кроків. Хвилини складаються з 60 кроків по 60 секунд кожен, а години — з 100 кроків по одній годині кожен:

@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 */

Крок 4: початок анімації посеред послідовності з негативною затримкою

Це основний трик. Коли значення animation-delay є від’ємним, браузер розглядає анімацію як таку, що почалася на стільки секунд раніше, тож вона починається у той момент, який був би досягнутий зараз.

Припустимо, у поточній хвилині залишилося 23 секунди. Анімація секунд починається з 59 та мусить вже зменшитися на 36 кроків, щоб показати 23, тому затримка -36s ставить її у правильне положення. Загалом:

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

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

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

Цей загальний час потім ділиться на частини для кожної одиниці. Числа годин обмежуються 99, оскільки анімація годин охоплює лише 100 кроків:

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

Пам’ятайте про це обмеження як про умову для форматування даних. Купон, який закінчується через п’ять днів, буде відображати 99:59:59 та здаватиметься застиглим, поки не залишиться менше 100 годин. Якщо ваші пропозиції можуть тривати так довго, додайте одиницю «дні» або змініть формат.

Синхронізація цифр за допомогою єдиної затримки

Очевидним наступним кроком є обчислення окремої затримки для кожної одиниці. Це призводить до серйозної проблеми: секунди повертаються до 59, тоді як хвилини залишаються на тому ж рівні протягом занадто довгого часу.

Чому окремі затримки відхиляються одна від одної

Візьмемо remaining = 125, що означає 0 годин, 2 хвилини та 5 секунд. Окреме обчислення затримок у секундах та хвилинах дає:

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

Кожна анімація вимірює свої зміни відносно приватного віртуального часу початку. Кроки у секундах відбуваються щосекунди, починаючи з now - 54s; кроки у хвилинах — щогодини, починаючи з now - 3420s. Оскільки точки відліку різні, межі настають у нерелевантні моменти за звичайним годинником. Цифра секунд досягає наступного переповнення тут:

now - 54 + 60 = now + 6s

Однак цифра хвилин починає змінюватися лише тоді:

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

Отже, через шість секунд знову почнеться облік секунд, тоді як хвилини змінюються через повну хвилину. Протягом 54 секунд дисплей відображає 01:59, хоча фактичне значення є нижчим за 01:00. Час є неправильним майже протягом кожної хвилини.

Один спільний початок

Рішенням є наявність спільного початку для всіх трьох анімацій, який досягається шляхом об’єднання зсувів у єдину затримку:

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

Тепер кожна анімація вимірюється від моменту now - 359874s. Межі їхніх кроків є кратностями тривалості власного кроку, рахованої від цього початкового моменту. Подивіться приклад: у 60-секундному вікні анімація секунд знаходиться на позиції 54, тож наступна межа настає через 6 секунд. У 3 600-секундному вікні анімація хвилин знаходиться на позиції 3 474, а наступна межа — на позиції 3 480, також через 6 секунд. Обидві цифри змінюються одночасно у момент now + 6s та залишаються синхронізованими кожні 60 секунд після цього.

Як одне число кодує три цифри

Зведена затримка працює як число змішаної основи, подібно до того, як формат HH:MM:SS об’єднує три поля в один рядок. Кодування формується з трьох компонентів:

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

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

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

Запуск прикладу за допомогою декодера підтверджує це. 359,874 модуло 60 дорівнює 54, тому секунди відображаються як 59 − 54 = 5. 359,874 модуло 3,600 дорівнює 3,474; це число, поділене на 60 з округленням вниз, дорівнює 57, тому хвилини відображаються як 59 − 57 = 2. 359,874 поділене на 3,600 з округленням вниз дорівнює 99, тому години відображаються як 99 − 99 = 0. На дисплеї відображається 00:02:05, що є правильним.

Анімації не можуть впливати одна на одну, оскільки кожне поле відповідає точному кратному значенню циклу наступного поля: частина з годинами завжди є кратною 3,600, а частина з хвилинами — кратною 60, тому жодна з них не заважає іншим.

Чисте зупинення о 00:00:00

Анімації іконок використовують безкінечне повторення, тому вони самі по собі ніколи не зупиняються. Коли закінчується цикл секунд, він повертається до 59, і таймер, який досягне нуля, знову почне відлік у зворотному напрямку.

Рішення полягає у ще одній анімації, яка відтворюється один раз саме у момент закінчення таймера та встановлює всі три значення у нуль. Цей блок також є CSS, попри свою початкову назву:

@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 призначає спільну затримку трьом анімаціям з кроками, а залишки часу як позитивну затримку — анімації з заморожуванням:

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

Тут працюють три елементи разом:

  • step-start (такий самий, як steps(1, jump-start)) переходить до кінцевої ключової кадри відразу після початку анімації, а не наприкінці її тривалості у 1 мс, тож усі три значення стають 0 у момент закінчення затримки.
  • animation-fill-mode: forwards зберігає кінцеву ключову кадру після завершення анімації, тож нулі залишаються незмінними протягом усього часу.
  • Порядок у animation-name має значення: коли кілька анімацій встановлюють одну й ту саму властивість, перемагає та, що вказана пізніше. Оскільки freeze знаходиться останнім, його значення нуля переважають над анімаціями, які продовжують виконуватися у фоновому режимі без видимого ефекту.
  • Якщо купон вже закінчив термін дії під час завантаження сторінки, залишок часу дорівнює 0, затримка призупинення становить 0s, і таймер відразу показує нулі.

    Яку ціну вам коштує підхід CSS

    Дизайн без використання JavaScript має реальні обмеження, і саме вони мають вплинути на ваше рішення:

    • Відсутній механізм завершення терміну. Не існує функції повернення, події чи зміни стану, коли таймер досягає нуля. Якщо закінчення терміну має спричинити виклик API, приховати картку чи відобразити щось інше, потрібен окремий одноразовий виклик setTimeout, що дещо повертає простоту цього підходу.
    • Обмежений стиль. Функція counter() генерує звичайний текст. Шрифт, колір та макет знаходяться повністю під вашим контролем, але все, що залежить від поточного значення в JavaScript — наприклад, зміна кольору на червоний при менш ніж 60 секундах або додавання класу при досягненні певного порогу — вимагає додаткових зусиль.
    • Точність до цілої секунди. Залишок часу при ініціалізації округлюється до цілої секунди, тому видимі позначки можуть відрізнятися на до однієї секунди від реальних меж секунд. Це має незначне значення для рекламних облікувачів часу, але це не точний годинник.
    • Підтримка браузерами. Ця техніка залежить від @property та анімованих зареєстрованих властивостей; переконайтеся у її підтримці у вашому наборі браузерів за допомогою актуальних даних про сумісність, перш ніж покладатися на неї.

    У відповідь, для таймера з лише читанням, який має лише відлік та зупинку, цей підхід є надзвичайно ефективним: один набір анімацій на кожну картку, жодного JavaScript під час кожного кроку відліку, жодних збоїв, а також немає потреби у очищенні після зняття елемента з виведення. Він ідеально підходить, коли сторінка одночасно відображає сотні карток-ваучерів у вигляді сітки.

    Компонент React для CSS-таймера

    Версія на CSS змінює форму компонента. У неї немає стану, жодного циклу анімацій та не потрібно нічого очищувати. Компонент один раз зчитує час, записує одну властивість стилю та відображає маркап. Для його роботи потрібні лише два хуки від React:

    import { useEffect, useRef } from 'react';
    

    Наведений нижче компонент обчислює залишок секунд, розділяє їх на одиниці, формує спільну затримку та записує чотири значення затримки у елемент. Атрибут type лише змінює візуальний стиль цифр.

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

    Як зазначено у коментарях, жодної очищення не потрібно: коли елемент залишає DOM, його анімації зникають разом із ним. Варто розглянути два вдосконалення. По-перше, оскільки useEffect виконується після малювання елемента браузером, перший кадр може на короткий час показати анімації у їхніх початкових положеннях перед тим, як буде застосовано затримку; useLayoutEffect або обчислення затримки під час відображення та її передача як вбудованого стилю усувають це миттєве зображення. По-друге, ефект залежить від endTime, тож якщо змінюється параметр, затримки перераховуються, і анімації правильно зміщуються у свої положення.

    Вибір між трьома підходами

    Просте правило прийняття рішень покриває більшість ситуацій:

    • Якщо після закінчення таймера має відбутися щось, наприклад оновлення стану, повторне завантаження даних чи виклик API, використовуйте requestAnimationFrame із перерахунком часу на основі годинника. Це надає вам повний контроль та забезпечує природне місце для функції-колбеку при закінченні таймера.
    • Якщо на сторінці є багато таймерів, і жоден з них не запускає логіку, скористайтеся технікою CSS та дозвольте браузеру виконувати цю роботу.
    • Якщо ви використовуєте звичайну ланцюгову структуру setTimeout, яка зменшує лічильник, замініть її. Цей підхід є неточним та продовжує використовувати ресурси основної смуги обробки щосекунди, тож поєднує недоліки обох альтернатив. Ці два підходи також можна поєднати: CSS для відображення та один спільний таймер для обробки логіки при закінченні таймера.

    Основні висновки

    • Затримки таймерів є мінімальними; обчислюйте залишковий час за допомогою Date.now() під час кожного оновлення замість підрахунку кроків, і таким чином відсутність точності зникне.
  • За допомогою rAF зберігайте останню відрендеровану секунду у змінній та скасовуйте оновлення кадру під час видалення елемента.
  • Версія на CSS ґрунтується на чотирьох функціях: цілих числах @property, функції counter() для форматування тексту, функції steps() для дискретних змін та негативному затримці для початку обробки посеред послідовності.
  • Розділіть одну затримку між усіма елементами, щоб цифри оновлювалися разом, та додайте анімацію freeze, щоб зберегти нулі.