Удобная страница поиска
Поиск уже умеет ранжировать документы, учитывать варианты запроса и выдавать одну страницу результатов. Теперь нужно сделать его состояние понятным посетителю. Программа может правильно находить материал, но выглядеть сломанной из-за пустого экрана, пропавшего запроса или сообщения «ничего не найдено» при сетевой ошибке.
Результатом урока станет страница, которая различает состояния, сохраняет запрос и раздел в URL и восстанавливает их при переходе назад. Старый параметр valsearch поддерживается как входной вариант; учебная страница использует q.
Что происходит на странице
До ввода запроса полезно показать короткое объяснение. Во время загрузки данных требуется сообщение о работе. После завершения выводятся карточки либо честное отсутствие совпадений. Ошибка получения индекса — самостоятельное состояние, при котором нельзя утверждать, что в библиотеке нет материала.
Эти состояния не заменяются одним пустым массивом. В трёх разных случаях массив может быть пуст: запрос не введён, совпадений нет, данные недоступны. Пользователь должен понимать, какое действие поможет продолжить.
Форма сохраняет обычную кнопку и отправку по Enter. Фильтр раздела является частью запроса, поэтому применяется одновременно с ним и сбрасывает номер страницы. Кнопки перехода изменяют только страницу текущей выдачи.
Элементы интерфейса
В index.html замените содержимое формы и добавьте управление страницами после списка. Заголовок, подключение модуля и общая HTML-оболочка остаются прежними.
<form id="search-form">
<label for="query">Запрос</label>
<input id="query" name="q" type="search" maxlength="200">
<label for="section">Раздел</label>
<select id="section" name="section">
<option value="">Все разделы</option>
<option value="frontend">Фронтенд</option>
<option value="backend">Серверная разработка</option>
<option value="deployment">Развёртывание</option>
</select>
<button>Найти</button>
</form>
<p id="status" role="status"></p>
<ul id="results"></ul>
<nav aria-label="Страницы результатов">
<button id="previous" type="button" disabled>Назад</button>
<button id="next" type="button" disabled>Далее</button>
</nav>
В реальной библиотеке список разделов должен происходить из общей структуры каталога. Наш короткий список соответствует учебным документам. Ручные подписи здесь объясняют устройство формы, но не предлагают завести отдельный несогласованный справочник настоящего сайта.
Ограничение длины в поле помогает интерфейсу, однако оно не заменяет проверки программы или серверного API. Значение может прийти из адресной строки, где HTML-ограничение не действует.
Состояние в адресной строке
Запрос и фильтр удобно сохранять в URL: ссылку можно открыть повторно, отправить другому человеку или восстановить через историю браузера. Адрес статьи при этом остаётся собственным; URL поиска описывает выдачу, а не новую копию материала.
Используем URLSearchParams, чтобы кодировать значения по правилам URL, а не соединять строки вручную. Вопросительный знак, амперсанд и кириллица в запросе не должны разрушать соседние параметры. Назначение API описано в справке URLSearchParams.
Ниже полный app.js этого этапа. Модуль core.js содержит функции предыдущих уроков, включая фильтры и пагинацию.
import { buildIndex, searchIndex } from "./core.js";
const form = document.querySelector("#search-form");
const input = document.querySelector("#query");
const section = document.querySelector("#section");
const results = document.querySelector("#results");
const status = document.querySelector("#status");
const previous = document.querySelector("#previous");
const next = document.querySelector("#next");
let indexPromise;
let revision = 0;
let last = { page: 1, totalPages: 0 };
function readState() {
const p = new URLSearchParams(location.search);
const key = p.has("q") ? "q" : p.has("valsearch") ? "valsearch" : "q";
const page = Number(p.get("page") || 1);
return {
key, query: (p.get(key) || "").slice(0, 200),
section: p.get("section") || "",
page: Number.isInteger(page) && page > 0 ? page : 1,
pageSize: 10
};
}
function changeState(values) {
const url = new URL(location.href);
const state = { ...readState(), ...values };
url.searchParams.delete("q");
url.searchParams.delete("valsearch");
if (state.query) url.searchParams.set(state.key, state.query);
for (const name of ["section", "page"]) {
const value = state[name];
if (value && !(name === "page" && value === 1)) url.searchParams.set(name, value);
else url.searchParams.delete(name);
}
history.pushState(null, "", url);
refresh();
}
function loadIndex() {
if (!indexPromise) {
indexPromise = 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 buildIndex(payload.documents);
}).catch(error => {
indexPromise = undefined;
throw error;
});
}
return indexPromise;
}
function render(hits) {
results.replaceChildren();
for (const doc of hits) {
const url = new URL(doc.url, location.origin);
if (!doc.url.startsWith("/") || doc.url.startsWith("//") ||
url.origin !== location.origin) continue;
const li = document.createElement("li");
const a = document.createElement("a");
a.href = url.pathname;
a.textContent = doc.title;
li.append(a);
results.append(li);
}
}
async function refresh() {
const requestId = ++revision;
const state = readState();
input.value = state.query;
section.value = state.section;
results.replaceChildren();
previous.disabled = next.disabled = true;
if (!state.query.trim()) {
results.setAttribute("aria-busy", "false");
status.textContent = "Введите слово, название урока или вопрос";
return;
}
status.textContent = "Ищем материалы…";
results.setAttribute("aria-busy", "true");
try {
const index = await loadIndex();
if (requestId !== revision) return;
last = searchIndex(index, state.query, state);
render(last.hits);
status.textContent = last.total
? "Найдено: " + last.total + ". Страница " + last.page + " из " + last.totalPages
: "Совпадений нет. Измените запрос или раздел";
previous.disabled = last.page <= 1;
next.disabled = last.page >= last.totalPages;
} catch {
if (requestId === revision) status.textContent = "Поиск недоступен. Повторите запрос";
} finally {
if (requestId === revision) results.setAttribute("aria-busy", "false");
}
}
form.addEventListener("submit", event => {
event.preventDefault();
changeState({ query: input.value.trim(), section: section.value, page: 1 });
});
previous.addEventListener("click", () => changeState({ page: last.page - 1 }));
next.addEventListener("click", () => changeState({ page: last.page + 1 }));
addEventListener("popstate", refresh);
refresh();
readState поддерживает оба имени параметра. Если есть q, используется оно; иначе читается историческое valsearch. При изменении запроса сохраняется выбранное имя. Такой договор избегает зависимости от одного нового адреса формы.
Сам путь страницы не изменяется. При интеграции в ProfessorWeb можно сохранить прежний search_result.php; JavaScript обрабатывает параметры уже открытого URL. Не нужно переносить страницу поиска только ради другого устройства индекса.
История и запоздавшие ответы
history.pushState записывает новое состояние без полной загрузки страницы. Событие popstate запускает повторное чтение URL, когда посетитель возвращается назад или идёт вперёд.
Это два направления синхронизации. Действие формы изменяет URL, а изменение истории обновляет форму и результаты. Если реализовать только первое, поле ввода и карточки начнут описывать разные запросы после кнопки браузера «Назад».
Номер revision защищает от запоздавшего результата. Каждый запуск получает собственный ID; после асинхронной загрузки программа проверяет, остался ли он текущим. Старый запрос не должен перезаписать более новый экран.
Та же проверка нужна в обработке ошибки. Иначе поздняя ошибка старого запроса могла бы заменить успешную новую выдачу сообщением о недоступности. Очистка aria-busy тоже относится только к текущей операции.
При переходе к пустому запросу программа сразу убирает aria-busy. Такой переход показывает, почему состояние интерфейса нужно рассматривать целиком, включая ранние выходы.
Пустой результат и ошибка
Для пустого результата полезно предложить изменить формулировку или раздел. Не стоит утверждать, что статьи на тему не существует: наша реализация могла не распознать запрос. Каталог остаётся альтернативным способом обнаружить материал.
При ошибке предложена повторная отправка формы. Загрузчик сбрасывает неудачный Promise, поэтому повтор может восстановить работу. Для более развитого интерфейса можно добавить отдельную кнопку повторения, сохранив исходные параметры.
Счётчик сообщает общее число совпадений, а номер страницы — положение текущей части. При нуле результатов кнопки недоступны. Поле запроса сохраняется, поэтому посетителю не нужно набирать длинную фразу заново.
Данные карточек выводятся через текстовые DOM-операции. Страница не превращает заголовки документов в HTML. В следующем уроке добавим подсветку, но это правило сохранится: найденный текст остаётся данными.
Связь с поисковыми системами
Служебная выдача и учебная статья имеют разное назначение. В ProfessorWeb поиск использует прежний адрес, а материалы остаются доступными по собственным URL. Создавать индексируемую страницу на каждую произвольную комбинацию запроса обычно не требуется для нашей библиотеки.
При интеграции нужно сохранить принятую политику служебной страницы и её общий шаблон. Этот урок изменяет учебный интерфейс, а не объявляет автоматически новую SEO-политику всего сайта.
Разберите последовательность из трёх действий: найти XML, выбрать другой раздел и вернуться кнопкой браузера назад. После возвращения должны восстановиться и запрос, и раздел, и соответствующие карточки. Проверка только текста поля недостаточна: старый список результатов мог остаться на экране и создать правдоподобное, но неверное состояние.
Отдельно откройте сохранённую ссылку в новой вкладке. Программа должна восстановить выдачу без ранее существовавших переменных JavaScript. Это объясняет выбор URL как источника состояния. Объект last нужен для текущих кнопок пагинации, но не заменяет адрес. Для пустого запроса прежний номер страницы также не должен заставлять интерфейс показывать сообщение о загрузке или активные кнопки перехода.
Теперь состояние запроса имеет один источник — URL, а интерфейс ясно показывает ход работы. Следующий шаг объяснит найденное совпадение с помощью короткого фрагмента и безопасной подсветки.