Состояние интерфейса в URL
В предыдущем блоке каталог сохранял выбор только в памяти текущей страницы. Ссылку можно было отправить другому человеку, но он увидел бы исходные четыре курса. Теперь поместим применённую тему и номер страницы в адрес. Открытие ссылки должно восстановить понятное состояние, а кнопки истории браузера — вернуть прежний выбор.
Возьмём независимый снимок advanced/lesson-21 из архива продолжения. Первоначальный catalog-lab сохраняется отдельно. Показывайте всю папку advanced через локальный HTTP-сервер по инструкции README: снимки используют общие модули. Учебная программа ещё не запускалась; далее описываем ожидаемые эффекты для ручного наблюдения.
Адрес как представление выбора
В новом снимке источник постраничный, но пока без кнопок перехода. Четыре прежних курса распределены по две записи. Созданы файлы data/pages/all-1.json, all-2.json, frontend-1.json, frontend-2.json, publishing-1.json. Модуль mock-api.js действительно читает эти файлы; внешний сервер API не требуется. Ответ содержит items, page, pageSize и total.
Для ссылки ?topic=frontend&page=1 ожидаются первые две фронтенд-карточки. Поле total равно трём, хотя items.length равно двум. Это первое отличие общего размера результата от размера показанного фрагмента. URL описывает запрос к набору, а не сохраняет в себе весь массив записей.
Не разбирайте строку адреса вручную через несколько split. Параметры могут находиться в разном порядке, отсутствовать или содержать закодированные символы. Объект URL отделяет части адреса, а URLSearchParams предоставляет операции над параметрами. Поведение этих операций изложено в MDN.
В js/url-state.js находится полная функция чтения:
export function readSelection(href = location.href) {
const params = new URL(href).searchParams;
const rawTopic = params.get('topic');
const rawPage = params.get('page') ?? '1';
const topic = ['all', 'frontend', 'publishing'].includes(rawTopic) ? rawTopic : 'all';
const page = /^[1-9]\d*$/.test(rawPage) && Number.isSafeInteger(Number(rawPage))
? Number(rawPage) : 1;
return { topic, page };
}
Сначала читаются строки, затем применяется договор. Неизвестная тема превращается в all, неверная запись номера — в единицу. Регулярное выражение разрешает положительное целое без пробелов, знака и дроби. Проверка безопасного целого исключает число, которое перестало бы точно представлять номер при преобразовании.
Это нормализация синтаксиса адреса, а не обещание существования страницы. Номер девять синтаксически допустим, но для четырёх курсов соответствующего файла нет. Такой запрос получит отказ загрузки. Полноту диапазона изучим в уроке о пагинации; сейчас важно не смешивать чтение строки с доступностью данных.
Запись без навигации документа
После успешного получения страницы создаём новый адрес на основе текущего. Полная функция записи также находится в url-state.js:
export function writeSelection(selection, { replace = false } = {}) {
const url = new URL(location.href);
url.searchParams.set('topic', selection.topic);
url.searchParams.set('page', String(selection.page));
history[replace ? 'replaceState' : 'pushState'](null, '', url);
}
Метод set задаёт одно значение нашего параметра. Остальные параметры и фрагмент текущего URL сохраняются, поэтому изменение темы не уничтожает независимые части адреса. Не собирайте строку с вопросительным знаком заново, если интерфейс уже может иметь другие настройки.
pushState добавляет запись истории без обычной загрузки другого документа. Адрес должен оставаться в допустимой области текущего происхождения; это не способ перевести браузер на произвольный чужой сайт. В нашем случае меняется только query текущей страницы. Ограничения и поведение изложены в MDN о pushState.
replaceState заменяет текущую запись. Он подходит для канонизации начального выбора, когда не нужно добавлять ещё один шаг «Назад». Пользовательское применение новых настроек в нашем снимке использует добавление. Не записывайте историю при каждом нажатии клавиши в сложной форме: сначала решите, какое действие является самостоятельным состоянием.
Переход от желаемого к показанному
В app.js начальный selection получается через readSelection. Поле темы получает его машинное значение. Функция refresh загружает страницу, показывает ответ и лишь после успеха сохраняет применённый выбор. Это отделяет желание получить страницу от фактически отображаемого состояния.
async function refresh(next, write = false) {
status.textContent = 'Загружаем страницу…';
try {
const result = await loadPage(next);
selection = next;
topicField.value = next.topic;
renderCourses(list, result.items);
status.textContent = `Показано: ${result.items.length}. Всего: ${result.total}.`;
if (write) writeSelection(next);
} catch (error) {
status.textContent = 'Страница не загружена.';
console.error(error);
}
}
Этот блок является частью полного снимка; объявления узлов и импорты уже находятся выше него. Он заменяет прежнюю функцию обновления из блока 20, а не дописывается как ещё одно определение рядом. Полный файл в архиве устраняет двусмысленность состояния проекта.
При ошибке прежние карточки не объявляются новым успешным результатом. Сообщение объясняет отказ, а адрес не записывается как успешно применённый выбор. Если страница была открыта сразу с несуществующим номером, исходный адрес остаётся видимым и помогает понять причину. В продукте можно было бы предложить явный возврат к первой странице, но не скрывать отказ молчаливой подменой.
История и восстановление
Программный pushState сам по себе не вызывает тот же поток, что нажатие кнопки «Назад». Для переходов по истории зарегистрирован popstate: обработчик читает текущий URL и получает соответствующую страницу. Он не добавляет новую запись истории, иначе возврат породил бы ещё один переход.
Форма при смене темы запрашивает первую страницу. Это предметное правило: номер второй страницы предыдущей темы может не существовать у новой. Состояние topic/page представляет согласованную пару, а не два независимых значения, которые можно сохранять без учёта связи.
Для ручного опыта откройте адрес с frontend&page=2: ожидается одна карточка производительности и общий размер три. Затем примените публикацию: ожидается Markdown и номер первой страницы в URL. Вернитесь по истории — должен восстановиться прежний отбор. Эти эффекты пока не являются подтверждённым протоколом запуска.
URL не является местом хранения секретов, личных целей обучения и токенов. Его копируют, записывают в историю и передают при переходах. Здесь используются только публичные параметры представления. Наличие читаемого адреса также не означает автоматически готовые SEO-страницы для каждого query: индексирование является отдельной политикой сайта.
Какие состояния адрес действительно сохраняет
Представьте, что читатель скопировал ссылку на вторую страницу фронтенда, затем изменил тему у себя. Получатель ссылки всё равно должен получить старую выбранную пару, потому что она находится в самом адресе. При этом ссылка не содержит текущего выделения текста, фокуса кнопки или сообщения ожидания. Такие временные детали не нужны для воспроизведения содержимого и не должны попадать в параметры автоматически.
У параметров может оказаться несколько одинаковых имён: например, два значения topic. В нашей функции get читает первое, а последующая запись через set оставляет одно значение данного параметра. Это конкретное правило снимка. Если приложение принимает множество тем, ему понадобится другой договор с getAll, проверкой каждого значения и понятным порядком. Нельзя считать повтор имени одновременно ошибкой и списком, не определив смысл заранее.
Проверка числа также намеренно отличается от простого parseInt. Строка 2abc не должна превращаться во вторую страницу лишь потому, что начинается с цифры. Нормализация возвращает первую страницу, а отсутствие файла для корректного номера остаётся ошибкой источника. Так легче выяснить, отказал ли разбор записи или загрузка существующих по форме параметров.
Есть особенность возврата по истории: браузер меняет адрес до выполнения нашего запроса. Если восстановление не удалось, оставшиеся карточки относятся к последнему успешному выбору, а текущий URL — к неудачной попытке. Статус ошибки должен сохраняться, чтобы человек не принял их за содержимое новой ссылки. При первой неудачной загрузке прежних карточек вообще нет. Поэтому проверяйте не только удачный переход, но и восстановление отсутствующего файла после уже показанного каталога.
Наконец, добавление одинакового выбора несколько раз создаёт несколько похожих записей истории в текущем простом варианте. Это не повреждение данных, но продукт может предпочесть сравнение применённой пары и пропуск повторной записи. Решение относится к поведению истории; его не следует скрывать внутри преобразования номера страницы.
Теперь выбор можно восстановить из ссылки. Однако несколько быстрых применений запускают несколько асинхронных обновлений, и старый результат способен прийти позже нового. Следующий урок добавит отмену ненужной операции, сохранив сформированный договор адреса и данных.