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

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

Кратко

Секция статьи "Кратко"

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

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 searchInput = searchForm.querySelector('[type="search"]');const searchResults = document.querySelector(".search-results");
          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"

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

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

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

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

Итак, 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 в нашем обработчике. Сперва немного порефакторим:

        
          
          // Вынесем обработчик события в отдельную функцию.// Внутри она будет такой же,// но так нам будет удобнее оборачивать её в 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);

        
        
          
        
      

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

        
          
          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);

        
        
          
        
      

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

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

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

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

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

Результат

Секция статьи "Результат"

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

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

На практике

Секция статьи "На практике"

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

Секция статьи "Саша Беспоясов"

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

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

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