Первый поиск в браузере
У нас есть documents.json: шесть опубликованных документов, их текст и постоянные адреса. Теперь подключим этот файл к странице и дадим посетителю возможность найти материал. Первая реализация будет искать подстроку. Её легко объяснить и использовать как исходную точку для сравнения с индексом.
Работаем в папке search-lab/public. Если вы открываете пример отдельно от сайта, учебные страницы должны обслуживаться по своим путям. В контрольных исходниках курса предусмотрены короткие страницы-заглушки; они обозначают материалы стенда. При интеграции с настоящим сайтом ссылки ведут на его существующие статьи.
Форма и область результатов
Создайте файл index.html. Это полная минимальная страница первого поиска:
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Поиск по учебной библиотеке</title>
</head>
<body>
<main>
<h1>Поиск по учебной библиотеке</h1>
<form id="search-form">
<label for="query">Запрос</label>
<input id="query" name="q" type="search">
<button>Найти</button>
</form>
<p id="status" role="status"></p>
<ul id="results"></ul>
</main>
<script type="module" src="./app.js"></script>
</body>
</html>
Форма работает с кнопкой и клавишей Enter. Поле имеет видимую подпись, а сообщение о результате помещается отдельно от списка. Это позволит позже добавить загрузку, отсутствие совпадений и ошибки без замены всей структуры страницы.
Для начала не добавляем автодополнение. Поиск выполняется по явному действию посетителя. Такой порядок отделяет получение результата от обработки десятков промежуточных строк, возникающих при печати.
Модуль app.js загружается относительно страницы. Внутри него данные тоже запрашиваются относительным адресом. Перенос файла в другую папку потребует проверить базовый путь; абсолютный URL статьи при этом останется прежним.
Загрузка и простой алгоритм
Ниже полный app.js. В нём отдельно представлены получение документов, поиск и отображение:
const form = document.querySelector("#search-form");
const input = document.querySelector("#query");
const status = document.querySelector("#status");
const results = document.querySelector("#results");
let dataPromise;
function normalize(value) {
return String(value).normalize("NFKC").toLocaleLowerCase("ru").replace(/ё/g, "е");
}
function loadDocuments() {
if (!dataPromise) {
dataPromise = fetch("./documents.json")
.then(response => {
if (!response.ok) throw new Error("Не удалось загрузить индекс");
return response.json();
})
.then(payload => {
if (payload.schemaVersion !== 1 || !Array.isArray(payload.documents)) {
throw new Error("Неподдерживаемые данные");
}
return payload.documents;
})
.catch(error => {
dataPromise = undefined;
throw error;
});
}
return dataPromise;
}
function find(documents, query) {
const needle = normalize(query).trim();
if (!needle) return [];
return documents.filter(doc =>
normalize([doc.title, ...doc.headings, doc.text].join(" ")).includes(needle)
);
}
function render(documents) {
results.replaceChildren();
for (const doc of documents) {
const url = new URL(doc.url, location.origin);
if (url.origin !== location.origin || !doc.url.startsWith("/")) continue;
const li = document.createElement("li");
const link = document.createElement("a");
link.href = url.pathname + url.search + url.hash;
link.textContent = doc.title;
li.append(link);
results.append(li);
}
}
form.addEventListener("submit", async event => {
event.preventDefault();
const query = input.value.trim();
results.replaceChildren();
if (!query) {
status.textContent = "Введите слово или название урока";
return;
}
status.textContent = "Загружаем данные…";
try {
const documents = await loadDocuments();
const found = find(documents, query);
render(found);
status.textContent = found.length
? "Найдено материалов: " + found.length
: "Совпадений нет. Попробуйте другую формулировку";
} catch {
status.textContent = "Поиск временно недоступен. Повторите запрос";
}
});
Promise сохраняется, поэтому повторный поиск не скачивает один и тот же файл заново. При ошибке ссылка на Promise сбрасывается: следующая отправка формы может повторить загрузку. Иначе одна временная неудача закрепилась бы до обновления страницы.
fetch не считает любой HTTP-ответ исключением. Поэтому проверяем response.ok до разбора JSON. Ошибка формата данных тоже отличается от пустого поискового результата: если файл сломан, сообщение «совпадений нет» было бы неверным. Это поведение описано в документации Fetch.
Что означает совпадение
Функция find приводит запрос и поля документа к одному регистру, а затем использует includes. Запрос XML должен найти материал xml, несмотря на различие регистра. Запрос Markdown должен найти соответствующий учебный документ. Это ожидаемые результаты для нашего небольшого корпуса.
Поиск подстроки не понимает, какие слова выражают одно понятие. Он не исправит ngnix, если такой последовательности нет в тексте. Фраза LINQ XML тоже может не найти заголовок «Запросы LINQ to XML», потому что между искомыми словами стоит to. Такое ограничение видно благодаря контрольным запросам первого урока.
Склеивание полей здесь позволяет искать в заголовке и тексте одинаковым способом. Но результат не имеет оценки полезности: документы остаются в порядке массива. Если два материала содержат одно слово, первым окажется тот, который экспортёр расположил раньше, а не обязательно более подходящий.
Можно предварительно сохранить нормализованный текст каждой записи. Тогда программа не будет повторять преобразование для каждого запроса. Однако сам перебор всё равно просмотрит документы один за другим. В следующем уроке изменим устройство поиска, а не будем называть небольшую оптимизацию полноценным индексом.
Замена ё на е — выбранное правило нашей библиотеки. Оно уменьшает различие распространённых вариантов ввода, но не решает русскую морфологию. Слова «таблица» и «таблицы» остаются разными последовательностями. Важно понимать границу уже введённого преобразования.
Текст карточки и адрес
Заголовок вставляется через textContent. Поиск работает с текстами статей, которые могут содержать символы разметки. Если использовать innerHTML для необработанного заголовка, данные начнут интерпретироваться как HTML. Для обычной карточки это не требуется.
Публичный путь берётся из doc.url, а не создаётся из ID. Старый адрес XML-урока сохраняет регистр каталогов и окончание .php. Никакого знания о языке исходного сервера поисковая форма для перехода не использует.
Пример разрешает ссылки того же origin. В нашем формате адрес должен начинаться с одного /; экспортёр исключает сетевые адреса с //. При получении данных из внешнего источника это условие следует проверять и на клиенте явно. Не делайте внешнюю ссылку доверенной только из-за подходящего заголовка.
Показанный счётчик относится к массиву найденных документов. Пока нет фильтров и разбиения, поэтому его смысл прост. В дальнейшем важно будет различать число всех совпадений и число карточек на текущей странице.
Наблюдение за первой версией
Откройте пример через локальный HTTP-сервер. Открытие HTML напрямую как file: может ограничить загрузку модулей и данных; это не ошибка алгоритма поиска. В контрольных исходниках отдельно описан способ обслуживания учебной папки.
Сначала используйте короткие успешные запросы, затем примеры с опечаткой и свободной формулировкой. Запишите найденные ID и их порядок. Не назначайте все неудачи одной причиной: отсутствие слова, несовпадение фразы и недоступность файла являются разными ситуациями.
У этой версии пока нет защиты от устаревшего асинхронного ответа при нескольких быстрых отправках формы. Для спокойной первой проверки достаточно одного запроса; позже введём номер запроса и научимся игнорировать запоздавшие результаты. Ограничение не стоит скрывать за случайным быстрым соединением.
Полезно отдельно рассмотреть повтор после сетевой ошибки. Если первая загрузка вернула 404, состояние формы сообщает о недоступности, а dataPromise сбрасывается. Исправив размещение файла, отправьте тот же запрос ещё раз. Загрузчик должен обратиться к данным заново. Простое повторное отображение пустого массива не выполняет эту задачу: оно скрывает неисправность вместо повторной попытки получить корпус.
Теперь измените только регистр строки xml и сравните карточки. Нормализация делает эти варианты одинаковыми для алгоритма, но исходное название статьи остаётся «Запросы LINQ to XML». Это два разных представления: строка для сравнения и текст для чтения. Хранение нормализованного названия вместо оригинального затронуло бы оформление результата и впоследствии затруднило подсветку.
Теперь поиск существует как работающий интерфейс, но его поведение полностью определяется подстрокой и порядком документов. Следующий урок построит связь между словами и ID, чтобы находить документы по нескольким отдельным словам.