ПроКодинг - Откроем для вас мир IT!

Вы когда-нибудь сталкивались с ситуацией, когда анимация на сайте дёргается, а счётчик времени «прыгает» через секунды? Проблема почти всегда кроется в неправильном выборе инструментов для управления временем. В JavaScript есть два основных способа задерживать выполнение кода: setTimeout и requestAnimationFrame. Но они работают по совершенно разным принципам.

setTimeout - это функция, которая откладывает выполнение кода на определённое количество миллисекунд. Она идеально подходит для задач, где точность не критична, например, показ всплывающего окна через 3 секунды. Однако если вы используете её для плавной анимации или частых обновлений интерфейса, получите рваное движение и просадки производительности.

requestAnimationFrame, напротив, синхронизирует вызов функции с циклом перерисовки браузера (обычно 60 кадров в секунду). Это гарантирует, что ваши изменения DOM будут видны пользователю именно тогда, когда экран готов к обновлению. Разница между этими двумя инструментами определяет, будет ли ваш интерфейс отзывчивым или «тормозить».

Ключевые различия: время vs кадры

Главное отличие заключается в том, что измеряет каждая функция. setTimeout(fn, delay) работает в единицах времени (миллисекундах), тогда как requestAnimationFrame(cb) привязан к вертикальной синхронизации монитора. Если у пользователя монитор с частотой 144 Гц, браузер может вызывать колбэк до 144 раз в секунду, чтобы максимально плавно отрисовать сцену.

Сравнение механизмов таймеров в JavaScript
Характеристика setTimeout requestAnimationFrame
Единица измерения Миллисекунды (ms) Кадр (frame)
Частота вызова Зависит от задержки и загрузки CPU Синхронизирована с HZ монитора (60/120/144)
Поведение в фоновой вкладке Работает (с ограничением до 1 раза в сек) Останавливается полностью
Идеальное применение Логика, уведомления, повторные запросы Анимации, обновления Canvas, следящие элементы
Возможность отмены clearTimeout(id) cancelAnimationFrame(id)

Почему setTimeout вызывает лаги в анимациях

Представьте, что вы пытаетесь двигать элемент по экрану каждые 16 миллисекунд, используя setTimeout. Теоретически это должно дать 60 FPS. Но на практике всё сложнее. Браузер должен выполнить ваш код, рассчитать новые стили, вычислить макет (layout) и отрисовать кадр (paint). Если этот процесс занимает больше 16 мс, следующий таймер запустится не вовремя, и пользователь увидит пропущенный кадр.

Есть еще одна тонкость: таймеры в JavaScript имеют минимальную разрешающую способность. Даже если вы установите задержку в 1 мс, браузер часто округляет её до ближайшего интервала тикера системы (обычно 4 мс или более). Из-за этого накапливается ошибка, и к концу длинной последовательности событий ваша анимация сильно отстаёт от реального времени.

Как правильно использовать requestAnimationFrame

requestAnimationFrame решает проблему рассинхронизации, потому что браузер сам решает, когда вызывать вашу функцию. Он гарантирует, что колбэк выполнится перед следующей перерисовкой экрана. Внутри колбэка вы получаете параметр timestamp - время в миллисекундах с момента начала работы страницы. Это позволяет точно рассчитывать длительность кадра независимо от того, насколько быстро работал предыдущий цикл.

  1. Начните цикл, вызвав requestAnimationFrame(loop).
  2. Внутри функции loop(timestamp) рассчитайте дельту времени: const delta = timestamp - lastTime;.
  3. Обновите состояние объекта (позицию, скорость) на основе delta, а не фиксированного шага.
  4. Вызовите requestAnimationFrame(loop) снова в конце функции, чтобы продолжить цикл.

Такой подход делает анимацию независимой от частоты кадров. На мониторе 60 Гц дельта будет около 16.6 мс, а на 144 Гц - около 6.9 мс. Объект будет двигаться с одинаковой скоростью визуально, просто на мощном экране он будет выглядеть плавнее.

Концептуальное изображение двух путей: механическое время и синхронизация кадров

Когда все же нужен setTimeout

Не стоит гнаться за requestAnimationFrame везде. Есть задачи, где важна именно временная задержка, а не визуальная плавность:

  • Дебаунс (debounce): ожидание паузы после ввода текста перед отправкой запроса.
  • Троттлинг (throttling): ограничение частоты обработчиков скролла или ресайза.
  • Повторные попытки (retry): перезапрос API после ошибки с экспоненциальной задержкой.
  • Показ уведомлений: скрытие тоста через 5 секунд, даже если пользователь смотрит в другую вкладку.

В этих случаях setTimeout проще и предсказуемее. Вам не нужно следить за кадрами, вам нужно просто подождать определенное время.

Ошибки, которые приводят к утечкам памяти

Самая частая причина «залипания» таймеров - забытая отмена. Если вы запускаете цикл requestAnimationFrame при монтировании компонента, но не останавливаете его при размонтировании, функция будет продолжать вызываться бесконечно, обновляя DOM, который уже удален из дерева. Это приводит к утечкам памяти и лишней нагрузке на CPU.

Для setTimeout ситуация аналогична. Если вы создали таймер внутри цикла или обработчика события, убедитесь, что храните ID возврата (const id = setTimeout(...)) и вызываете clearTimeout(id) в нужном месте. Для requestAnimationFrame используйте cancelAnimationFrame(id).

Простое правило: любой таймер, созданный внутри жизненного цикла компонента или модуля, должен иметь соответствующий метод очистки. Иначе вы рискуете получить «призрачные» процессы, которые съедают ресурсы вашего приложения.

Свечение курсора в редакторе кода, символизирующее бесконечный цикл

Практический пример: плавный счетчик

Давайте посмотрим, как реализовать плавный счетчик от 0 до 100, который выглядит естественно, а не скачет цифрами.

let startTime;
function updateCounter(timestamp) {
    if (!startTime) startTime = timestamp;
    const elapsed = timestamp - startTime;
    const progress = Math.min(elapsed / 2000, 1); // Длительность 2 секунды
    
    document.getElementById('counter').textContent = Math.floor(progress * 100);
    
    if (progress < 1) {
        requestAnimationFrame(updateCounter);
    }
}

// Запуск
requestAnimationFrame(updateCounter);

Здесь мы используем timestamp для расчета прогресса. Если бы мы использовали setTimeout с шагом 10 мс, на слабых устройствах счетчик мог бы «спотыкаться», потому что выполнение кода могло занять 15 мс вместо ожидаемых 10. С requestAnimationFrame мы всегда знаем точное прошедшее время, поэтому прогресс линейный и плавный.

Частые вопросы о таймерах в JS

Можно ли заменить setTimeout на requestAnimationFrame для всех задач?

Нет. requestAnimationFrame останавливается, когда вкладка уходит на фон. Если вам нужно, чтобы таймер продолжал работать незаметно для пользователя (например, обновление данных каждые 30 секунд), лучше использовать setInterval или setTimeout, так как rAF будет ждать возвращения вкладки в фокус.

Что делать, если анимация все равно тормозит?

Проверьте, не вызываете ли вы тяжелые операции внутри колбэка rAF. Избегайте чтения свойств layout (как offsetTop) сразу после записи стилей. Используйте transform и opacity для анимаций, так как они обрабатываются GPU и не требуют пересчета макета страницы.

Какая разница между setInterval и setTimeout?

setInterval пытается вызывать функцию строго через заданный интервал, но если выполнение кода заняло больше времени, следующий вызов произойдет сразу после завершения текущего, а не через полный интервал. Это может привести к «накоплению» ошибок во времени. setTimeout требует ручного перезапуска, что дает больше контроля над логикой.

Как отменить таймер, созданный в другом модуле?

Вы должны сохранить ID, возвращаемый функцией создания таймера (clearTimeout или cancelAnimationFrame принимают только этот ID). Передайте этот ID туда, где нужно остановить таймер, или используйте класс/объект для инкапсуляции логики создания и отмены.

Работает ли requestAnimationFrame в старых браузерах?

Да, поддержка есть практически во всех современных браузерах, включая IE10+. Для очень старых сред можно использовать polyfill, но сегодня это редкость. Главное преимущество rAF - нативная интеграция с движком рендеринга, которую сложно воспроизвести полифилами.