Кратко
СкопированоРабота с формами не всегда настолько проста, насколько может показаться. В этой статье мы разберём, как сделать поле поиска, которое будет подсказывать варианты запросов, и при этом не положить свой сервер миллионом запросов в секунду.
debounce
— это функция, которая «откладывает» вызов другой функции до того момента, когда с последнего вызова пройдёт определённое количество времени.
Такую функцию используют, чтобы не бомбардировать сервер кучей запросов на каждый чих, а подождать, когда пользователь закончит или приостановит ввод, и только после этого отправить запрос на сервер.
Это бывает нужно в не только в формах поиска, как у нас, но и если мы пишем:
- скрипт аналитики, который что-то считает после события прокрутки — если нам не хочется ничего считать до тех пор, пока пользователь не закончит прокручивать страницу;
- модуль, который ждёт окончания некоторого повторяющегося действия, чтобы выполнить свою работу.
Разметка
СкопированоНачнём с разметки формы. У нас будет сама форма #search
и список ссылок, данные для которых мы будем получать в ответ:
<!-- У формы есть атрибут action, который будет работать, если пользователи отключили скрипты. --><form action="/some-route" method="GET" id="search"> <label>Найди любимую пиццу:</label> <!-- Используем input с типом search, чтобы браузеры делали дополнительную магию с автозаполнением и подходящими кнопками на телефонных клавиатурах. --> <input type="search" name="query" placeholder="Маргарита" /> <!-- У кнопки тип проставлять необязательно, так как submit — это тип кнопки по умолчанию. --> <button>Искать!</button></form><ul class="search-results"></ul>
<!-- У формы есть атрибут action, который будет работать, если пользователи отключили скрипты. --> <form action="/some-route" method="GET" id="search"> <label>Найди любимую пиццу:</label> <!-- Используем input с типом search, чтобы браузеры делали дополнительную магию с автозаполнением и подходящими кнопками на телефонных клавиатурах. --> <input type="search" name="query" placeholder="Маргарита" /> <!-- У кнопки тип проставлять необязательно, так как submit — это тип кнопки по умолчанию. --> <button>Искать!</button> </form> <ul class="search-results"></ul>
Форма будет выглядеть незамысловато и будет работать стандартным образом. Мы будем обрабатывать форму с помощью JavaScript. Чтобы узнать больше о том, как это работает, читайте статьи «Валидация форм» и «Работа с формами в JS».
Просто форма 🙂
Фейковый сервер для запросов
СкопированоСледующим шагом мы подготовим «сервер», на который будем отправлять запросы из формы.
Так как это всего лишь пример, мы не будем поднимать «настоящий сервер™». Вместо этого мы напишем «заглушку» для сервера, который будет делать всё, что нам потребуется.
Нам потребуется, чтобы «сервер» на запрос отвечал массивом названий видов пиццы, которые мы потом будем преобразовывать в ссылки и выводить в списке под формой.
Сперва приготовим список названий (так сказать базу данных 😃):
// По этому массиву мы будем искать названия,// которые содержат пользовательский запрос.const pizzaList = [ 'Маргарита', 'Пепперони', 'Гавайская', '4 Сыра', 'Диабло', 'Сицилийская']
// По этому массиву мы будем искать названия, // которые содержат пользовательский запрос. const pizzaList = [ 'Маргарита', 'Пепперони', 'Гавайская', '4 Сыра', 'Диабло', 'Сицилийская' ]
А дальше создадим объект, который будет имитировать асинхронный ответ (Посмотрите статью про асинхронность в 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 ) }) },}
// В функции 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')})()
(async () => { const response = await server.search('Peppe') })()
Или так:
server.search('Peppe').then(() => { /*...*/})
server.search('Peppe').then(() => { /*...*/ })
Первая версия обработчика
СкопированоСперва напишем основу для обработки формы без debounce
, убедимся, что всё работает, увидим причину, зачем нам debounce
вообще нужен, а потом напишем его.
Получим ссылки на все элементы, с которыми будем работать:
const searchForm = document.getElementById('search-form');const searchInput = searchForm.querySelector('[type="search"]');const searchResults = document.querySelector('.search-results');
const searchForm = document.getElementById('search-form'); 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 })})
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 ) }) },}
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 // до тех пор, пока «снаружи всё не подуспокоится». }}
// Аргументами функции будут: // - функция, которую надо «откладывать»; // - интервал времени, спустя которое функцию следует вызывать. 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 принимает те же аргументы,// что и doSomething, потому что perform внутри debounce// прокидывает все аргументы без изменения в doSomething,// так что и вызов debouncedDoSomething будет таким же,// как и вызов doSomething:debouncedDoSomething(42)
// Функция, которую мы хотим «откладывать»: function doSomething(arg) { // ... } doSomething(42) // А вот — та же функция, но обёрнутая в debounce: const debouncedDoSomething = debounce(doSomething, 250) // debouncedDoSomething — это именно функция, // потому что из debounce мы возвращаем функцию. // debouncedDoSomething принимает те же аргументы, // что и doSomething, потому что 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)
// Вынесем обработчик события в отдельную функцию. // Внутри она будет такой же, // но так нам будет удобнее оборачивать её в 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)
Теперь обернём вынесенную функцию и обновим add
:
function handleInput(e) { // ..}// Указываем, что нам нужно ждать 250 мс,// прежде чем запустить обработчик:const debouncedHandle = debounce(handleInput, 250)// Передаём новую debounced-функцию в addEventListener:searchInput.addEventListener('input', debouncedHandle)
function handleInput(e) { // .. } // Указываем, что нам нужно ждать 250 мс, // прежде чем запустить обработчик: const debouncedHandle = debounce(handleInput, 250) // Передаём новую debounced-функцию в addEventListener: searchInput.addEventListener('input', debouncedHandle)
И теперь, если мы быстро напишем несколько символов, мы отправим лишь один запрос:
Вместо пяти запросов теперь отправляем всего один!
Обратите внимание, что API функции не поменялось. Мы как передавали event
, так и передаём. То есть для внешнего мира debounced-функция ведёт себя точно так же, как и простая функция-обработчик.
Это удобно, потому что меняется лишь одна небольшая часть программы, не затрагивая системы в целом.
Результат
СкопированоПолный пример строки поиска у нас получится такой:
На практике
Скопированосоветует Скопировано
Используйте debounce
, чтобы оптимизировать операции, которые можно выполнить единожды в конце.
Например, для формы поиска это подойдёт. Однако для отслеживания движения мыши — нет, потому что будет странно ждать, пока пользователь остановит курсор.
Для таких задач, которые можно выполнять раз в какое-то количество времени, лучше подходит throttle
.