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

Устранение ситуаций конкуренции: демпфирование не может решить проблему в интерфейсах поиска

Узнайте, почему одного лишь снижения количества импульсов недостаточно для предотвращения того, чтобы устаревшие ответы API перезаписывали свежее состояние интерфейса, и ознакомьтесь с четырьмя практическими решениями для обеспечения порядка выполнения запросов.

Издатель: REACTAPP.TOP10 мин чтения2397 слов

Источники: I Thought Debouncing Fixed My Search Bar.

В прошлом году я добавил задержку в 300 миллисекунд для снижения частоты запросов к полю поиска в одном проекте и считал проблему решенной.

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

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

Однако баг действительно существовал. Просто это был не тот баг, который мы исправляли.

Любой, кто создавал поле поиска, вероятно, сталкивался с подобной проблемой. Каждая нажатие клавиши запускает сетевой запрос, из-за чего сервер перегружается.

Обычный совет — «просто добавьте задержку». Вы вводите небольшую паузу, объем запросов снижается, и проблема решается.

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

Вот что функция дебаунсинга на самом деле не гарантирует: она контролирует когда отправляется запрос. Она ничего не говорит о том, какой ответ придет первым.

Что на самом деле исправляет функция дебаунсинга для полей поиска

Функция дебаунсинга для поля поиска решает именно одну проблему — чрезмерное количество отправляемых запросов.

Это вся сфера ее действия. Порядок обработки запросов не меняется.

Вот как она может сработать неправильно. Предположим, что время дебаунсинга составляет 300 мс.

  1. Вы вводите rea, затем прекращаете ввод на 300 мс. Отправляется запрос A для rea.
  2. Вы продолжаете вводить: react. После еще одной паузы отправляется запрос B для react.
  • Запрос A не находится в кэше сервера, поэтому его обработка занимает 900 мс.
  • Запрос B находится в кэше, и ответ приходит за 120 мс.
  • B возвращается первым, поэтому интерфейс отображает результаты для react.
  • Затем, спустя 700 мс, приходит A, который заменяет содержимое экрана результатами для rea.
  • В поле ввода указано react, но в списке ниже отображаются результаты для rea. Именно это несоответствие и привело к мерцанию, замеченному тестировщиком.

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

    Неясно, проявляется ли это явление одинаково во всех комбинациях фреймворков и инструментов.

    Однако в проектах на React, использующих fetch или axios, этот паттерн постоянно встречается, каждый раз.

    Это не просто проблема поисковой строки

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

    • Ползунки фильтрации. Пользователь выбирает «Обувь», а затем быстро переключается на «Сумки». Если запрос к разделу обуви выполнится последним, на странице будут отображаться списки обуви под заголовком «Сумки».
    • Вкладки. Кто-то нажимает на «Заказы», а затем сразу же — на «Счёты-фактуры». Если ответ от раздела заказов придёт медленнее, он всё равно «победит», и неправильные данные окажутся под правильной вкладкой.
  • Пагинация. Быстрое несколько раз подряд нажатие кнопки «Далее» может привести к тому, что ответ за страницу 2 придет позже, чем ответ за страницу 4.
  • Инструменты выбора дат на панелях управления. Быстрое дважды изменение диапазона дат может привести к тому, что график будет отображать данные за замененный диапазон.
  • Каждый раз, когда пользователь может изменить свои намерения быстрее, чем сервер может отреагировать, возникает та же проблема соревнования.

    В реальных приложениях разобраться в этом становится еще сложнее.

    Представьте панель управления администратора, где кто-то выбирает:

    • Последние 30 дней
    • Затем последние 7 дней
    • Затем сегодняшний день

    В этом случае одновременно могут отправляться три отдельных запроса.

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

    Почему увеличение задержки не помогает

    Первым побуждением обычно является увеличение времени дебаунсинга — попробовать 500 мс, а затем, возможно, 800 мс.

    Такая настройка, основанная скорее на привычке, чем на реальных размышлениях, никак не снижала частоту возникновения мерцания. Она лишь делала интерфейс более медленным в работе. Запрос, который не находится в кэше и обрабатывается за 900 мс, всё равно будет выполняться дольше, чем за счёт времени дебаунсинга в 800 мс.

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

    Именно в этот момент становится ясна истинная природа ошибки.

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

    Ситуация с гонкой условий — это сбой в порядке выполнения операций.

    Увеличение времени, пока светофор остаётся красным, не определяет, какая машина доберётся до пункта назначения первой.

    Чтобы решить эту проблему, требуется совершенно другое правило.

    Правильное решение: только самый последний запрос имеет право обновлять интерфейс

    Это одно предложение — вся суть решения. Существует несколько способов его реализации, от простых до полноценных.

    Решение 1: Присваивать каждому запросу номер тикета

    Это самая простая версия, которую стоит объяснить в первую очередь, поскольку она делает основные механизмы очевидными.

    import { useState, useRef } from "react";
    
    function useSearch() {
      const [results, setResults] = useState([]);
      const latestRequestId = useRef(0);
    
      async function search(query) {
        const myId = ++latestRequestId.current;
    
        const res = await fetch(`/search?q=${encodeURIComponent(query)}`);
        const data = await res.json();
    
        // If a newer request started while we were waiting, throw this away.
        if (myId === latestRequestId.current) {
          setResults(data.results);
        }
      }
    
      return { results, search };
    }
    

    Каждому отправляемому запросу присваивается номер тикета.

    При возврате ответа он проверяет: «Всё ещё у меня самый свежий тикет?»

    Если ответ отрицательный, ответ игнорируется. Сами данные не ошибочны — они просто устарели. А устаревшие данные выглядят точно так же, как неверные, после отображения на экране.

    Решение 2: Отмена старого запроса с помощью AbortController

    Проверка номеров тикетов просто игнорирует устаревшие ответы после их получения. AbortController предотвращает их поступление с самого начала. Вот как это выглядит на чистом JavaScript, без использования каких-либо фреймворков:

    let controller = null;
    
    async function search(query) {
      // Kill whatever is still in flight.
      controller?.abort();
      controller = new AbortController();
    
      try {
        const res = await fetch(`/search?q=${encodeURIComponent(query)}`, {
          signal: controller.signal,
        });
        const data = await res.json();
        renderResults(data.results);
      } catch (err) {
        // An aborted fetch throws. That's expected, not an error.
        if (err.name === "AbortError") return;
        showError(err);
      }
    }
    

    Один момент, который легко упустить: вызов fetch, прерванный, отклоняется с ошибкой AbortError. Если игнорировать обработку этой ошибки, вы будете записывать ложные сообщения об ошибках при каждом нажатии клавиши.

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

    Решение 3: способ React — очистка с помощью useEffect

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

    import { useState, useEffect } from "react";
    
    function useDebouncedValue(value, delay = 300) {
      const [debounced, setDebounced] = useState(value);
    
      useEffect(() => {
        const id = setTimeout(() => setDebounced(value), delay);
        return () => clearTimeout(id);
      }, [value, delay]);
    
      return debounced;
    }
    
    function SearchBox() {
      const [query, setQuery] = useState("");
      const [results, setResults] = useState([]);
      const debouncedQuery = useDebouncedValue(query, 300);
    
      useEffect(() => {
        const q = debouncedQuery.trim();
        if (!q) {
          setResults([]);
          return;
        }
    
        let ignore = false;
        const controller = new AbortController();
    
        fetch(`/search?q=${encodeURIComponent(q)}`, { signal: controller.signal })
          .then((r) => r.json())
          .then((data) => {
            if (!ignore) setResults(data.results);
          })
          .catch((err) => {
            if (err.name !== "AbortError") console.error(err);
          });
    
        // Runs when debouncedQuery changes again, or the component unmounts.
        return () => {
          ignore = true;
          controller.abort();
        };
      }, [debouncedQuery]);
    
      return (
        <>
          <input value={query} onChange={(e) => setQuery(e.target.value)} />
          <ul>
            {results.map((r) => (
              <li key={r.id}>{r.name}</li>
            ))}
          </ul>
        </>
      );
    }
    

    Обратите внимание, что этот паттерн автоматически обеспечивает. Откладывается выполнение самого значения, а не функции, которая отправляет запрос.

    Это означает, что каждый раз, когда изменяется значение с отложенным выполнением, сначала запускается процедура очистки предыдущей операции, что отменяет устаревший запрос. Отключение компонента также запускает аналогичную процедуру очистки. Для работы этого механизма не требуются refs или вручную увеличиваемые счетчики.

    Решение 4: Позвольте библиотеке для загрузки данных с этим разобраться

    Если ваш проект уже использует TanStack Query (React Query), это гарантированное порядок выполнения предоставляется бесплатно — никакой дополнительной логики не требуется.

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

    import { useQuery, keepPreviousData } from "@tanstack/react-query";
    
    function useSearch(debouncedQuery) {
      return useQuery({
        queryKey: ["search", debouncedQuery],
        queryFn: ({ signal }) =>
          fetch(`/search?q=${encodeURIComponent(debouncedQuery)}`, { signal })
            .then((r) => r.json()),
        enabled: debouncedQuery.trim().length > 0,
        placeholderData: keepPreviousData, // v5 replacement for keepPreviousData: true
      });
    }
    

    Фактическая обработка происходит в двух местах: signal, передаваемый в queryFn, и опция placeholderData: keepPreviousData, которая позволяет оставлять предыдущий список видимым вместо того, чтобы экран оставался пустым во время загрузки следующей порции результатов.

    SWR обеспечивает аналогичное поведение с помощью своей собственной настройки keepPreviousData.

    Небольшие привычки, которые значительно упростили правильную реализацию

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

    1. Всегда кодируйте строку запроса. Отдельный пробел или символ & внутри условия поиска могут нарушить структуру URL. Каждый раз при формировании запроса оборачивайте значение в encodeURIComponent(query). Поисковая форма в производственных условиях может действительно сломаться из-за такого простого ввода, как «R&D».

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

    3. Продолжайте отображать предыдущие результаты во время загрузки нового запроса. Избегайте очистки списка при каждом нажатии клавиши. Небольшой индикатор загрузки рядом с полем ввода гораздо менее раздражает, чем пустой экран, мигающий между поисками — даже устаревший список лучше, чем его отсутствие. Именно такое поведение обеспечивает параметр keepPreviousData.

    4. Храните в кэше уже полученные результаты. Люди часто удаляют символы и вводят их заново. Простой объект Map, ключом к которому является термин поиска, гарантирует, что при вводе сначала rea, затем react, а потом возврата назад к rea сервер не будет обращаться дважды за один и тот же запрос.

    const cache = new Map();
    
    async function searchWithCache(query, signal) {
      if (cache.has(query)) return cache.get(query);
      const res = await fetch(`/search?q=${encodeURIComponent(query)}`, { signal });
      const data = await res.json();
      cache.set(query, data);
      return data;
    }
    

    5. Пусть бэкенд возвращает запрос вместе с ответом. Вместо простого массива возвращайте что-то вроде { query: "react", results: [...] }. Это позволяет клиенту выполнить ещё одну проверку корректности — if (data.query !== currentQuery) return; — которая служит дешёвым дополнительным защитным механизмом и сразу делает любую ошибку видимой в вкладке сети.

    6. Умышленно воссоздайте условия гонки. Вряд ли вы заметите эту ошибку на локальном хосте при работе с API, отвечающим за пять миллисекунд. Внесите искусственную случайную задержку в ваш сервер-мок, и проблема проявится практически сразу:

    app.get("/search", async (req, res) => {
      await new Promise((r) => setTimeout(r, Math.random() * 2000));
      res.json({ query: req.query.q, results: fakeResults(req.query.q) });
    });
    

    Альтернативно откройте инструменты разработчика Chrome, перейдите в раздел «Сеть», включите режим ограничения скорости, выберите «Медленный 3G» и быстро введите текст. Вы увидите, что всё будет работать некорректно.

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

    8. Всегда аннулируйте операцию при демонтировании компонента. Если пользователь уходит в другую часть интерфейса во время выполнения поиска, последующий ответ попытается вызвать метод setState у компонента, который уже не существует. Функция очистки, показанная в Исправлении 3, уже учитывает этот случай. Если вы используете подход с номером тикета, убедитесь, что функция очистки при демонтировании также увеличивает счетчик.

    9. Используйте useDeferredValue при фильтрации локально, а не через сеть. Если вы фильтруете список, который уже загружен в память, вам совсем не нужны механизмы дебаунсинга или отмены запросов. useDeferredValue обеспечивает плавную работу при вводе, пока React выполняет операции фильтрации на фоне — однако он не заменяет логику отмены запросов, когда задействуется сервер.

    10. Напишите один тест, направленный непосредственно на выявление ситуаций гонки. Симулируйте два вызова функции fetch и обработайте второй из них раньше первого. Проверьте, что интерфейс отобразит результат второго вызова. Это короткий тест, но он позволит обнаружить регрессию, которая в противном случае могла бы остаться незамеченной во время проверки кода.

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

    Применение механизма дебаунсинга к вводу данных в поиске по-прежнему является хорошей практикой — это значительно сокращает количество отправляемых запросов. Однако он сам по себе не решает всей проблемы.

    Вам также необходимо четкое правило порядка обработки: только самый свежий запрос должен обновлять интерфейс. Это можно реализовать с помощью идентификатора запроса, объекта AbortController, функции очистки useEffect или библиотеки, уже реализующей эту схему. Любой из этих подходов подходит — главное — выбрать один и последовательно им пользоваться.