Главная / Статьи

Написание утилиты для борьбы с дребезгом с нуля: таймеры, аргументы и это

Создайте функцию дебаунсинга, которую можно использовать повторно, шаг за шагом; узнайте, почему она передаёт аргументы и когда вместо неё следует использовать теуторинг, а также как сочетать её с 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);

Обертка представляет собой обычную function, поэтому она получает значение this вызывающего кода, например объект, когда функция с дебаузингом хранится как метод. Функция-стрелка внутри setTimeout не имеет собственного значения 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);

Авосложение

В редакторе техника дебаунсинга превращает поток изменений в одно сохранение после того, как пользователь прекращает ввод:

User types
User types
User types
User types
...
User stops
      ↓
Save document

Важно: техника дебаунсинга не срабатывает, пока пользователь непрерывно вводит текст, а незавершенное сохранение теряется при закрытии страницы. Обычно в редакторах устанавливается максимальное время ожидания или сохранение происходит при событии visibilitychange.

Дебаунсинг против троттлинга

Эти понятия легко спутать. Дебаунсинг ожидает прекращения действий, а затем выполняет операцию один раз:

Typing → Typing → Typing → STOP
                              ↓
                           Execute

Троттлинг выполняет функцию не чаще одного раза в фиксированный интервал, независимо от количества поступивших событий:

Event → Execute
  ↓
100ms
  ↓
Ignore
  ↓
100ms
  ↓
Execute

Выполнение в начале и в конце добавляет нюансы, но это различие охватывает большинство случаев.

Дебаунс не решает проблему устаревших ответов

Дебаунс ограничивает количество отправляемых запросов, но не влияет на те запросы, которые уже в процессе обработки. Предположим, пользователь надолго задерживается, пока не будет отправлен запрос, а затем уточняет свой поиск:

User searches: react
       ↓
Request A

User changes search: react native
       ↓
Request B

Если запрос B вернется раньше запроса A, старый ответ придет в последнюю очередь и заменит более свежие результаты. В таком случае интерфейс отобразит результаты по:

react

хотя ввод теперь гласит:

react native

Решение заключается в отмене устаревших запросов с помощью AbortController, применяемого поверх механизма дебаунса:

Debouncing
    ↓
Reduce unnecessary requests
    ↓
AbortController
    ↓
Cancel outdated requests

Эта проблема с конкурентными состояниями подробно рассматривается в статье Как устранить конкурентные состояния: дебаунс их не решит.

Соединение обеих слоев

В этом подходе сохраняется 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 решает, разрешается ли завершить устаревший запрос. Используйте обе функции для поиска с использованием сети.