skip to main content

Debounce на примере формы поиска

js

Кратко 🔗

Работа с формами не всегда настолько проста, насколько может показаться. В этой статье мы разберём, как сделать поле поиска, которое будет подсказывать варианты запросов, и при этом не положить свой сервер миллионом запросов в секунду.

debounce — это функция, которая «откладывает» вызов другой функции до того момента, когда с последнего вызова пройдёт определённое количество времени.

диаграмма работы debounce

Такую функцию используют, чтобы не бомбардировать сервер кучей запросов на каждый чих, а подождать, когда пользователь закончит или приостановит ввод, и только после этого отправить запрос на сервер.

Это бывает нужно в не только в формах поиска, как у нас, но и если мы пишем :

  • скрипт аналитики, который что-то считает после события прокрутки — если нам не хочется ничего считать до тех пор, пока пользователь не закончит прокручивать страницу;
  • модуль, который ждёт окончания некоторого повторяющегося действия, чтобы выполнить свою работу.

Разметка 🔗

Начнём с разметки формы. У нас будет сама форма #search и список ссылок, данные для которых мы будем получать в ответ:

<!-- У формы есть атрибут action, который будет работать,
если пользователи отключили скрипты. -->

<form action="/some-route" method="GET" id="search">
<label>Find your favourite pizza:</label>

<!-- Используем input с типом search,
чтобы браузеры делали дополнительную магию
с автозаполнением и подходящими кнопками
на телефонных клавиатурах. -->

<input type="search" name="query" placeholder="Margherita">

<!-- У кнопки тип проставлять необязательно,
так как submit — это тип кнопки по умолчанию. -->

<button>Search!</button>
</form>

<ul class="search-results"></ul>

Форма будет выглядеть незамысловато и будет работать стандартным образом. Мы будем обрабатывать форму с помощью JavaScript. Чтобы узнать больше о том, как это работает, читайте статьи «Валидация форм» и «Работа с формами в JS».

Изображение сверстанной формы

Просто форма 🙂

Фейковый сервер для запросов 🔗

Следующим шагом мы подготовим «сервер», на который будем отправлять запросы из формы.

Так как это всего лишь пример, мы не будем поднимать «настоящий сервер™». Вместо этого мы напишем «заглушку» для сервера, который будет делать всё, что нам потребуется.

Нам потребуется, чтобы «сервер» на запрос отвечал массивом названий видов пиццы, которые мы потом будем преобразовывать в ссылки и выводить в списке под формой.

Сперва приготовим список названий (так сказать базу данных 😃):

// По этому массиву мы будем искать названия,
// которые содержат пользовательский запрос.
const pizzaList = [
"Margherita",
"Pepperoni",
"Hawaii",
"4 Cheeses",
"Diabola",
"Sfincione",
]

А дальше создадим объект, который будет имитировать асинхронный ответ (Посмотрите статью про асинхронность в JS, если это понятие вам не знакомо).

// В функции contains мы будем проверять,
// содержится ли пользовательский запрос
// в каком-либо из названий:
function contains(query) {
return pizzaList.filter((title) =>
title.toLowerCase().includes(query.toLowerCase())
)
}

// Мок-объект сервера будет содержать метод search:
const server = {
search(query) {
// Этот метод будет возвращать промис,
// таким образом мы будем эмулировать «асинхронность»,
// как будто мы «сходили на сервер, он подумал и ответил».
return new Promise((resolve) => {
// Таймаут нужен исключительно для того,
// чтобы иметь возможность настраивать время задержки 🙂
setTimeout(
() =>
resolve({
// В качестве ответа будем отправлять объект,
// значением поля list которого
// будет наш отфильтрованный массив.
list: query ? contains(query) : [],
}),
150
)
})
},
}

Мы сможем вызывать этот метод вот так:

;(async () => {
const response = await server.search("Peppe")
})()

Или так:

server.search("Peppe").then(() => {
/*...*/
})

Первая версия обработчика 🔗

Сперва напишем основу для обработки формы без debounce, убедимся, что всё работает, увидим причину, зачем нам debounce вообще нужен, а потом напишем его.

Получим ссылки на все элементы, с которыми будем работать:

const searchInput = searchForm.querySelector('[type="search"]')
const searchResults = document.querySelector(".search-results")

Затем напишем обработчик события ввода с клавиатуры в поле поиска:

searchInput.addEventListener("input", (e) => {
// Получаем значение в поле,
// на котором сработало событие:
const { value } = e.target

// Получаем список названий пицц от сервера:
server.search(value).then(function (response) {
const { list } = response

// Проходим по каждому из элементов списка,
// и составляем строчку с несколькими <li> элементами...
const html = list.reduce((markup, item) => {
return `${markup}<li>${item}</li>`
}, ``)

// ...которую потом используем как содержимое списка:
searchResults.innerHTML = html
})
})

Проверим, что при вводе какой-то строки, например a, мы видим список на странице.

форма заполненная буквой «а»

Работает 💥

Теперь вернёмся к проблеме, с которой мы начали. Сейчас каждое нажатие клавиши в поле отправляет запрос на сервер. Мы это можем проверить, если прологируем метод search на сервере:

const server = {
search(query) {
// Поставим логер, который будет выводить
// каждый принятый запрос:
console.log(query)

return new Promise((resolve) => {
setTimeout(
() =>
resolve({
list: query ? contains(query) : [],
}),
100
)
})
},
}

Теперь введём название пиццы:

Форма, которая отправляет запрос на каждую из пяти введенных букв

Мы быстро ввели 5 букв, а из-за этого улетело 5 запросов. Это расточительно.

Для того, чтобы не дёргать сервер на каждое изменение ввода, мы хотим «отложить» запрос до момента, когда пользователь приостановит ввод.

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

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

Пишем debounce 🔗

Хорошо, мы определились с проблемой, как теперь её решить?

Первая мысль, которая приходит в голову — изменить обработчик события, чтобы он следил за тем, когда ему отправлять запросы, а когда нет. Но это не очень удачная мысль.

  • Это смешение ответственностей, обработчику лучше обрабатывать события, а не заниматься чем-то параллельно, иначе он быстро станет нечитаемым.
  • Если у нас появится похожая форма, то придётся реализовать ту же фичу ещё раз.

Нам нужно написать функцию, которая будет знать, когда надо вызывать другую функцию.

Итак, debounce — это функция высшего порядка, которая будет принимать аргументом функцию, которую надо «отложить».

Поехали:

// Аргументами функции будут:
// - функция, которую надо «откладывать»;
// - интервал времени, спустя которое функцию следует вызывать.
function debounce(callee, timeoutMs) {
// Как результат возвращаем другую функцию.
// Это нужно, чтобы мы могли не менять другие части кода,
// чуть позже мы увидим, как это помогает.
return function perform(...args) {
// В переменной previousCall мы будем хранить
// временную метку предыдущего вызова...
let previousCall = this.lastCall

// ...а в переменной текущего вызова —
// временную метку нынешнего момента.
this.lastCall = Date.now()

// Нам это будет нужно, чтобы потом сравнить,
// когда была функция вызвана в этот раз и в предыдущий.
// Если разница между вызовами меньше, чем указанный интервал,
// то мы очищаем таймаут...
if (previousCall && this.lastCall - previousCall <= timeoutMs) {
clearTimeout(this.lastCallTimer)
}

// ...который отвечает за непосредственно вызов функции-аргумента.
// Обратите внимание, что мы передаём все аргументы ...args,
// который получаем в функции perform —
// это тоже нужно, чтобы нам на приходилось менять другие части кода.
this.lastCallTimer = setTimeout(() => callee(...args), timeoutMs)

// Если таймаут был очищен, вызова не произойдёт;
// если он очищен не был, то callee вызовется.
// Таким образом мы как бы «отодвигаем» вызов callee
// до тех пор, пока «снаружи всё не подуспокоится».
}
}

Использовать такой debounce мы можем так:

// Функция, которую мы хотим «откладывать»:
function doSomething(arg) {
// ...
}

doSomething(42)

// А вот — та же функция, но обёрнутая в debounce:
const debouncedDoSomething = debounce(doSomething, 250)

// debouncedDoSomething — это именно функция,
// потому что из debounce мы возвращаем функцию.

// debouncedDoSomething принимает те же аргументы,
// что и doSomenthing, потому что perform внутри debounce
// прокидывает все аргументы без изменения в doSomething,
// так что и вызов debouncedDoSomething будет таким же,
// как и вызов doSomething:
debouncedDoSomething(42)

Применяем debounce 🔗

Теперь мы можем применить debounce в нашем обработчике. Сперва немного порефакторим:

// Вынесем обработчик события в отдельную функцию.
// Внутри она будет такой же,
// но так нам будет удобнее оборачивать её в debounce.
function handleInput(e) {
const { value } = e.target

server.search(value).then(function (response) {
const { list } = response

const html = list.reduce((markup, item) => {
return `${markup}<li>${item}</li>`
}, ``)

searchResults.innerHTML = html
})
}

searchInput.addEventListener("input", handleInput)

Теперь обернём вынесенную функцию и обновим addEventListener:

function handleInput(e) {
// ..
}

// Указываем, что нам нужно ждать 250 мс,
// прежде чем запустить обработчик:
const debouncedHandle = debounce(handleInput, 250)

// Передаём новую debounced-функцию в addEventListener:
searchInput.addEventListener("input", debouncedHandle)

И теперь, если мы быстро напишем несколько символов, мы отправим лишь один запрос:

форма поиска с debounce. На сервер отправляется один запрос

Вместо пяти запросов теперь отправляем всего один!

Обратите внимание, что API функции не поменялось. Мы как передавали event, так и передаём. То есть для внешнего мира debounced-функция ведёт себя точно так же, как и простая функция-обработчик.

Это удобно, потому что меняется лишь одна небольшая часть программы, не затрагивая системы в целом.

Результат 🔗

Полный пример строки поиска у нас получится такой:

See the Pen doka-debounce-search-form by Alexander Bespoyasov (@bespoyasov) on CodePen.

В работе 🔗

Используйте debounce, чтобы оптимизировать операции, которые можно выполнить единожды в конце.

Например, для формы поиска это подойдёт. Однако для отслеживания движения мыши — нет, потому что будет странно ждать, пока пользователь остановит курсор.

Для таких задач, которые можно выполнять раз в какое-то количество времени, лучше подходит throttle.