Реалізація пошуку з автодоповненням для веб-застосунку

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.

Розробка та обслуговування будь-яких видів сайтів:

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

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Реалізація пошуку з автодоповненням для веб-застосунку
Середня
~2-3 робочих дні
Часті питання

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

Етапи розробки

Останні роботи

  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

Реалізація автодоповнення пошуку для веб-програм

Автодоповнення — поле пошуку яке показує підказки по мірі введення. Технічно це перетин кількох завдань: швидкий fuzzy-пошук по індексу, debounce щоб не перевантажити сервер, правильна робота з клавіатурою (ARIA combobox), скасування застарілих запитів. Кожне розв'язується окремо; разом вони становлять повнофункціональний компонент.

Архітектура пошуку

Client-side — дані завантажуються разом, пошук у браузері. Підходить до ~50 000 записів. Бібліотеки: Fuse.js (fuzzy), MiniSearch (fulltext). Без затримки після завантаження.

Server-side — кожне введення — запит до сервера. Потрібен debounce та скасування запитів. Підходить для великих даних. Elasticsearch suggest API, Typesense, PostgreSQL trgm.

Гібридний — кешуємо популярні запити в пам'яті, рідкі йдуть на сервер.

Базовий хук з 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 };
}

Компонент з 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)}
            >
              <HighlightMatch text={result.title} query={query} />
              {result.category && (
                <span className="autocomplete__category">{result.category}</span>
              )}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Підсвітлення збігів

function HighlightMatch({ text, query }: { text: string; query: string }) {
  if (!query.trim()) return <span>{text}</span>;

  const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  const parts = text.split(new RegExp(`(${escaped})`, 'gi'));

  return (
    <span>
      {parts.map((part, i) =>
        part.toLowerCase() === query.toLowerCase()
          ? <mark key={i}>{part}</mark>
          : <span key={i}>{part}</span>
      )}
    </span>
  );
}

Серверна частина: Elasticsearch suggest

// 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',  // поле типу completion
            size: 10,
            fuzzy: { fuzziness: 'AUTO' },
          },
        },
      },
      // Додатково — fulltext пошук для більш релевантних результатів
      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" }
        }
      }
    }
  }
}

PostgreSQL з pg_trgm

Для проектів без Elasticsearch:

-- Розширення для триграмного пошуку
CREATE EXTENSION IF NOT EXISTS pg_trgm;

-- Індекс
CREATE INDEX products_title_trgm_idx ON products
  USING gin (title gin_trgm_ops);

-- Запит з підказками
SELECT id, title, category, similarity(title, $1) AS score
FROM products
WHERE title % $1           -- триграмний збіг
   OR title ILIKE $1 || '%' -- starts-with (швидше для точних збігів)
ORDER BY
  title ILIKE $1 || '%' DESC,  -- starts-with пріоритет
  score DESC
LIMIT 10;

Кешування на стороні клієнта

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 combobox, keyboard navigation, підсвітлення, client cache): 2–3 дні
  • З серверним індексом Elasticsearch (completion mapping, налаштування індексу, fuzzy): ще 1–2 дні