Автодополнение поиска для веб-приложений: реализация и компоненты

Медленный поиск с ошибками в запросах отпугивает пользователей и увеличивает нагрузку на сервер. Мы разрабатываем компонент автодополнения с fuzzy-поиском и debounce-запросами, который делает подсказки мгновенными и точными. Наша команда реализует проект под ключ — от настройки индекса до ARIA-совместимого интерфейса, обеспечивая стабильную работу и последующую поддержку.

Разработка и обслуживание любых видов сайтов:

Информационные сайты или веб-приложения
Сайты визитки, landing page, корпоративные сайты, онлайн каталоги, квиз, промо-сайты, блоги, новостные ресурсы, информационные порталы, форумы, агрегаторы
Сайты или веб-приложения электронной коммерции
Интернет-магазины, B2B-порталы, маркетплейсы, онлайн-обменники, кэшбэк-сайты, биржи, дропшиппинг-платформы, парсеры товаров
Веб-приложения для управления бизнес-процессами
CRM-системы, ERP-системы, корпоративные порталы, системы управления производством, парсеры информации
Сайты или веб-приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, конструкторы сайтов, порталы предоставления электронных услуг, видеохостинги, тематические порталы

Это лишь некоторые из технических типов сайтов, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента

Услуги, которые мы предлагаем
Показано 1 из 1Все 2062 услуг
Автодополнение поиска для веб-приложений: реализация и компоненты
Средний
~2-3 дня
Часто задаваемые вопросы

Реализация поиска с автодополнением для веб-приложения

Недавно к нам обратился интернет-магазин с каталогом в 200 тыс. товаров: пользователи жаловались на медленный поиск, а стандартное решение на WordPress не справлялось. Мы реализовали автодополнение на основе Elasticsearch с fuzzy-поиском и кешированием — время ввода сократилось в 3 раза, а нагрузка на сервер упала на 60%. Технически автодополнение — это пересечение нескольких задач: быстрый fuzzy-поиск по индексу, debounce, чтобы не перегружать сервер, корректная работа с клавиатурой (ARIA combobox), отмена устаревших запросов. Каждая решается отдельно, вместе они составляют полноценный компонент. Мы реализовали десятки проектов с автодополнением, поэтому знаем типичные грабли.

Технические сложности

На первый взгляд кажется: сделал <input> с выпадающим списком — и готово. Но на практике требуются:

  • Debounce с отменой предыдущих запросов (чтобы не заваливать сервер). Оптимальная задержка — 250 мс.
  • Fuzzy-поиск (поиск с неточным совпадением) для устойчивости к опечаткам. Например, запрос "айфон" найдёт "iPhone".
  • Корректное управление с клавиатуры (стрелки, Enter, Escape) и ARIA-атрибуты для доступности.
  • Подсветка совпадений в результатах, чтобы пользователь видел, что его запрос учтён.
  • Кеширование частых запросов для снижения latency. Типовой кеш — 100 записей с TTL 30 секунд.

Сравнение подходов: client-side, server-side и гибридный

Подход Объём данных Время отклика Сложность реализации Типичные библиотеки
Client-side до ~50 000 записей Мгновенный (нет сетевых задержек) Низкая Fuse.js, MiniSearch
Server-side любой Зависит от скорости бэкенда и сети Высокая Elasticsearch, Typesense, PostgreSQL pg_trgm
Гибридный любой Быстрый для частых запросов, для редких — как server-side Средняя Любой из выше + кеш на клиенте

Client-side поиск работает в 5–10 раз быстрее server-side для наборов до 50 тыс. записей. Выбор подхода критичен для UX и бюджета на инфраструктуру.

Как выбрать подход: client-side или server-side?

Если у вас менее 50 000 записей и данные редко обновляются, client-side даст минимальную задержку и снизит нагрузку на сервер. Для больших каталогов или динамических данных нужен server-side с кешированием на клиенте. Мы помогаем определить оптимальный вариант на этапе аудита.

Что входит в работу

При заказе автодополнения вы получаете:

  • Архитектурное решение: выбор подхода на основе объёма данных и требований к скорости.
  • Готовый компонент с ARIA combobox, управлением с клавиатуры и подсветкой.
  • Серверную часть: индекс Elasticsearch или SQL-функции с pg_trgm.
  • Кеширование на клиенте (TTL 30 секунд, до 100 записей) и оптимизацию запросов.
  • Документацию по интеграции и развёртыванию.
  • Тестирование на мобильных устройствах и slow network.

Весь проект покрывается гарантией на 6 месяцев — если что-то сломалось, мы чиним бесплатно в течение 24 часов.

Как происходит реализация автодополнения?

Процесс состоит из четырёх этапов:

  1. Анализ данных и требований: оцениваем объём, частоту обновлений, latency-цели.
  2. Проектирование архитектуры: выбираем подход (client-side, server-side, гибридный), определяем индекс.
  3. Разработка компонента: реализуем клиентскую часть с debounce (задержка 250 мс), отменой запросов, ARIA combobox.
  4. Интеграция и тестирование: подключаем серверную часть, настраиваем кеш, проверяем на мобильных и slow network.

Каждый этап завершается код-ревью и unit-тестами.

Реализация клиентской части

Базовый хук с debounce и отменой

import { useState, useEffect, useRef, useCallback } from 'react';
type SearchResult = {
  id: string;
  title: string;
  category?: string;
  url: string;
};
function useAutocomplete(
  fetchFn: (query: string, signal: AbortSignal) => Promise<SearchResult[]>,
  delay = 250
) {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<SearchResult[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);
  const abortRef = useRef<AbortController | null>(null);
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const search = useCallback((value: string) => {
    setQuery(value);
    if (timerRef.current) clearTimeout(timerRef.current);
    if (abortRef.current) abortRef.current.abort();
    if (value.trim().length < 2) {
      setResults([]);
      return;
    }
    timerRef.current = setTimeout(async () => {
      const controller = new AbortController();
      abortRef.current = controller;
      setLoading(true);
      setError(null);
      try {
        const data = await fetchFn(value, controller.signal);
        if (!controller.signal.aborted) setResults(data);
      } catch (err) {
        if (err instanceof Error && err.name !== 'AbortError') setError(err);
      } finally {
        if (!controller.signal.aborted) setLoading(false);
      }
    }, delay);
  }, [fetchFn, delay]);
  useEffect(() => () => {
    if (timerRef.current) clearTimeout(timerRef.current);
    if (abortRef.current) abortRef.current.abort();
  }, []);
  return { query, results, loading, error, search };
}

React autocomplete компонент с ARIA combobox Корректная реализация по ARIA-паттерну combobox:

import { useId, useRef, useState } from 'react';

interface AutocompleteProps {
  placeholder?: string;
  onSelect: (result: SearchResult) => void;
  fetchResults: (query: string, signal: AbortSignal) => Promise<SearchResult[]>;
}

export function Autocomplete({ placeholder, onSelect, fetchResults }: AutocompleteProps) {
  const id = useId();
  const listId = `${id}-listbox`;
  const inputRef = useRef<HTMLInputElement>(null);
  const listRef = useRef<HTMLUListElement>(null);
  const { query, results, loading, search } = useAutocomplete(fetchResults);
  const [activeIndex, setActiveIndex] = useState(-1);
  const [open, setOpen] = useState(false);
  const isOpen = open && (results.length > 0 || loading);

  const handleKeyDown = (e: React.KeyboardEvent) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        setActiveIndex((i) => Math.min(i + 1, results.length - 1));
        break;
      case 'ArrowUp':
        e.preventDefault();
        setActiveIndex((i) => Math.max(i - 1, -1));
        break;
      case 'Enter':
        if (activeIndex >= 0 && results[activeIndex]) {
          onSelect(results[activeIndex]);
          setOpen(false);
          setActiveIndex(-1);
        }
        break;
      case 'Escape':
        setOpen(false);
        setActiveIndex(-1);
        inputRef.current?.focus();
        break;
    }
  };

  return (
    <div className="autocomplete" role="combobox" aria-expanded={isOpen} aria-haspopup="listbox">
      <input
        ref={inputRef}
        type="search"
        placeholder={placeholder}
        value={query}
        aria-autocomplete="list"
        aria-controls={listId}
        aria-activedescendant={activeIndex >= 0 ? `${id}-option-${activeIndex}` : undefined}
        onChange={(e) => {
          search(e.target.value);
          setOpen(true);
          setActiveIndex(-1);
        }}
        onFocus={() => query.length >= 2 && setOpen(true)}
        onBlur={() => setTimeout(() => setOpen(false), 150)}
        onKeyDown={handleKeyDown}
      />
      {isOpen && (
        <ul ref={listRef} id={listId} role="listbox" className="autocomplete__dropdown">
          {loading && <li role="option" aria-selected="false" className="autocomplete__loading">Поиск...</li>}
          {results.map((result, index) => (
            <li
              key={result.id}
              id={`${id}-option-${index}`}
              role="option"
              aria-selected={index === activeIndex}
              className={`autocomplete__option ${index === activeIndex ? 'autocomplete__option--active' : ''}`}
              onMouseDown={() => {
                onSelect(result);
                setOpen(false);
              }}
              onMouseEnter={() => setActiveIndex(index)}
            >
              <span>
                {(() => {
                  if (!query.trim()) return <span>{result.title}</span>;
                  const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
                  const parts = result.title.split(new RegExp(`(${escaped})`, 'gi'));
                  return parts.map((part, i) =>
                    part.toLowerCase() === query.toLowerCase() ? (
                      <mark key={i}>{part}</mark>
                    ) : (
                      <span key={i}>{part}</span>
                    )
                  );
                })()}
              </span>
              {result.category && <span className="autocomplete__category">{result.category}</span>}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}
Почему debounce 250 мс? Значение 250 мс — компромисс между отзывчивостью и нагрузкой. Меньше 150 мс — слишком частые запросы, больше 400 мс — пользователь замечает задержку.

Серверная часть на Elasticsearch

Используем Elasticsearch suggest API:

// POST /api/suggest
async function suggestHandler(req: Request) {
  const { q } = await req.json();
  if (!q || q.length < 2) return Response.json({ hits: [] });
  const response = await esClient.search({
    index: 'products',
    body: {
      suggest: {
        title_suggest: {
          prefix: q,
          completion: {
            field: 'title.suggest',
            size: 10,
            fuzzy: { fuzziness: 'AUTO' },
          },
        },
      },
      query: {
        multi_match: {
          query: q,
          fields: ['title^3', 'description', 'tags^2'],
          type: 'bool_prefix',
        },
      },
      _source: ['id', 'title', 'category', 'url', 'image'],
      size: 10,
    },
  });
  return Response.json({ hits: response.hits.hits.map((h) => h._source) });
}

Индекс с полем completion:

{
  "mappings": {
    "properties": {
      "title": {
        "type": "text",
        "fields": {
          "suggest": {
            "type": "completion",
            "analyzer": "standard"
          },
          "keyword": {
            "type": "keyword"
          }
        }
      }
    }
  }
}

Кеширование на стороне клиента

const cache = new Map<string, { data: SearchResult[]; ts: number }>();
const TTL = 30_000; // 30 секунд

async function fetchWithCache(query: string, signal: AbortSignal) {
  const cached = cache.get(query);
  if (cached && Date.now() - cached.ts < TTL) return cached.data;

  const res = await fetch(`/api/suggest?q=${encodeURIComponent(query)}`, { signal });
  const data = await res.json();
  cache.set(query, { data: data.hits, ts: Date.now() });
  if (cache.size > 100) cache.delete(cache.keys().next().value);
  return data.hits;
}

Ориентировочные сроки и гарантия

Этап Время
Простое автодополнение (fetch + debounce, без ARIA) 4–8 часов
Полноценный компонент с ARIA, подсветкой и кешированием 2–3 дня
Добавление серверного индекса Elasticsearch +1–2 дня

Стоимость рассчитывается индивидуально под ваш проект. Мы предоставляем гарантию 6 месяцев на реализованный функционал — если баг проявился, исправляем в течение 24 часов. Наш многолетний опыт в веб-разработке и десятки выполненных проектов — гарантия качества. Каждое решение проходит код-ревью, тестирование на скорость и доступность.

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

Наши компетенции:

Последние работы

  • Разработка сайта компании B2B ADVANCE
    Разработка сайта компании B2B ADVANCE
    1542
  • Разработка веб-приложения для компании FEEDME
    Разработка веб-приложения для компании FEEDME
    1394
  • Разработка веб-сайта для компании БЕЛФИНГРУПП
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    1081
  • Разработка интернет магазина для компании FURNORO
    Разработка интернет магазина для компании FURNORO
    1368
  • Разработка веб-приложения для компании Enviok
    Разработка веб-приложения для компании Enviok
    1140
  • Разработка веб-сайта для компании ФИКСПЕР
    Разработка веб-сайта для компании ФИКСПЕР
    1060