Перейти к содержанию

Состояние интерфейса в 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 — к неудачной попытке. Статус ошибки должен сохраняться, чтобы человек не принял их за содержимое новой ссылки. При первой неудачной загрузке прежних карточек вообще нет. Поэтому проверяйте не только удачный переход, но и восстановление отсутствующего файла после уже показанного каталога.

Наконец, добавление одинакового выбора несколько раз создаёт несколько похожих записей истории в текущем простом варианте. Это не повреждение данных, но продукт может предпочесть сравнение применённой пары и пропуск повторной записи. Решение относится к поведению истории; его не следует скрывать внутри преобразования номера страницы.

Теперь выбор можно восстановить из ссылки. Однако несколько быстрых применений запускают несколько асинхронных обновлений, и старый результат способен прийти позже нового. Следующий урок добавит отмену ненужной операции, сохранив сформированный договор адреса и данных.

Оглавление курса