Повелители времени: Как setTimeout и setInterval управляют будущим (и почему это сложнее, чем кажется)
Вы когда-нибудь задумывались, как JavaScript выполняет код "через 5 секунд", если он однопоточный? Или почему setTimeout(fn, 0) не выполняется мгновенно? Или как остановить бесконечный интервал, который уже запущен?
Добро пожаловать в мир асинхронного планирования. setTimeout и setInterval - это два кита, на которых держится отложенное выполнение в JavaScript. Они кажутся простыми: "запланируй эту функцию на потом". Но за этой простотой скрывается Event Loop, макрозадачи и множество подводных камней.
Сегодня мы разберём всё: от основ до продвинутых техник, от задержек до анимаций, от вложенных таймеров до альтернатив.
Часть 1. setTimeout: Отложенный вызов
setTimeout планирует выполнение функции один раз через указанное количество миллисекунд.
javascript
// Базовый синтаксис
const timeoutId = setTimeout(function, delay, arg1, arg2, ...);
// Простейший пример
setTimeout(() => {
console.log("Прошла 1 секунда");
}, 1000);
console.log("Это выполнится первым");
// Вывод:
// "Это выполнится первым"
// "Прошла 1 секунда" (через 1 секунду)
1.1 Параметры setTimeout
javascript
// Передача аргументов в функцию
function greet(name, age) {
console.log(`Привет, ${name}! Тебе ${age} лет.`);
}
setTimeout(greet, 1000, "Анна", 25);
// Через 1 секунду: "Привет, Анна! Тебе 25 лет."
// Аргументы работают со стрелочными функциями (но там они не нужны)
setTimeout((name, age) => {
console.log(`Привет, ${name}!`);
}, 1000, "Борис");
1.2 Отмена setTimeout (clearTimeout)
javascript
const timeoutId = setTimeout(() => {
console.log("Это не выполнится");
}, 5000);
// Отменяем до того, как прошло 5 секунд
clearTimeout(timeoutId);
// Проверить, был ли таймер отменён, напрямую нельзя,
// но можно обернуть в флаг
let cancelled = false;
const id = setTimeout(() => {
if (!cancelled) {
console.log("Выполнилось");
}
}, 1000);
cancelled = true;
clearTimeout(id); // Всё равно лучше очистить
Часть 2. setInterval: Периодический вызов
setInterval планирует выполнение функции многократно через указанный интервал.
javascript
// Базовый синтаксис
const intervalId = setInterval(function, delay, arg1, arg2, ...);
let count = 0;
const intervalId = setInterval(() => {
count++;
console.log(`Прошло ${count} секунд`);
if (count === 5) {
clearInterval(intervalId);
console.log("Стоп!");
}
}, 1000);
// Каждую секунду: "Прошло 1 секунд", "Прошло 2 секунд"...
2.1 Остановка setInterval (clearInterval)
javascript
const intervalId = setInterval(() => {
console.log("Тик");
}, 1000);
// Через 5 секунд остановим
setTimeout(() => {
clearInterval(intervalId);
console.log("Интервал остановлен");
}, 5000);
Часть 3. Как это работает: Event Loop и макрозадачи
Ключевое понимание: setTimeout и setInterval не гарантируют точное время выполнения. Они гарантируют минимальную задержку.
javascript
console.log("1. Начало");
setTimeout(() => {
console.log("3. setTimeout (через 0мс)");
}, 0);
console.log("2. Конец");
// Вывод:
// 1. Начало
// 2. Конец
// 3. setTimeout (через 0мс)
Почему? Даже с задержкой 0, колбэк не выполняется сразу. Он попадает в очередь макрозадач и ждёт, пока стек вызовов опустеет.
javascript
// Эксперимент с долгим синхронным кодом
setTimeout(() => {
console.log("Таймер сработал через 100мс");
}, 100);
const start = Date.now();
while (Date.now() - start < 500) {
// Блокируем поток на 500мс
}
console.log("Блокировка закончилась");
// Вывод:
// "Блокировка закончилась"
// "Таймер сработал через 100мс" (на самом деле через 500+мс)
// Таймер ждал, пока освободится поток!
Часть 4. Вложенные setTimeout: Анимация и рекурсия
Для периодических действий с динамическим интервалом или гарантией паузы между выполнениями лучше использовать рекурсивный setTimeout, а не setInterval.
javascript
// setInterval - интервал между НАЧАЛАМИ выполнения
let count = 0;
const intervalId = setInterval(() => {
console.log(`Начало ${count}`);
// Долгая операция (например, 200мс)
const start = Date.now();
while (Date.now() - start < 200) {}
console.log(`Конец ${count}`);
count++;
if (count === 3) clearInterval(intervalId);
}, 100);
// Если функция выполняется дольше интервала, вызовы накладываются
// Рекурсивный setTimeout - интервал между ЗАВЕРШЕНИЕМ и следующим НАЧАЛОМ
function repeat(count = 0) {
console.log(`Начало ${count}`);
const start = Date.now();
while (Date.now() - start < 200) {} // долгая операция
console.log(`Конец ${count}`);
if (count < 2) {
setTimeout(() => repeat(count + 1), 100);
}
}
repeat();
4.1 Анимация через setTimeout
javascript
function animate(element, property, from, to, duration) {
const startTime = performance.now();
const diff = to - from;
function step(now) {
const elapsed = now - startTime;
const progress = Math.min(1, elapsed / duration);
const value = from + diff * progress;
element.style[property] = value + 'px';
if (progress < 1) {
requestAnimationFrame(step);
}
}
requestAnimationFrame(step);
}
Часть 5. setTimeout с задержкой 0
setTimeout(..., 0) часто используется для откладывания выполнения до завершения текущего синхронного кода.
javascript
// Пример: "разблокировка" потока для обновления UI
function heavyOperation() {
console.log("Начало тяжёлой операции");
for (let i = 0; i < 1000000000; i++) {}
console.log("Конец тяжёлой операции");
}
// Плохо - UI заморожен
heavyOperation();
// Лучше - разбиваем на части
function heavyOperationAsync(chunkSize = 1000000) {
let i = 0;
function chunk() {
const end = Math.min(i + chunkSize, 1000000000);
for (; i < end; i++) {}
if (i < 1000000000) {
setTimeout(chunk, 0);
}
}
setTimeout(chunk, 0);
}
Часть 6. Проблемы с setInterval
6.1 Накопление вызовов (backpressure)
Если выполнение колбэка занимает больше времени, чем интервал, вызовы накапливаются.
javascript
setInterval(() => {
// Этот колбэк выполняется 150мс
const start = Date.now();
while (Date.now() - start < 150) {}
console.log("Тяжёлый колбэк");
}, 100);
// Пока выполняется первый, второй уже в очереди...
// Браузер пропускает лишние, но не всегда предсказуемо
6.2 Дрейф времени (time drift)
setInterval не корректирует время. Если операция заняла 105мс при интервале 100мс, следующий вызов начнётся через 5мс после предыдущего.
javascript
let expected = Date.now() + 100;
setInterval(() => {
const drift = Date.now() - expected;
console.log(`Отклонение: ${drift}мс`);
expected += 100;
}, 100);
// Отклонение будет накапливаться
6.3 Решение: рекурсивный setTimeout
javascript
function accurateInterval(fn, delay) {
let expected = Date.now() + delay;
function step() {
const drift = Date.now() - expected;
fn(drift);
expected += delay;
setTimeout(step, Math.max(0, delay - drift));
}
setTimeout(step, delay);
}
accurateInterval((drift) => {
console.log(`Выполнение с отклонением ${drift}мс`);
}, 1000);
Часть 7. setTimeout и setInterval в асинхронных контекстах
7.1 Внутри промисов и async/await
javascript
// Ожидание через setTimeout в async функции
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function demo() {
console.log("Начало");
await delay(2000);
console.log("Прошло 2 секунды");
await delay(1000);
console.log("Ещё 1 секунда");
}
demo();
7.2 Отмена промиса с таймаутом
javascript
function fetchWithTimeout(url, timeoutMs = 5000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
return fetch(url, { signal: controller.signal })
.finally(() => clearTimeout(timeoutId));
}
fetchWithTimeout('/api/data', 3000)
.then(response => response.json())
.catch(err => {
if (err.name === 'AbortError') {
console.log("Запрос превысил таймаут");
}
});
Часть 8. Альтернативы
8.1 requestAnimationFrame - для анимаций
javascript
let start = null;
let element = document.getElementById('box');
function step(timestamp) {
if (!start) start = timestamp;
const progress = timestamp - start;
const x = Math.min(progress / 2000, 1) * 300;
element.style.transform = `translateX(${x}px)`;
if (progress < 2000) {
requestAnimationFrame(step);
}
}
requestAnimationFrame(step);
8.2 Web Workers - для тяжёлых операций
javascript
// main.js
const worker = new Worker('worker.js');
worker.postMessage({ type: 'start', data: hugeArray });
worker.onmessage = (e) => {
console.log('Результат:', e.data);
};
// worker.js
self.onmessage = (e) => {
if (e.data.type === 'start') {
const result = heavyCalculation(e.data.data);
self.postMessage(result);
}
};
Часть 9. Память и утечки
9.1 Всегда очищайте таймеры
javascript
class Timer {
constructor() {
this.timeoutId = null;
}
start() {
this.timeoutId = setTimeout(() => {
console.log("Выполнилось");
}, 1000);
}
stop() {
if (this.timeoutId) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
}
}
destroy() {
this.stop();
}
}
// В React компоненте
useEffect(() => {
const timer = new Timer();
timer.start();
return () => timer.destroy();
}, []);
9.2 Замыкания и утечки памяти
javascript
function createLeakyTimer() {
const hugeData = new Array(1000000).fill("данные");
setInterval(() => {
console.log("Тик");
// hugeData не используется, но живёт в замыкании!
}, 1000);
}
// hugeData никогда не освободится
function createSafeTimer() {
const hugeData = new Array(1000000).fill("данные");
const result = hugeData.length; // нужна только длина
setInterval(() => {
console.log(`Размер данных: ${result}`);
// hugeData может быть собрана сборщиком мусора
}, 1000);
}
Часть 10. Реальные кейсы
10.1 Дебаунс (debounce)
javascript
function debounce(fn, delay) {
let timeoutId = null;
return function(...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
const debouncedSearch = debounce((query) => {
console.log(`Поиск: ${query}`);
}, 300);
// При быстром вводе вызовется только последний
debouncedSearch("a");
debouncedSearch("ap");
debouncedSearch("app");
debouncedSearch("appl");
debouncedSearch("apple"); // только этот выполнится
10.2 Троттлинг (throttle)
javascript
function throttle(fn, limit) {
let inThrottle = false;
let lastArgs = null;
let lastThis = null;
function execute() {
if (lastArgs) {
fn.apply(lastThis, lastArgs);
lastArgs = null;
lastThis = null;
setTimeout(execute, limit);
} else {
inThrottle = false;
}
}
return function(...args) {
if (!inThrottle) {
fn.apply(this, args);
inThrottle = true;
setTimeout(execute, limit);
} else {
lastArgs = args;
lastThis = this;
}
};
}
const throttledScroll = throttle(() => {
console.log("Скролл!", Date.now());
}, 1000);
window.addEventListener('scroll', throttledScroll);
10.3 Имитация серверного времени
javascript
class VirtualClock {
constructor(serverTime) {
this.offset = serverTime - Date.now();
this.listeners = [];
}
now() {
return Date.now() + this.offset;
}
setTimeout(fn, delay) {
const targetTime = this.now() + delay;
const realDelay = targetTime - Date.now();
const timeoutId = setTimeout(fn, Math.max(0, realDelay));
return timeoutId;
}
setInterval(fn, interval) {
let timeoutId;
const wrapper = () => {
fn();
timeoutId = this.setTimeout(wrapper, interval);
};
timeoutId = this.setTimeout(wrapper, interval);
return {
clear: () => clearTimeout(timeoutId)
};
}
}
Часть 11. Подводные камни
Камень #1: Максимальная задержка
javascript
// Задержка более 24.8 дней (2^31-1 мс) округляется
setTimeout(() => console.log("Никогда"), 2 ** 31); // ~24.8 дней
// Фактическая задержка: 2**31 мс (максимум)
Камень #2: this в setTimeout
javascript
const obj = {
name: "Анна",
greet() {
setTimeout(function() {
console.log(this.name); // undefined (this = window)
}, 100);
setTimeout(() => {
console.log(this.name); // "Анна" (стрелка сохраняет)
}, 100);
setTimeout(this.greet.bind(this), 100);
}
};
Камень #3: Аккумуляция setTimeout в циклах
javascript
// Плохо
for (var i = 0; i < 5; i++) {
setTimeout(() => console.log(i), 100);
}
// Вывод: 5,5,5,5,5 (все 5)
// Хорошо (let)
for (let i = 0; i < 5; i++) {
setTimeout(() => console.log(i), 100);
}
// Вывод: 0,1,2,3,4
// Хорошо (замыкание)
for (var i = 0; i < 5; i++) {
setTimeout(((j) => () => console.log(j))(i), 100);
}
Итог: Манифест таймеров
- setTimeout(fn, delay) - однократная задержка.
- setInterval(fn, delay) - многократное выполнение.
- clearTimeout(id) / clearInterval(id) - всегда очищайте таймеры.
- Минимальная задержка - 4мс (после 5 вложенных таймеров в браузере).
- setTimeout(fn, 0) - не мгновенно, а после текущего синхронного кода.
- Для точного интервала - используйте рекурсивный setTimeout.
- Для анимации - requestAnimationFrame, не setInterval.
- Для тяжёлых операций - Web Workers.
- Всегда очищайте таймеры при размонтировании компонентов.
- Никогда не передавайте строку (setTimeout("code", 100)) - это eval.
Финальный тест (что выведет?):
javascript
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
let count = 0;
const interval = setInterval(() => {
count++;
console.log(count);
if (count === 3) clearInterval(interval);
}, 100);
setTimeout(() => console.log("5"), 50);
Ответ: 1, 4, 3, 2, 1, 2, 3, 5 (микрозадачи - Promise - между макрозадачами).
setTimeout и setInterval - это фундамент асинхронного JavaScript. Они просты на поверхности, но скрывают глубины Event Loop, очередей макрозадач и проблем синхронизации. Освойте их, и вы сможете управлять временем в ваших приложениях. А когда вам понадобится больше - requestAnimationFrame, Web Workers и промисы ждут вас. Планируйте мудро.