Створення утиліти для подавлення сигналів з нуля: таймери, аргументи та це
Створіть функцію debounce, яку можна використовувати багаторазово, рядок за рядком; дізнайтеся, чому вона передає аргументи та коли краще використовувати throttle, а також як поєднати її з AbortController.
Джерела: Debouncing in JavaScript: How to Stop Sending Useless API Requests
Поле пошуку, яке звертається до серверного модуля при кожному натисканні клавіші, перетворює одне введене слово на низку запитів, більшість з яких відкидаються. Рішенням є невелика утиліта під назвою debounce. Тут ви її реалізуєте, зрозумієте кожен рядок коду, виберете між алгоритмами debounce та throttling, а також поєднаєте їх із скасуванням запитів для створення процесу пошуку, який буде ефективно працювати під час реального введення тексту.
Як натискання клавіш перетворюються на марні запити
r
re
rea
reac
react
Якщо кожна зміна викликає запит до API, сервер обробляє п’ять запитів на одне пошукове запитання. Більш точний пошук погіршує ситуацію:
r
re
rea
reac
react
react j
react js
react js d
react js de
react js dev
Це вже десять запитів, і лише останній відображає те, що хотів користувач; решта все одно споживають пропускну здатність мережі, час обробки на сервері та ліміти кількості запитів.
Ідея застосування debounce
Дебаунсинг відкладає виклик функції до того моменту, коли мине визначений проміжок часу без подальших викликів: дозвольте користувачеві закінчити введення, а потім виконайте необхідну роботу.
Припустимо, що виклик пошуку заблокований з затримкою у 500 мс, і користувач знову вводить ті самі п’ять символів:
r
re
rea
reac
react
Кожен натиск клавіши починає заново лічення часу, тому під час продовження введення жодних даних не надсилається. Через 500 мс тиші відправляється один запит:
react → API request
Цикл прийняття рішень для кожного виклику:
Typing
↓
Wait 500ms
↓
Did user type again?
↓
Yes → Reset timer
↓
No → Execute function
Поле пошуку без жодного захисту
Почнімо з звичайного елемента введення:
<input id="search" placeholder="Search..." />
Отримайте посилання на нього в JavaScript:
const searchInput = document.querySelector("#search");
Потім підключіть слухач input, який передає значення у функцію пошуку, яка лише записує це значення для підрахунку кількості її викликів:
searchInput.addEventListener("input", (event) => {
searchProducts(event.target.value);
});function searchProducts(query) {
console.log("API request:", query);
}
Тепер припустимо, що хтось вводить назву продукту:
iphone
Захід input викликається один раз за кожен символ, приблизно у такій послідовності:
i
ip
iph
ipho
iphon
iphone
Тож обробник виконується шість разів та фіксує шість запитів:
API request: i
API request: ip
API request: iph
API request: ipho
API request: iphon
API request: iphone
Для справжнього API це означає шість поїздок, тоді як користувача цікавить лише:
iphone
Реалізація механізму debounce у кількох рядках
Цей інструментарій — це замикання: воно бере функцію та затримку, а потім повертає обгортку, яку використовують замість оригінальної функції:
function debounce(fn, delay) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
Зберігайте один таймер на кожну функцію з debounce
let timer;
timer знаходиться у замиканні, тому кожен виклик обгортки бачить однакове значення; саме так один виклик дізнається, що попередній все ще очікує. Кожен виклик debounce отримує свій власний незалежний таймер.
Скасуйте очікуваний виклик
clearTimeout(timer);
Кожне викликання скасовує поточний запланований виклик, якщо такий існує. Передача значення undefined до функції clearTimeout під час першого виклику не має наслідків.
Запланувати новий виклик
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
Новий таймер запускає початкову функцію після закінчення терміну очікування. При значенні 500 мс швидкі послідовні виклики працюють наступним чином:
Call
↓
500ms timer starts
Call again
↓
Old timer cancelled
↓
New 500ms timer starts
Call again
↓
Old timer cancelled
↓
New 500ms timer starts
Функція виконується лише один раз після того, як мине повний проміжок у 500 мс без інших викликів, причому останні аргументи мають перевагу.
Підключення цього інструменту до поля пошуку
Замініть звичайний обробник на версію з дебаунсингом (це замінить попередню декларацію searchProducts, оскільки обидві використовують однакову назву):
const searchProducts = debounce((query) => {
console.log("API request:", query);
}, 500);
searchInput.addEventListener("input", (event) => {
searchProducts(event.target.value);
});
Коли користувач вводить п’ять знайомих символів:
r
re
rea
reac
react
кожен натискання клавіші скасовує попередній таймер, і зрештою завершується період тиші:
react
↓
500ms
↓
API request
Результатом є один запит до API замість п’яти.
Чому обгортка передає аргументи
Два елементи можуть здаватися необов’язковими, але роблять інструмент перевикористовуваним. Перший з них — параметр rest:
return function (...args)
Другий — спосіб виклику оригінальної функції:
fn.apply(this, args);
Підтримка будь-якої кількості аргументів
Універсальний механізм debounce не повинен нічого припускати щодо сигнатури обгорнутої функції. Він може обгортати пошук з одним аргументом:
const search = debounce((query) => {
console.log(query);
}, 500);
або функцію, яка приймає два значення:
const calculate = debounce((a, b) => {
console.log(a + b);
}, 500);
Збір усього за допомогою синтаксису rest та його передача забезпечують працездатність обох варіантів:
...args
Збереження контексту виклику
Та сама рядок також передає this:
fn.apply(this, args);
Обгортка є звичайною функцією, тому вона отримує this викликаючого коду, наприклад об’єкт, коли функція з затримкою зберігається як метод. Функція-стрілка всередині setTimeout не має власного this, тому вона запам’ятовує this обгортки, а функція apply передає його далі. Обробник пошуку ніколи цього не помічає, але спільний інструментарій, який втрачає контекст, спричиняє заплутані помилки в коді, заснованому на об’єктах. Зауважте, що функція-стрілка, передана як fn, у будь-якому разі зберігає своє лексичне this.
Де корисна затримка виконання
Ця схема підходить для будь-якої події, яка виникає серійно, коли має значення лише кінцевий стан.
Пропозиції з пошуку
Примітивний підхід надсилає запит за кожним натисканням клавіші:
User types → API request
У підході з затримкою спочатку очікують на паузу:
User types
↓
Wait
↓
User stops
↓
API request
Зміна розміру вікна
Перетягування краю вікна постійно генерує події resize, і щоразу виконується дорога операція через прямого слухача:
window.addEventListener("resize", () => {
expensiveCalculation();
});
Це може означати сотні виконань на кожне перетягування. Якщо операцію обгорнути, вона виконується лише після того, як зміна розміру завершиться:
const handleResize = debounce(() => {
expensiveCalculation();
}, 300);
window.addEventListener("resize", handleResize);
Автозбереження
У редакторі технологія debouncing перетворює потік змін на одне збереження після того, як користувач припинить ввод:
User types
User types
User types
User types
...
User stops
↓
Save document
Увага: технологія debouncing не спрацьовує, поки користувач безперервно вводить текст, а незавершене збереження втрачається, якщо сторінка закривається. Редактори зазвичай встановлюють максимальний термін очікування або здійснюють збереження при зміні visibilitychange.
Debouncing проти throttling
Ці два підходи легко плутати. Debouncing чекає, поки активність припиниться, а потім виконується один раз:
Typing → Typing → Typing → STOP
↓
Execute
Throttling виконує функцію щонайбільше один раз на фіксований інтервал, незалежно від кількості подій:
Event → Execute
↓
100ms
↓
Ignore
↓
100ms
↓
Execute
Виконання спочатку чи наприкінці додає нюансів, але це розрізнення охоплює більшість випадків.
Debounce не виправляє застарілі відповіді
Debouncing обмежує кількість початкових запитів, а не те, що відбувається з тими, які вже у процесі обробки. Припустимо, користувач затримується достатньо довго, щоб запит було надіслано, а потім уточнює запит:
User searches: react
↓
Request A
User changes search: react native
↓
Request B
Якщо запит B повертається раніше за запит A, старіша відповідь надходить останньою та замінює новіші результати. Тоді інтерфейс відображає результати для:
react
хоча введені дані тепер говорять:
react native
Рішення полягає у скасуванні застарілих запитів за допомогою AbortController, який використовується разом із механізмом debounce:
Debouncing
↓
Reduce unnecessary requests
↓
AbortController
↓
Cancel outdated requests
Цю проблему детально розглядається у статті про вирішення умов змагання, які debounce не може вирішити.
Об’єднання обох шарів
У цьому підході зберігається controller на рівні модуля. Кожного разу, коли виконується функція з дебаунсингом, попередній запит скасовується, створюється новий контролер, а його signal передається до fetch. Функція encodeURIComponent захищає спеціальні символи, а помилку AbortError ігнорують, оскільки вона означає навмисне скасування, а не збій:
const searchInput = document.querySelector("#search");
let controller;
const searchProducts = debounce(async (query) => {
controller?.abort();
controller = new AbortController();
try {
const response = await fetch(
`/api/products?q=${encodeURIComponent(query)}`,
{
signal: controller.signal
}
);
const data = await response.json();
renderResults(data);
} catch (error) {
if (error.name !== "AbortError") {
console.error(error);
}
}
}, 500);
searchInput.addEventListener("input", (event) => {
searchProducts(event.target.value);
});
Дебаунсинг запобігає надсиланню запиту за кожен натиск клавіші; AbortController зупиняє запити, результати яких більше не мають значення.
У продакшні також варто пропускати порожні запити та перевіряти response.ok перед обробкою даних, адже fetch не відхиляє запити через статуси HTTP-помилок.
Швидка модель прийняття рішень
Коли подія викликається багаторазово, потрібно з’ясувати, чи потрібно реагувати на кожну з них. Якщо ні, для прийняття рішення використовується друге запитання. Оскільки вам потрібне кінцеве значення, застосовуйте механізм дебаунсингу:
Input events
↓
Do I need every event?
↓
No
↓
Do I care about the final state?
↓
Yes
↓
Debounce
Для прокрутки потрібні регулярні оновлення під час виконання дії, тому використовуйте механізм теулінгу:
Scroll events
↓
Do I need every event?
↓
No
↓
Do I need periodic updates while scrolling?
↓
Yes
↓
Throttle
Підсумок
Дебаунсинг насправді слугує для контролю частоти виконання ресурсомістких операцій, і механізм складається з двох рядків коду:
clearTimeout(timer);
timer = setTimeout(() => {
fn();
}, delay);
Використовуйте цей підхід, коли подібні події виникають швидко одна за одною:
input
resize
search
auto-save
Кілька порад для подальшого використання:
- Функцію дебаунсингу потрібно створювати лише один раз та використовувати її знову; створення нової функції для кожної події (або під час кожного рендеру React без
useMemoабоuseRef) призводить до того, що кожен виклик отримує власний таймер, що унеможливлює ефект дебаунсингу. - Передача
...argsтаthisробить допоміжну функцію безпечною для повторного використання.
AbortController вирішує, чи дозволити завершитися застарілому запиту. Використовуйте обидва для пошуку з підтримкою мережі.