Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Що таке throttling?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Throttling** обмежує частоту виклику функції: не більше одного разу за вказаний проміжок часу, решта відхиляється. Використовується для обробників scroll (16ms = 60fps), live-пошуку (200-300ms) та захисту від API rate limit. Throttle викликає рівномірно під час активності. Debounce чекає тишу і викликає один раз. **Ключове:** зберігає рівномірний часовий ряд під час бурстів.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Throttling** - це обмеження частоти виклику функції: не більше одного виклику за визначений проміжок часу, решта відхиляється. ## Теорія ### TL;DR - Аналогія: кав'ярня з одним бариста. Одне замовлення за хвилину проходить, решта відхиляються. - Throttle викликає функцію рівномірно під час бурсту. [Debounce](/questions/debounce) чекає тишу і викликає один раз. - Правило вибору: потрібні регулярні оновлення під час активності (scroll, live-пошук)? Throttle. Потрібен лише фінальний стан після зупинки? Debounce. - 16ms throttle = 60fps. Це число для обробників scroll і resize. - Стан зберігається через [замикання (closure)](/questions/closures), зовнішні бібліотеки не потрібні. ### Короткий приклад ```javascript function throttle(fn, delay) { let lastCall = 0; return function(...args) { const now = Date.now(); if (now - lastCall >= delay) { lastCall = now; return fn(...args); } // Виклики всередині вікна delay ігноруються }; } const log = throttle(() => console.log('Викликано'), 1000); log(); // Викликано (0ms) setTimeout(log, 500); // Ігнорується (500ms < 1000ms) setTimeout(log, 1000); // Викликано (1000ms >= 1000ms) ``` Функція закриває `lastCall` у собі через замикання. Кожен виклик перевіряє час і або виконується, або повертається одразу. Жодних таймерів, жодних черг. Тільки перевірка часової мітки. ### Throttle vs debounce Throttle задає рівномірний ритм, як метроном. Під час бурсту функція виконується через рівні проміжки. Debounce пригнічує всі виклики до зупинки активності і потім виконується один раз. Якщо користувач друкує п'ять секунд, throttle із затримкою 200ms дасть близько 25 preview-викликів. Debounce із 500ms дасть один виклик через пів секунди після останнього символу. Обидва обмежують навантаження, але throttle зберігає рівномірний часовий ряд під час активності. ### Коли використовувати throttle - Обробники scroll і resize можуть спрацьовувати сотні разів на секунду. Throttle до 16ms для рендерингу в 60fps без layout thrashing. - Пошук під час введення: throttle до 200-300ms для live-превʼю без зайвих запитів. - Захист на стороні клієнта від rate limit API. GitHub дозволяє 5000 запитів/год на токен, і один бурст може вичерпати цей ліміт. - Датчики або ігрові цикли з високою частотою подій. - Якщо потрібен лише фінальний стан (валідація після втрати фокусу, автозавершення після паузи), краще debounce. ### Як це працює всередині Базова реалізація на основі часової мітки: зчитати `Date.now()`, порівняти з `lastCall`, пропустити якщо різниця менша за `delay`. Жоден таймер не запускається. Черги немає. Пробіл між викликами залежить тільки від того, коли наступний виклик прийде після завершення вікна. Варіант на основі `setTimeout` працює інакше. Перший виклик запускає таймер і ігнорує решту до його спрацювання. Це trailing-edge реалізація: виконання відбувається після затримки, а не до неї. Потрібна тоді, коли важливо щоб пройшли найсвіжіші аргументи, а не перші у бурсті. Браузери обробляють `setTimeout` у черзі макрозадач, тому реальна затримка може бути трохи більша під навантаженням. Node.js використовує libuv-таймери, які мають ту ж особливість. Для точного вимірювання часу краще `performance.now()`: він монотонний, доступний і в браузері, і в Node починаючи з версії 8.5. ### Leading та trailing edges Базовий timestamp-throttle спрацьовує на leading edge: перший виклик у бурсті проходить одразу. Trailing edge спрацьовує після затримки з найсвіжішими аргументами. Lodash `_.throttle` підтримує обидва варіанти через опції. ```javascript function advancedThrottle(fn, delay, { leading = true, trailing = true } = {}) { let lastCall = 0, timeoutId, pendingArgs; return function(...args) { const now = Date.now(); const timeLeft = delay - (now - lastCall); if (leading && timeLeft <= 0) { lastCall = now; fn(...args); // Leading: одразу } else if (trailing) { pendingArgs = args; clearTimeout(timeoutId); timeoutId = setTimeout(() => { if (pendingArgs) { lastCall = Date.now(); fn(...pendingArgs); // Trailing: з найсвіжішими аргументами pendingArgs = null; } }, timeLeft > 0 ? timeLeft : delay); } }; } const apiCall = advancedThrottle(console.log, 1000); apiCall('перший'); // Leading: 0ms setTimeout(() => apiCall('другий'), 500); // Trailing: 1000ms ``` Якщо увімкнені обидва режими, один виклик спрацює двічі: одразу і після затримки. Це часто дивує. ### Типові помилки **Помилка 1: Рекурсивний `setTimeout` без перевірки часу** ```javascript // Неправильно: новий таймер при кожному виклику без блокування function badThrottle(fn, delay) { let timeoutId; return () => { timeoutId = setTimeout(fn, delay); // Перезаписує посилання, але функція все одно спрацьовує }; } ``` Кожен виклик додає новий таймер. Під бурстом вони накопичуються і виконуються разом із дрейфом. Виправлення: відстежувати `lastCall` і повертатись одразу, якщо час не минув. **Помилка 2: Throttle для async-функцій без врахування in-flight запитів** ```javascript // Неправильно: не враховує чи завершився попередній запит const throttledFetch = throttle(async (url) => { const data = await fetch(url); // Виконується 2+ секунди console.log(data); }, 1000); throttledFetch('/slow'); // Починає виконання // Через 1001ms throttle пропускає наступний, але перший ще не завершився throttledFetch('/slow'); ``` Throttle контролює частоту викликів, але не паралелізм. Якщо функція виконується довше ніж `delay`, запити перекриватимуться. Для комбінованого контролю частоти та паралелізму використовуй `p-throttle`. **Помилка 3: Відсутність cleanup при розмонтуванні React-компонента** ```javascript // Неправильно: таймер спрацює після видалення компонента useEffect(() => { window.addEventListener('scroll', throttledHandler); // Немає cleanup = виклики після розмонтування }, []); // Правильно: useEffect(() => { window.addEventListener('scroll', throttledHandler); return () => window.removeEventListener('scroll', throttledHandler); }, []); ``` Виклики після розмонтування призводять до попередження "setState on unmounted component". Зберігай посилання на функцію в `useRef` і викликай `clearTimeout` у cleanup. **Помилка 4: Один загальний екземпляр throttle для кількох незалежних споживачів** ```javascript // Неправильно: усі ділять спільний lastCall const sharedThrottle = throttle(fn, 1000); items.forEach(item => sharedThrottle(item)); // Перший елемент блокує всіх ``` Кожен незалежний споживач потребує власного екземпляра throttle зі своїм замиканням. Створюй per-компонент або per-фіча, а не глобально. ### Де зустрічається - **VS Code:** Lodash throttle на live-лінтері. Парсинг запускається не частіше одного разу за delay під час введення. - **GitHub API:** 5000 запитів/год на токен. Throttle на стороні клієнта запобігає вичерпанню одним бурстом. - **React Window:** обробник scroll обмежений 16ms для рендерингу великих списків. - **Netflix web:** RxJS `throttleTime` на події scroll в UI-шарі. - **Vercel serverless:** `p-throttle` для обмеження паралельних запитів до бази даних. ### Питання на співбесіді **Q:** Яка різниця між throttle і debounce для поля пошуку? **A:** Throttle викликає функцію кожні 200ms під час введення, даючи регулярні preview-результати. Debounce чекає 500ms після останнього символу і викликає один раз. Throttle для постійного зворотного зв'язку, debounce для одного фінального запиту. **Q:** Реалізуй throttle з підтримкою leading і trailing edge. **A:** Відстежуй `lastCall`. При кожному виклику обчислюй `timeLeft = delay - (now - lastCall)`. Якщо `timeLeft <= 0`, виконай одразу (leading) і оновлюй `lastCall`. Інакше збережи останні аргументи і встанови таймер на `timeLeft` (trailing). Скидай попередній таймер при кожному новому виклику. **Q:** Як throttle поводиться з async-функціями? **A:** Не чекає завершення попереднього виклику. Два виклики через 1001ms обидва пройдуть, навіть якщо перший ще не resolved. Для послідовного async-контролю використовуй `p-throttle` або керуй чергою промісів вручну. **Q:** Як [event loop](/questions/event-loop) в Node.js впливає на throttle-таймери? **A:** libuv-таймери не гарантують точне спрацювання. Під навантаженням `setTimeout(fn, 16)` може спрацювати через 20ms або пізніше. Для точного контролю перевіряй `performance.now()` вручну, а не покладайся на момент спрацювання таймера. **Q:** (Senior) Спроектуй розподілений throttle для мікросервісів. Чому token bucket краще ніж fixed window? **A:** Fixed window дозволяє бурст на межі: 100 запитів/хв означає 100 в кінці однієї хвилини і 100 на початку наступної, тобто 200 за дві секунди. Token bucket вирівнює потік: токени поповнюються з фіксованою швидкістю незалежно від позиції в межах вікна. В Redis: `INCR` + `EXPIRE` для fixed window, Lua-скрипт з логікою поповнення для token bucket. Bucket запобігає бурстам на межах повністю. ## Приклади ### Базова реалізація throttle ```javascript function throttle(fn, delay) { let lastCall = 0; return function(...args) { const now = Date.now(); if (now - lastCall >= delay) { lastCall = now; return fn(...args); } }; } // Сценарій: кнопка збереження з API-запитом const saveDocument = throttle((content) => { fetch('/api/save', { method: 'POST', body: JSON.stringify({ content }) }); console.log('Збережено о', Date.now()); }, 2000); saveDocument('чернетка 1'); // Виконується (0ms) saveDocument('чернетка 2'); // Пропускається (400ms) saveDocument('чернетка 3'); // Пропускається (800ms) saveDocument('чернетка 4'); // Пропускається (1200ms) // Через 2000ms: saveDocument('чернетка 5'); // Виконується (2000ms) ``` Функція зберігає не частіше одного разу на 2 секунди. Проміжні чернетки відхиляються. Якщо потрібно щоб остання чернетка завжди зберігалась, додай trailing edge: збережи останні аргументи і виконай їх після завершення вікна. ### Throttle для scroll у React-компоненті ```javascript import { useCallback, useEffect, useState } from 'react'; function throttle(fn, delay) { let timeoutId = null; return (...args) => { if (!timeoutId) { timeoutId = setTimeout(() => { timeoutId = null; fn(...args); }, delay); } }; } function VirtualList({ items }) { const [visibleRange, setVisibleRange] = useState({ start: 0, end: 20 }); const handleScroll = useCallback( throttle((e) => { const scrollTop = e.target.scrollTop; const itemHeight = 40; const start = Math.floor(scrollTop / itemHeight); const end = start + 15; // видимі + буфер setVisibleRange({ start, end }); }, 16), // ~60fps [] ); useEffect(() => { return () => window.removeEventListener('scroll', handleScroll); }, [handleScroll]); return ( <div onScroll={handleScroll} style={{ height: 400, overflow: 'auto' }}> {items.slice(visibleRange.start, visibleRange.end).map(item => ( <div key={item.id} style={{ height: 40 }}>{item.name}</div> ))} </div> ); } // Без throttle: scroll спрацьовує 1000+ разів/сек // З throttle 16ms: ~60 викликів/сек, плавний рендеринг ``` Цей варіант на основі `setTimeout` спрацьовує на trailing edge: перша подія scroll чекає 16ms перед виконанням. У scroll-важких інтерфейсах 16ms - єдине значення затримки, яке дає плавний scroll без видимих підвисань в продакшені. ### Розподілений rate limiter на Redis (token bucket) ```javascript // Node.js + Redis: token bucket для мікросервісів const redis = require('redis'); const client = redis.createClient(); async function tokenBucketThrottle(userId, maxTokens, refillRate) { const key = `throttle:${userId}`; const now = Date.now(); const luaScript = ` local key = KEYS[1] local maxTokens = tonumber(ARGV[1]) local refillRate = tonumber(ARGV[2]) local now = tonumber(ARGV[3]) local data = redis.call('HMGET', key, 'tokens', 'lastRefill') local tokens = tonumber(data[1]) or maxTokens local lastRefill = tonumber(data[2]) or now local elapsed = (now - lastRefill) / 1000 tokens = math.min(maxTokens, tokens + elapsed * refillRate) if tokens >= 1 then tokens = tokens - 1 redis.call('HMSET', key, 'tokens', tokens, 'lastRefill', now) redis.call('EXPIRE', key, 3600) return 1 else return 0 end `; const allowed = await client.eval(luaScript, 1, key, maxTokens, refillRate, now); return allowed === 1; } // Middleware для Express: throttle per-user app.use(async (req, res, next) => { const allowed = await tokenBucketThrottle( req.user.id, 100, // Максимум 100 токенів у бакеті 1.67 // Поповнення 1.67 токена/сек = 100/хв ); if (!allowed) { return res.status(429).json({ error: 'Ліміт запитів вичерпано' }); } next(); }); ``` Token bucket розподіляє навантаження рівномірно незалежно від того, коли в межах хвилини приходять запити. Fixed window такої гарантії не дає: 100 запитів в 0:59 і ще 100 в 1:00 пропустить без жодних обмежень.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.