Головна / Статті

Створення утиліти для подавлення сигналів з нуля: таймери, аргументи та це

Створіть функцію debounce, яку можна використовувати багаторазово, рядок за рядком; дізнайтеся, чому вона передає аргументи та коли краще використовувати throttle, а також як поєднати її з AbortController.

Видавець: REACTAPP.TOP8 хв читання1742 слів

Джерела: 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 робить допоміжну функцію безпечною для повторного використання.
  • Виберіть debounce для подій „кінцевого стану“ та throttle для подій „постійних оновлень під час їх виконання“.
  • Debounce визначає момент початку запиту; AbortController вирішує, чи дозволити завершитися застарілому запиту. Використовуйте обидва для пошуку з підтримкою мережі.