Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Дебаунс та тротл у JavaScript». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Debounce** відкладає виклик функції до паузи в подіях. **Throttle** обмежує виконання до одного разу за проміжок часу. ```javascript // Debounce: один запит через 300мс після паузи const debounced = debounce(search, 300); // Throttle: обробник scroll максимум раз на 200мс const throttled = throttle(onScroll, 200); ``` **Правило вибору:** користувач зупиняється - debounce. Потрібна рівна частота - throttle.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Debounce** відкладає виклик функції до паузи в подіях. **Throttle** обмежує виконання до одного разу за заданий проміжок часу. ## Теорія ### Коротко - Debounce чекає тиші: спрацьовує один раз після того, як виклики зупинились - Throttle тримає ритм: спрацьовує з фіксованою частотою під час неперервної активності - Аналогія: debounce - це туалет із замком на 5 хвилин після кожного використання; throttle - кава, яку підливають максимум раз на 5 хвилин - Користувач зупиняється, потім діє? Debounce. Потрібна стабільна частота? Throttle. ### Швидкий приклад ```javascript // Debounce: спрацьовує один раз після зупинки викликів const debounce = (fn, delay) => { let timer; return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), delay); }; }; // Throttle: спрацьовує не частіше ніж раз на delay мс const throttle = (fn, delay) => { let lastCall = 0; return (...args) => { const now = Date.now(); if (now - lastCall >= delay) { lastCall = now; fn(...args); } }; }; ``` Обидва патерни базуються на замиканнях (closures). `debounce` скидає таймер при кожному виклику. `throttle` перевіряє мітку часу і відкидає виклики, що надходять занадто рано. ### Головна різниця Debounce збирає серію викликів і виконує функцію рівно один раз, після того як серія закінчується. Саме це слово тут головне. Throttle не чекає кінця серії. Він спрацьовує одразу, потім ще раз через кожен інтервал, поки виклики продовжуються. ### Коли що вибирати - Користувач вводить текст у пошук → **debounce** (один API-запит після паузи, не на кожну літеру) - Прокрутка або зміна розміру вікна → **throttle** (оновлення інтерфейсу з постійним темпом) - Кнопка відправлення форми → **throttle** (захист від повторних відправлень) - Зовнішній API з лімітом на запити → **throttle** (рівномірно розподіляємо навантаження) - Валідація поля під час введення → **debounce** (перевірка після паузи, не по-середині слова) ### Таблиця порівняння | Аспект | Debounce | Throttle | |--------|----------|---------| | **Коли спрацьовує** | Після останнього виклику + delay | Одразу, потім раз на інтервал | | **При частих викликах** | Скидається щоразу, спрацьовує в кінці | Спрацьовує з фіксованим інтервалом | | **Кількість виконань** | Зазвичай 1 на серію | Кілька, обмежених за частотою | | **Стандартна поведінка** | Trailing (після тиші) | Leading (перший виклик одразу) | | **Підходить для** | Автодоповнення, автозбереження | Scroll, mousemove, API polling | ### Як це працює всередині `debounce` викликає `clearTimeout` при кожному виклику. Це скасовує попередній запланований callback. Виживає тільки останній виклик, бо йому вже нічого не заважає. `throttle` в базовому варіанті не використовує таймер взагалі. Він порівнює `Date.now()` із часом останнього виконання. Якщо різниця менша за delay, виклик відкидається. Помилка, яку я сам робив на початку: думав, що throttle завжди пропускає перший виклик. Ні. Перший проходить одразу, бо `lastCall` починається з 0, а `Date.now() - 0` завжди більше будь-якого реального delay. Обидва патерни залежать від event loop браузера. Callback-и таймерів потрапляють у чергу macrotask і виконуються після поточного синхронного коду. ### Типові помилки **Помилка 1: Немає очищення при розмонтуванні в React** ```javascript // Неправильно: таймер спрацьовує після видалення компонента useEffect(() => { debouncedFetch(query); }, [query]); // Правильно: скасовуємо таймер при очищенні useEffect(() => { const timer = setTimeout(() => fetchData(query), 300); return () => clearTimeout(timer); }, [query]); ``` Без очищення таймер викликає `setState` на вже розмонтованому компоненті. Це React-попередження в dev-режимі і потенційна проблема при SSR. **Помилка 2: Нова debounce-функція при кожному рендері** ```javascript // Неправильно: кожен рендер створює нову функцію, таймер постійно скидається function SearchInput() { const handleChange = debounce((val) => search(val), 500); return <input onChange={handleChange} />; } // Правильно: створюємо один раз function SearchInput() { const handleChange = useMemo( () => debounce((val) => search(val), 500), [] ); return <input onChange={handleChange} />; } ``` **Помилка 3: Очікування, що throttle пропустить перший виклик** ```javascript const throttled = throttle(fn, 1000); throttled(); // Спрацьовує одразу (leading edge) throttled(); // Пропускається throttled(); // Пропускається ``` Lodash `_.throttle` за замовчуванням спрацьовує на leading edge. Передай `{ leading: false }`, якщо потрібна інша поведінка. **Помилка 4: Втрата контексту `this`** ```javascript // Неправильно: `this` губиться всередині обгортки function debounce(fn, delay) { let timer; return function() { clearTimeout(timer); timer = setTimeout(fn, delay); // `this` не передається }; } // Правильно function debounce(fn, delay) { let timer; return function(...args) { clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), delay); // ✅ }; } ``` **Помилка 5: Вкладення debounce в throttle** ```javascript // Зламана конструкція: внутрішній debounce скидається при кожному виклику throttle const broken = throttle(() => debounce(save, 500)(), 1000); // save ніколи не викликається ``` Одна обгортка завжди краще двох вкладених. Вкладення призводить до постійного скидання внутрішнього таймера. ### Де зустрічається в реальних проектах - Пошукові поля в React використовують хук `use-debounce` (той самий патерн, що у шаблонах Vercel commerce) - Lodash `_.debounce` і `_.throttle` є практично в кожному великому npm-проекті для обробки scroll та input - `express-rate-limit` в Node.js/Express реалізує throttle-патерн для API-ендпоінтів - Для scroll і resize на 60fps `requestAnimationFrame` часто краще за throttle з фіксованим delay - Нативний `ResizeObserver` у сучасних браузерах позбавляє потреби вручну throttle-ити resize-події ### Питання на співбесіді **Q:** Реалізуй debounce з нуля. **A:** Замикання (closure), `clearTimeout` і `setTimeout`. Передай `this` та args через `.apply(this, args)`. Базова реалізація є у "Швидкому прикладі" вище. **Q:** Що станеться, якщо `delay` дорівнює 0? **A:** Debounce виконає функцію на наступному тику event loop після кожного виклику, тобто один раз на task. Throttle пропустить всі виклики, бо `Date.now() - 0` завжди більше або рівне 0. **Q:** Як уникнути memory leak від debounce в React? **A:** Повертай функцію очищення з `useEffect`, яка викликає `clearTimeout`. Зберігай ID таймера в `useRef`, щоб він не зникав між рендерами. **Q:** Яка різниця між leading і trailing edge у throttle? **A:** Leading спрацьовує одразу при першому виклику. Trailing спрацьовує в кінці інтервалу з останніми аргументами. Lodash `_.throttle` робить обидва за замовчуванням, що дає зайвий виклик наприкінці серії. **Q:** Як зробити debounce для async-функції? **A:** Обгортка повертає `void`, не Promise. Щоб отримати результат, передай його через callback або стеж за останнім Promise через ref і резолви його окремо. Інакше кілька паралельних запитів можуть завершитися в непередбачуваному порядку. ## Приклади ### Базовий: debounce для поля пошуку ```javascript const debounce = (fn, delay) => { let timer; return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), delay); }; }; const input = document.querySelector('#search'); const search = debounce((event) => { console.log('API запит:', event.target.value); // fetch(`/api/search?q=${event.target.value}`) }, 400); input.addEventListener('input', search); // Введення 'hello' = 5 подій = 1 API-запит через 400мс після паузи ``` Без debounce введення п'яти символів дає п'ять API-запитів. З ним - один, після того як користувач зупинився. ### Середній рівень: throttle для infinite scroll ```javascript const throttle = (fn, delay) => { let lastCall = 0; return (...args) => { const now = Date.now(); if (now - lastCall >= delay) { lastCall = now; fn(...args); } }; }; const handleScroll = throttle(() => { const nearBottom = window.scrollY + window.innerHeight >= document.body.scrollHeight - 100; if (nearBottom) { loadMoreItems(); } }, 200); window.addEventListener('scroll', handleScroll); // Максимум 5 перевірок на секунду незалежно від швидкості прокрутки ``` Швидка прокрутка без throttle генерує сотні подій на секунду. При 200мс - максимум 5 перевірок. Цього достатньо, щоб infinite scroll спрацьовував без помітних затримок. ### Просунутий рівень: React хук із правильним очищенням ```javascript import { useCallback, useRef, useEffect } from 'react'; function useDebounce(callback, delay) { const timerRef = useRef(null); useEffect(() => { return () => clearTimeout(timerRef.current); }, []); return useCallback((...args) => { clearTimeout(timerRef.current); timerRef.current = setTimeout(() => callback(...args), delay); }, [callback, delay]); } function SearchBox() { const handleSearch = useDebounce((value) => { fetch(`/api/search?q=${value}`).then((res) => res.json()); }, 300); return ( <input type='text' onChange={(e) => handleSearch(e.target.value)} placeholder='Пошук...' /> ); } // useEffect cleanup скасовує таймер при розмонтуванні // Немає спроб оновити стан видаленого компонента ``` Очищення в `useEffect` - це деталь, яку найчастіше пропускають у власних реалізаціях. Без неї користувач, який швидко перейде на іншу сторінку, отримає fetch-запит, що намагається оновити стан вже неіснуючого компонента.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.