Обычно поиск по сайту делает сервер: запрос уходит в базу, ответ приходит обратно. Для сайта в полсотни страниц это лишнее. Проще собрать все заголовки и описания в один файл и искать по нему прямо в браузере — быстро и без задержек.
Как это устроено
Поиск состоит из трёх частей, и самая важная — первая.
| Часть | Что делает |
|---|---|
| Индекс | Файл со списком всех страниц: заголовок, адрес, раздел, описание, ключевые слова |
| Окно поиска | Поле ввода и список найденного, открывается из шапки |
| Скрипт | Загружает индекс при первом открытии окна и отбирает подходящие строки |
Границы этого способа
Индекс целиком грузится в браузер, поэтому способ хорош до нескольких сотен страниц. На сайте в тысячи страниц файл станет тяжёлым, и поиск придётся переносить на сервер.
Индекс: строка на страницу
Индекс — обычный файл JSON. В нём не весь текст страниц, а то, по чему ищут: заголовок, короткое описание и несколько слов, которых в заголовке нет, но которые люди вводят.
[
{
"title": "Хлебные крошки",
"url": "/manualy/hlebnye-kroshki/",
"section": "Мануалы",
"desc": "Путь по сайту списком ссылок и микроразметка BreadcrumbList",
"keywords": "крошки навигация путь breadcrumbs"
}
]
Писать этот файл руками нельзя: он разойдётся с сайтом
на третьей же новой странице. Его собирает скрипт из того же
списка страниц, из которого берутся меню и крошки, — у меня
это docs/pages.json. Одно место правды, а всё
остальное считается из него.
Отбор: с чем сравнивать введённое
Простое сравнение подстрок даёт плохие ответы. Три поправки превращают его в приличный поиск.
- Привести к одному виду. И запрос, и строки индекса перевести в нижний регистр, а «ё» заменить на «е»: иначе «крошки» и «Крошки» — разные слова.
- Искать по всем словам запроса. Запрос разбивается по пробелам, и страница подходит, только если в ней нашлось каждое слово. Иначе по запросу «хлебные крошки» вылезут все страницы, где есть слово «крошки».
- Считать вес. Совпадение в заголовке важнее совпадения в описании: дайте заголовку три очка, ключевым словам два, описанию одно — и отсортируйте по сумме.
const norm = (s) => s.toLowerCase().replace(/ё/g, 'е');
function find(query, index) {
const words = norm(query).split(/\s+/).filter(Boolean);
if (!words.length) return [];
return index
.map((page) => {
let score = 0;
for (const word of words) {
if (norm(page.title).includes(word)) score += 3;
else if (norm(page.keywords).includes(word)) score += 2;
else if (norm(page.desc).includes(word)) score += 1;
else return null; // нет хотя бы одного слова — не подходит
}
return { page, score };
})
.filter(Boolean)
.sort((a, b) => b.score - a.score);
}
Окно: клавиатура решает всё
Поиском пользуются с клавиатуры, и это задаёт список требований. Окно открывается кнопкой в шапке и закрывается по Esc, фокус при открытии сразу в поле ввода, стрелки вверх и вниз ходят по найденному, Enter открывает выбранное. После закрытия фокус возвращается на кнопку, с которой всё началось.
Список результатов обновляется при вводе, а значит, его
изменения нужно объявлять: блоку со списком ставится
role="status" или рядом кладётся строка
«Найдено 4 страницы» — иначе человек, который не видит
экран, не узнает, что ответ уже готов.
Индекс грузить не сразу
Файл нужен только тому, кто открыл поиск. Загружайте его
при первом открытии окна и запоминайте — тогда он
не утяжеляет каждую страницу сайта. И само окно
открывайте тегом dialog: Esc, затемнение
и удержание фокуса внутри достанутся даром.
Что попросить у нейросети
Напиши поиск по статическому сайту на чистом JavaScript.
Есть файл /search-index.json: массив
объектов с полями title, url,
section, desc,
keywords. Библиотек и сборщика нет.
Окно поиска — тег dialog, открывается кнопкой
в шапке. Индекс загружать при первом открытии и держать
в памяти. Запрос разбивать по пробелам, страница подходит,
если найдено каждое слово; совпадение в заголовке весит
больше, чем в описании; регистр и буква «ё» значения
не имеют.
С клавиатуры: фокус сразу в поле, стрелки ходят по результатам, Enter открывает выбранный, Esc закрывает окно. Число найденного объявлять программам чтения экрана. Совпавшие куски подсвечивать, но без вставки разметки из данных — только безопасным способом. Комментарии по-русски.
Про подсветку. Требование верное
и важное. Собирать строку с тегами и класть её
в innerHTML опасно: текст из файла попадёт
в разметку как есть. Безопасный путь — резать строку
на куски и добавлять их через
createTextNode, а совпадения оборачивать
в созданный элемент mark.
Про частоту отбора. На полусотне страниц отбор занимает доли миллисекунды, откладывать его не нужно. Задержка после ввода понадобится позже, если индекс вырастет в разы.
Про стрелки и поле ввода. Не уводите
фокус из поля на элементы списка: человек перестанет
набирать. Правильнее оставить фокус в поле и отмечать
текущий результат атрибутом
aria-activedescendant.
Про пустой ответ. Покажите строку «Ничего не нашлось» и рядом ссылку на карту сайта. Пустое окно человек читает как поломку поиска.
Поиск после переезда на CMS
На этом сайте файла в корне больше нет, а адрес остался
прежним. /search-index.json — обычный ресурс
дерева: шаблона у него нет, тип содержимого —
application/json, а в поле «Содержимое» стоит
один вызов сниппета. Сниппет обходит дерево и отдаёт тот же
JSON, что раньше лежал файлом.
Пересобирать больше нечего: страницу завели в админке — она
в поиске с первого же открытия окна. Тем же приёмом сделаны
robots.txt, sitemap.xml
и llms.txt: пустой ресурс, свой тип содержимого,
сниппет внутри.
Второй путь — искать прямо по базе готовым сниппетом. Он найдёт и то, что написано в середине текста, а не только в заголовках, но отвечает перезагрузкой страницы и запросом к базе на каждый поиск. Для справочника из полусотни страниц мгновенный ответ важнее полноты, поэтому индекс остался.
Частые ошибки
| Ошибка | Чем плохо | Как правильно |
|---|---|---|
| Индекс написан руками | Расходится с сайтом на третьей новой странице | Сборка из того же списка, что меню и крошки |
| Индекс грузится на каждой странице | Лишние килобайты тем, кто поиском не пользуется | Загрузка при первом открытии окна |
Результаты вставляются через innerHTML |
Содержимое файла попадает в разметку как код | Текстовые узлы и созданный mark |
| Поиск только мышью | Основной способ пользоваться поиском не работает | Стрелки, Enter, Esc и фокус в поле при открытии |
| Пустой экран, когда ничего не нашлось | Читается как поломка | Сообщение и ссылка на карту сайта |
Что должно получиться
- Окно открывается из шапки, фокус сразу в поле ввода
- Результаты обновляются при вводе, без перезагрузки страницы
- Запрос из двух слов находит страницу, где есть оба
- Стрелки ходят по найденному, Enter открывает, Esc закрывает окно
- Индекс собирается скриптом и не расходится с сайтом