Skip to main content

Вкладені таймери

Багато «вкладених» setTimeout, тобто коли всередині колбека ставиться наступний таймер, не переповнюють стек викликів, але можуть забити чергу задач, спричинити дрейф часу, throttling і лаги інтерфейсу. Стек очищується між викликами, а от точність тайминга втрачається.

Теорія

TL;DR

  • Stack overflow не буде: кожен колбек завершується, перш ніж виконається наступний.
  • Натомість росте черга макрозадач, і інтерфейс може підлагувати.
  • Час «тікання» дрейфує: затримки накопичуються від кроку до кроку.
  • Навіть із затримкою 0 діє мінімальний інтервал, близько 4 мс, а в неактивній вкладці до 1000 мс і більше.
  • Між таймерами повністю виконуються всі мікрозадачі, тож вони подовжують кожен крок.
  • Рекурсивний setTimeout кращий за setInterval, коли колбек іноді триває довше за інтервал.

Швидкий приклад

javascript
function tick() { // робота... setTimeout(tick, 0); // наступний тік у наступному оберті Event Loop } setTimeout(tick, 0);

Стек не росте, бо tick завершується одразу після планування наступного виклику. Але «тіки» йтимуть з обмеженням мінімальної затримки і з дрейфом.

Що саме відбувається

  1. Немає stack overflow. Кожен setTimeout ставить наступну задачу в чергу макрозадач і завершує поточну, тож стек очищується між викликами.
  2. Черга макрозадач росте. Якщо всередині кожного колбека ставити кілька нових таймерів або робити важку роботу, задачі почнуть накопичуватися, а інтерфейс підлагувати.
  3. Дрейф, тобто накопичення затримки. Момент спрацювання зсувається: колбек виконається не раніше за вказану затримку і ще пізніше, якщо стек був зайнятий. У довгих ланцюжках зсув накопичується.
  4. Мінімальна затримка і throttling. Навіть за 0 мс браузери застосовують мінімальний інтервал, зазвичай близько 4 мс, а в неактивній вкладці до 1000 мс і більше. Вкладені таймери з наднизькими затримками все одно «затискаються».
  5. Мікрозадачі між таймерами. Після кожного колбека виконуються всі мікрозадачі (Promise.then, queueMicrotask). Якщо всередині таймера створювати багато мікрозадач, вони додатково подовжать крок ланцюжка.
  6. Порядок виконання. Таймери беруться з черги за часом готовності; якщо кілька готові одночасно, порядок може відрізнятися між рушіями, тож покладатися на конкретний порядок не варто.
  7. Пам'ять. Якщо колбеки замикають великі об'єкти, а ланцюжок довгий або нескінченний, споживання пам'яті може зростати: об'єкти довше лишаються досяжними.

Як робити краще

  • Компенсуйте дрейф у періодичних задачах:

    javascript
    const start = performance.now(); const step = 1000; // 1 секунда let n = 0; function loop() { n++; // ...корисна робота... const nextAt = start + n * step; setTimeout(loop, Math.max(0, nextAt - performance.now())); } setTimeout(loop, step);
  • Для анімацій використовуйте requestAnimationFrame, він синхронізований з перемальовуванням і менше «дрейфує».

  • Для «кожні N мс» без накладань обирайте рекурсивний setTimeout, як вище, а не setInterval: так ви уникнете накопичення викликів, якщо колбек іноді триває довше за інтервал.

  • Важку роботу виносьте у Web Worker, щоб не блокувати інтерфейс.

Рекурсивний setTimeout проти setInterval

ВластивістьРекурсивний setTimeoutsetInterval
Наступний запуск плануєтьсяПісля завершення поточного колбекаНезалежно від того, чи завершився колбек
Накопичення викликівНеможливеМожливе, якщо колбек повільніший за інтервал
Пауза між запускамиГарантованаНе гарантована
Змінна затримкаЛегко, значення можна перерахувати щоразуНі, інтервал фіксований
ЗупинкаclearTimeout(id) для останнього таймераclearInterval(id)

Висновок

Вкладені setTimeout це безпечний спосіб «розірвати» синхронну рекурсію, але:

  • точного тайминга не буде;
  • за великого обсягу задач ви отримаєте лаги і дрейф;
  • браузер застосує мінімальні затримки і throttling.

Типові помилки

  • Чекати на переповнення стека. Це не рекурсія у звичайному сенсі: стек між тіками порожній, тому помилки Maximum call stack size exceeded не буде, і проблема виявиться пізніше, у вигляді лагів.
  • Ставити кілька таймерів у кожному колбеку. Ланцюжок розгалужується, кількість задач у черзі зростає лавиноподібно.
  • Розраховувати на затримку 0. Вкладені таймери затискаються до приблизно 4 мс, тож цикл на 1000 кроків триватиме щонайменше кілька секунд.
  • Рахувати час як крок * кількість тіків. Через дрейф реальний час буде більшим; беріть його з performance.now().
  • Не зупиняти ланцюжок. Без прапорця або збереженого ID таймер працює вічно, навіть коли екран, що його запустив, уже закрито.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.