Джерело
Дивовижний світ веброзробки | Днями на редіті побачив допис, в якому автор питав, чому його лічильни...
734 Охват/переглядів
2026-09-25 07:24
Повідомлення №447
Днями на редіті побачив допис, в якому автор питав, чому його лічильник часу на сторінці за пів години активності відстає аж на 30 секунд.
Задача, здавалось би, одна з найпростіших — запускай таймер, рахуй секунди, виводь і буде тобі щастя. Але річ в тому, що багато хто досі вважає таймери в JS точними.
На ділі що setTimeout, що setInterval не виконують ваш код через заданий проміжок часу. Вони ставлять його в чергу. Це суттєва різниця. Тобто це не "виконай через точно 1000ms", а радше "я тут вам задачку залишив, запусти за нагоди, але не раніше ніж 1000ms". Це якщо прям дуже приблизно. Я не буду вдаватися в деталі, там вам і улюблений event loop, і черги, і так звана однопоточність.
Давайте ліпше розберемо, як уникнути такого дрифта або хоча б зменшити відхилення до меж статистичної похибки.
Перше, що прийде нам в голову для такого лічильника, — запустити setInterval:
setInterval(() => doMagic(), 1000);
Це найнаївніший спосіб. setInterval намагається запускати callback із заданою періодичністю, але не гарантує момент його фактичного виконання. Якщо main thread зайнятий, виклик відбудеться пізніше. Якщо ж ми просто робимо seconds++, то починаємо вимірювати не час, а кількість викликів — і рано чи пізно ці дві величини можуть розійтися.
Кращим підходом буде запускати лічильник, коли попередня ітерація точно відпрацювала. Але просто переробляти на рекурсивний setTimeout не треба, бо він так само накопичуватиме затримку:
const tick = () => {
…
setTimeout(() => tick(), 1000);
}
tick();
Краще розраховувати наступний таймаут як залишок до бажаного моменту в майбутньому:
let next = performance.now() + 1000;
const tick = () => {
next += 1000;
const delay = Math.max(0, next - performance.now());
setTimeout(tick, delay);
}
setTimeout(tick, 1000);
Оцей delay буде трошки менше за 1000ms, бо враховує похибку попередньої ітерації.
Це працює в ідеальних умовах, коли основний потік нічим суттєво не зайнятий. Але setTimeout не гарантує, що callback виконається саме в заданий момент. Якщо main thread зайнятий JavaScript, layout, rendering чи іншою роботою, callback чекатиме. Якщо потік буде заблокований більше секунди, ми можемо взагалі проскочити момент оновлення інтерфейсу.
А оскільки нам зрештою треба не просто щось порахувати, а намалювати нове значення, можна передати останній крок requestAnimationFrame. Він не робить таймер точнішим і не обходить заблокований main thread — він просить браузер виконати оновлення перед наступним repaint.
let next = performance.now() + 1000;
const tick = () => {
console.log('tick');
next += 1000;
const delay = Math.max(0, next - performance.now());
setTimeout(() => requestAnimationFrame(tick), delay);
};
setTimeout(() => requestAnimationFrame(tick), 1000);
Тут requestAnimationFrame розвʼязує не проблему точності, а іншу: якщо результат треба показати користувачу, ми можемо синхронізувати DOM-update з наступним кадром браузера. Аби не перескакувати секунди, варто ще знати, скільки часу пройшло з останнього виконання:
let previous = performance.now();
let seconds = 0;
const tick = (now) => {
// Ось тут визначаємо, скільки реально минуло секунд
// і на скільки нам треба "стрибнути" вперед
const elapsed = now - previous;
previous = now;
seconds += elapsed / 1000;
…
setTimeout(() => requestAnimationFrame(tick), 1000);
};
requestAnimationFrame(tick);
Такий підхід дозволяє максимально точно рахувати "тіки". Але є одне але, якого ну ніяк не уникнути — якщо ваш UI таки важкий, то іноді лічильник буде прострибувати значення не через рівні проміжки часу, наприклад 0.5с > 1с > 3с. Але при цьому показуватиме точний час. Це вимушений трейдоф через завантажений основний потік. Але це вже проблема не лічильника, а ваша — бо чого це у вас така важка сторінка, га?
Що почитати:
MDN — setTimeout()
MDN — requestAnimationFrame()
Що почитати душнілам:
HTML Living Standard — Timers
---
Збір на авто для 21 ОМБр триває, зібрано 22 200грн з необхідних 271 500грн.
🔗 send.monobank.ua/jar/AeXQ6YRf2X
💳 5375411202918178
Задача, здавалось би, одна з найпростіших — запускай таймер, рахуй секунди, виводь і буде тобі щастя. Але річ в тому, що багато хто досі вважає таймери в JS точними.
На ділі що setTimeout, що setInterval не виконують ваш код через заданий проміжок часу. Вони ставлять його в чергу. Це суттєва різниця. Тобто це не "виконай через точно 1000ms", а радше "я тут вам задачку залишив, запусти за нагоди, але не раніше ніж 1000ms". Це якщо прям дуже приблизно. Я не буду вдаватися в деталі, там вам і улюблений event loop, і черги, і так звана однопоточність.
Давайте ліпше розберемо, як уникнути такого дрифта або хоча б зменшити відхилення до меж статистичної похибки.
Перше, що прийде нам в голову для такого лічильника, — запустити setInterval:
setInterval(() => doMagic(), 1000);
Це найнаївніший спосіб. setInterval намагається запускати callback із заданою періодичністю, але не гарантує момент його фактичного виконання. Якщо main thread зайнятий, виклик відбудеться пізніше. Якщо ж ми просто робимо seconds++, то починаємо вимірювати не час, а кількість викликів — і рано чи пізно ці дві величини можуть розійтися.
Кращим підходом буде запускати лічильник, коли попередня ітерація точно відпрацювала. Але просто переробляти на рекурсивний setTimeout не треба, бо він так само накопичуватиме затримку:
const tick = () => {
…
setTimeout(() => tick(), 1000);
}
tick();
Краще розраховувати наступний таймаут як залишок до бажаного моменту в майбутньому:
let next = performance.now() + 1000;
const tick = () => {
next += 1000;
const delay = Math.max(0, next - performance.now());
setTimeout(tick, delay);
}
setTimeout(tick, 1000);
Оцей delay буде трошки менше за 1000ms, бо враховує похибку попередньої ітерації.
Це працює в ідеальних умовах, коли основний потік нічим суттєво не зайнятий. Але setTimeout не гарантує, що callback виконається саме в заданий момент. Якщо main thread зайнятий JavaScript, layout, rendering чи іншою роботою, callback чекатиме. Якщо потік буде заблокований більше секунди, ми можемо взагалі проскочити момент оновлення інтерфейсу.
А оскільки нам зрештою треба не просто щось порахувати, а намалювати нове значення, можна передати останній крок requestAnimationFrame. Він не робить таймер точнішим і не обходить заблокований main thread — він просить браузер виконати оновлення перед наступним repaint.
let next = performance.now() + 1000;
const tick = () => {
console.log('tick');
next += 1000;
const delay = Math.max(0, next - performance.now());
setTimeout(() => requestAnimationFrame(tick), delay);
};
setTimeout(() => requestAnimationFrame(tick), 1000);
Тут requestAnimationFrame розвʼязує не проблему точності, а іншу: якщо результат треба показати користувачу, ми можемо синхронізувати DOM-update з наступним кадром браузера. Аби не перескакувати секунди, варто ще знати, скільки часу пройшло з останнього виконання:
let previous = performance.now();
let seconds = 0;
const tick = (now) => {
// Ось тут визначаємо, скільки реально минуло секунд
// і на скільки нам треба "стрибнути" вперед
const elapsed = now - previous;
previous = now;
seconds += elapsed / 1000;
…
setTimeout(() => requestAnimationFrame(tick), 1000);
};
requestAnimationFrame(tick);
Такий підхід дозволяє максимально точно рахувати "тіки". Але є одне але, якого ну ніяк не уникнути — якщо ваш UI таки важкий, то іноді лічильник буде прострибувати значення не через рівні проміжки часу, наприклад 0.5с > 1с > 3с. Але при цьому показуватиме точний час. Це вимушений трейдоф через завантажений основний потік. Але це вже проблема не лічильника, а ваша — бо чого це у вас така важка сторінка, га?
Що почитати:
MDN — setTimeout()
MDN — requestAnimationFrame()
Що почитати душнілам:
HTML Living Standard — Timers
---
Збір на авто для 21 ОМБр триває, зібрано 22 200грн з необхідних 271 500грн.
🔗 send.monobank.ua/jar/AeXQ6YRf2X
💳 5375411202918178