Девушка сидит за столом, на столе стоит цветок, на столе стоят часы, на на заднем фоне висят электронные часы, и большие аналоговые часы
Иллюстрация: Кира Кустова

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

Как не положить свой сервер большим потоком запросов.

Время чтения: 11 мин

Кратко

Скопировано

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

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

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

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

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

Разметка

Скопировано

Начнём с разметки формы. У нас будет сама форма #search и список ссылок, данные для которых мы будем получать в ответ. У формы есть атрибут action, который будет работать, если пользователи отключили скрипты. Для поля используем <input> с типом search, чтобы браузеры делали дополнительную магию с автозаполнением и подходящими кнопками на телефонных клавиатурах. Кнопкам необязательно проставлять тип, так как submit — тип по умолчанию.

        
          
          <form action="/some-route" method="GET" id="search">  <label>Найди любимую пиццу:</label>  <input type="search" name="query" placeholder="Маргарита">  <button>Искать!</button></form><ul class="search-results"></ul>
          <form action="/some-route" method="GET" id="search">
  <label>Найди любимую пиццу:</label>

  <input type="search" name="query" placeholder="Маргарита">

  <button>Искать!</button>
</form>

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

        
        
          
        
      

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

Свёрстанная форма

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

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

Скопировано

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

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

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

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

        
          
          // По этому массиву мы будем искать названия,// которые содержат пользовательский запросconst pizzaList = [  'Маргарита',  'Пепперони',  'Гавайская',  '4 Сыра',  'Диабло',  'Сицилийская']
          // По этому массиву мы будем искать названия,
// которые содержат пользовательский запрос
const pizzaList = [
  'Маргарита',
  'Пепперони',
  'Гавайская',
  '4 Сыра',
  'Диабло',
  'Сицилийская'
]

        
        
          
        
      

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

В функции contains() будем проверять, содержится ли пользовательский запрос в каком-либо из названий Мок-объект сервера будет содержать метод .search(), который возвращает промис. Таким образом, мы будем эмулировать «асинхронность», как будто мы «сходили на сервер, он подумал и ответил». Таймаут нужен исключительно для того, чтобы иметь возможность настраивать время задержки. В виде ответа отправим объект с отфильтрованным массивом в качестве значения поля list.

        
          
          function contains(query) {  return pizzaList.filter((title) =>    title.toLowerCase().includes(query.toLowerCase())  )}const server = {  search(query) {    return new Promise((resolve) => {      setTimeout(        () =>          resolve({            list: query ? contains(query) : [],          }),        150      )    })  },}
          function contains(query) {
  return pizzaList.filter((title) =>
    title.toLowerCase().includes(query.toLowerCase())
  )
}

const server = {
  search(query) {
    return new Promise((resolve) => {
      setTimeout(
        () =>
          resolve({
            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() — это функция высшего порядка, принимающая аргументом функцию, которую надо «отложить».

Поехали. Аргументами будут функция, которую надо «откладывать», и интервал времени, спустя который следует вызывать функцию. Как результат возвращаем другую функцию. Это нужно, чтобы мы могли не менять другие части кода. Чуть позже увидим, как это помогает. В переменной previousCall мы храним временную метку предыдущего вызова, а в переменной текущего вызова — временную метку нынешнего момента. Это нужно, чтобы потом сравнить, когда функция была вызвана в этот раз и в предыдущий. Если разница между вызовами меньше, чем указанный интервал, то мы очищаем таймаут, который отвечает за непосредственно вызов функции-аргумента. Обратите внимание, что мы передаём все аргументы ...args, которые получаем в функции perform(). Это тоже нужно, чтобы не приходилось менять другие части кода. Если таймаут был очищен, вызова не произойдёт. Если он не был очищен, то вызовется callee. Таким образом, мы как бы «отодвигаем» вызов callee до тех пор, пока «снаружи всё не подуспокоится».

        
          
          function debounce(callee, timeoutMs) {  return function perform(...args) {    let previousCall = this.lastCall    this.lastCall = Date.now()    if (previousCall && this.lastCall - previousCall <= timeoutMs) {      clearTimeout(this.lastCallTimer)    }    this.lastCallTimer = setTimeout(() => callee(...args), timeoutMs)  }}
          function debounce(callee, timeoutMs) {
  return function perform(...args) {
    let previousCall = this.lastCall

    this.lastCall = Date.now()

    if (previousCall && this.lastCall - previousCall <= timeoutMs) {
      clearTimeout(this.lastCallTimer)
    }

    this.lastCallTimer = setTimeout(() => callee(...args), timeoutMs)
  }
}

        
        
          
        
      

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

        
          
          // Функция, которую хотим «откладывать»function doSomething(arg) {  // …}doSomething(42)// А вот та же функция, но обёрнутая в debounce()const debouncedDoSomething = debounce(doSomething, 250)debouncedDoSomething(42)
          // Функция, которую хотим «откладывать»
function doSomething(arg) {
  // …
}

doSomething(42)

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

debouncedDoSomething(42)

        
        
          
        
      

debouncedDoSomething() — это именно функция, потому что из debounce() мы возвращаем функцию. debouncedDoSomething() принимает те же аргументы, что и doSomething(), потому что perform внутри debounce() прокидывает все аргументы без изменения в doSomething(). Так что и вызов debouncedDoSomething() будет таким же, как и вызов doSomething().

Применяем 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)
          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. Укажем, что нам нужно ждать 250 мс, прежде чем запустить обработчик. Дальше передаём новую debounced-функцию в addEventListener.

        
          
          function handleInput(e) {  // …}const debouncedHandle = debounce(handleInput, 250)searchInput.addEventListener('input', debouncedHandle)
          function handleInput(e) {
  // …
}

const debouncedHandle = debounce(handleInput, 250)

searchInput.addEventListener('input', debouncedHandle)

        
        
          
        
      

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

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

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

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

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

Результат

Скопировано

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

Открыть демо в новой вкладке

На практике

Скопировано

Саша Беспоясов советует

Скопировано

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

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

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