Вы выбрали тему каталога, обновили страницу — фильтр исчез. Исправим это: сохраним применённые условия в URL, восстановим форму при открытии ссылки и научим выдачу следовать кнопкам «Назад» и «Вперёд».

Договоритесь, какое состояние хранится в ссылке

Соберём маленький каталог материалов. У него есть поиск по названию, тема и сортировка. Человек меняет поля и нажимает «Применить фильтры». После этого URL описывает показанную выдачу. Набранный, но ещё не применённый текст остаётся черновиком в поле.

Такой явный момент применения удобен для знакомства с историей: один подтверждённый выбор создаёт одну запись. Если сохранять каждый введённый символ через pushState, кнопка «Назад» начнёт перебирать буквы. Для мгновенного поиска потребуется другая политика записи адреса; в этом примере у нас форма с кнопкой.

Понадобятся базовые HTML, JavaScript, события формы и модули, а также браузер и локальный Node.js. Создайте отдельную папку с файлами index.html, state.mjs, app.js, server.mjs и check.mjs. Никаких пакетов устанавливать не потребуется.

Условия кодируем так: q хранит текст, kind — тему html или js, sort=minutes включает сортировку по времени чтения. Значения по умолчанию в адрес не записываем: отсутствие kind означает все темы, отсутствие sort — сортировку по названию.

Каталог владеет только этими тремя параметрами. Любые другие, например campaign=letter или повторяющиеся tag, сохраняем. Фрагмент после # тоже оставим. Это осознанный контракт: сброс фильтров не должен стирать метку рассылки или состояние другого независимого компонента. Если добавите пагинацию, включите её параметр в контракт каталога и сбрасывайте номер страницы при новом фильтре.

Если нужен разбор самого синтаксиса адреса, рядом есть статья «Объекты URL и URLSearchParams в JavaScript». Наша задача — согласовать адрес с формой и историей.

Добавьте форму и место для выдачи

Запишите index.html:

<!doctype html>
<html lang="ru">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Каталог с адресом</title>
<style>
body { font: 1.1rem/1.5 system-ui; max-width: 48rem; margin: 2rem auto; padding: 1rem; }
form { display: grid; gap: 1rem; }
input, select, button { font: inherit; max-width: 100%; }
label { display: grid; gap: .25rem; }
:focus-visible { outline: 3px solid #164bbb; outline-offset: 3px; }
</style>
<h1>Каталог материалов</h1>
<form id="filters">
  <label>Название <input name="q" type="search" maxlength="80"></label>
  <label>Тема
    <select name="kind">
      <option value="all">Все</option>
      <option value="html">HTML</option>
      <option value="js">JavaScript</option>
    </select>
  </label>
  <label>Сортировка
    <select name="sort">
      <option value="title">По названию</option>
      <option value="minutes">По времени чтения</option>
    </select>
  </label>
  <button type="submit">Применить фильтры</button>
  <button type="button" id="clear">Сбросить фильтры</button>
</form>
<p id="count" role="status"></p>
<ul id="items"></ul>
<script type="module" src="app.js"></script>
</html>

Поля получают name, потому что FormData собирает значения по этим именам. Кнопка сброса имеет type="button": она вызывает нашу навигацию. Обычный form.reset() вернул бы исходные значения элементов, но сам по себе не обновил бы URL и список.

Сообщение о количестве вынесено в role="status". При применении фильтров фокус остаётся на кнопке, а текст сообщает результат. Пустая выдача тоже имеет явное объяснение и путь к исправлению: изменить условия или сбросить их.

Мы используем textContent для названий. В учебных данных строки известны заранее, однако адрес уже вводится пользователем. Привычка разделять текст и HTML пригодится, когда каталог начнёт получать данные из другого источника.

Опишите один формат состояния

Создайте модуль state.mjs:

export function normalize(raw) {
  return {
    q: String(raw.q ?? '').trim().slice(0, 80),
    kind: ['html', 'js'].includes(raw.kind) ? raw.kind : 'all',
    sort: raw.sort === 'minutes' ? 'minutes' : 'title',
  };
}

export function readState(url) {
  return normalize({
    q: url.searchParams.get('q'),
    kind: url.searchParams.get('kind'),
    sort: url.searchParams.get('sort'),
  });
}

export function writeState(current, raw) {
  const url = new URL(current);
  const state = normalize(raw);
  for (const key of ['q', 'kind', 'sort']) url.searchParams.delete(key);
  if (state.q) url.searchParams.set('q', state.q);
  if (state.kind !== 'all') url.searchParams.set('kind', state.kind);
  if (state.sort !== 'title') url.searchParams.set('sort', state.sort);
  return url;
}

export function selectItems(items, state) {
  const query = state.q.toLocaleLowerCase('ru');
  return items.filter((item) =>
    (state.kind === 'all' || item.kind === state.kind) &&
    item.title.toLocaleLowerCase('ru').includes(query)
  ).sort((a, b) => state.sort === 'minutes'
    ? a.minutes - b.minutes || a.id - b.id
    : a.title.localeCompare(b.title, 'ru') || a.id - b.id);
}

normalize задаёт допустимые значения. Неизвестная тема превращается в all, незнакомая сортировка — в title. Поисковую строку обрезаем по краям и ограничиваем 80 символами в коде, поскольку адрес можно набрать вручную, минуя maxlength у поля.

Это нормализация клиентского состояния. Если позже те же фильтры уйдут на сервер, сервер отдельно проверит входные параметры. Ограничение в браузере не защищает API от произвольного запроса.

У адреса может быть несколько значений kind. В нашем контракте этот фильтр одиночный: при чтении get берёт первое значение, а при следующей записи все одноимённые параметры удаляются и сохраняется один нормализованный вариант. Если нужна множественная тема, модель придётся явно изменить на массив и читать через getAll.

Функция writeState получает текущий адрес целиком и создаёт его копию. Затем удаляет только принадлежащие каталогу ключи и добавляет применённые значения. Она не собирает строку запроса конкатенацией, поэтому русские буквы, пробелы и символ & внутри поиска кодируются средствами URL API.

Чужие параметры сохраняются по значениям, включая повторения. При этом URL API может изменить написание строки запроса, например способ кодирования пробела. Если внешний протокол подписывает точные байты адреса, такую ссылку нельзя свободно переписывать этим способом. В нашем локальном каталоге подписи нет.

selectItems возвращает новый отфильтрованный массив. Функция сортирует новый массив и оставляет исходный catalog на месте. При равных значениях используем id как дополнительный критерий: так одинаковое время чтения не делает порядок примера неопределённым.

Свяжите навигацию с повторным чтением URL

Сохраните app.js:

import { readState, writeState, selectItems } from './state.mjs';

const catalog = [
  { id: 1, title: 'Формы в HTML', kind: 'html', minutes: 8 },
  { id: 2, title: 'Таблицы на телефоне', kind: 'html', minutes: 12 },
  { id: 3, title: 'Формы и события', kind: 'js', minutes: 15 },
  { id: 4, title: 'Поиск без гонок', kind: 'js', minutes: 10 },
];
const form = document.querySelector('#filters');
const list = document.querySelector('#items');
const count = document.querySelector('#count');

function renderFromUrl() {
  const state = readState(new URL(location.href));
  for (const key of ['q', 'kind', 'sort']) {
    form.elements.namedItem(key).value = state[key];
  }
  const items = selectItems(catalog, state);
  list.replaceChildren(...items.map((item) => {
    const li = document.createElement('li');
    li.textContent = `${item.title} — ${item.minutes} мин`;
    return li;
  }));
  count.textContent = items.length
    ? `Материалов: ${items.length}.`
    : 'По этим фильтрам материалов нет. Измените условия или сбросьте их.';
}

function navigate(raw) {
  const next = writeState(location.href, raw);
  if (next.href !== location.href) {
    history.pushState(null, '', next);
  }
  renderFromUrl();
}

form.addEventListener('submit', (event) => {
  event.preventDefault();
  navigate(Object.fromEntries(new FormData(form)));
});
document.querySelector('#clear').addEventListener('click', () => {
  navigate({ q: '', kind: 'all', sort: 'title' });
});
window.addEventListener('popstate', renderFromUrl);

// Нормализуем только принадлежащие каталогу параметры.
const initial = writeState(location.href, readState(new URL(location.href)));
if (initial.href !== location.href) history.replaceState(null, '', initial);
renderFromUrl();

Функция renderFromUrl читает состояние из текущего адреса, заполняет поля и рисует выдачу. Она вызывается в трёх местах: при старте страницы, после нашей навигации и при событии popstate. Один путь восстановления уменьшает шанс, что форма и список разойдутся.

history.pushState добавляет запись истории без перезагрузки документа. Этот вызов сам не создаёт событие popstate, поэтому после него мы явно рисуем состояние. Когда человек нажимает «Назад» или «Вперёд», браузер меняет адрес и вызывает наш обработчик. В обработчике истории мы читаем адрес и обновляем DOM, не записывая новую историю.

Последнее ограничение принципиально. Если вызывать navigate внутри popstate, можно снова добавить адрес, с которого человек только что ушёл, и сделать кнопку «Назад» похожей на застрявшую. При переходе истории мы восстанавливаем состояние, при новом выборе — записываем его.

Параметр state у pushState здесь равен null. Мы сознательно храним применённые фильтры в URL: он переживает перезагрузку и копируется в другую вкладку. Дублировать те же значения в history.state для этого примера не требуется. В большом приложении там могут жить другие данные, например позиция списка, но их нужно восстанавливать отдельно.

При старте выполняем нормализацию через replaceState. Адрес ?kind=unknown&q=%20формы%20 становится адресом с нормализованным поиском. Вызов replaceState не добавляет шага «Назад». После неё поля и список читаются уже из исправленного адреса.

Если пользователь повторно применяет те же условия, проверка next.href !== location.href предотвращает дубликат записи. Сброс тоже проходит через navigate: он убирает наши ключи, сохраняет чужие и позволяет вернуться к прежнему фильтру кнопкой «Назад».

Поднимите локальный сервер

Для модулей используйте HTTP, а не открытие файла с диска. Сохраните server.mjs:

import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';

const files = new Map([
  ['/', ['index.html', 'text/html; charset=utf-8']],
  ['/app.js', ['app.js', 'text/javascript; charset=utf-8']],
  ['/state.mjs', ['state.mjs', 'text/javascript; charset=utf-8']],
]);
const server = createServer(async (req, res) => {
  const url = new URL(req.url, 'http://localhost');
  const file = files.get(url.pathname);
  if (!file) { res.writeHead(404).end('Not found'); return; }
  try {
    const body = await readFile(new URL(file[0], import.meta.url));
    res.writeHead(200, { 'Content-Type': file[1] });
    res.end(body);
  } catch {
    res.writeHead(500).end('Cannot read file');
  }
});
server.listen(4173, '127.0.0.1', () => {
  console.log('Open http://127.0.0.1:4173');
});

В папке примера выполните:

node server.mjs

Откройте http://127.0.0.1:4173/?campaign=letter#list. Сервер учебный, слушает только локальный интерфейс и раздаёт заранее перечисленные файлы. При занятом порте остановите прежний процесс либо поменяйте число 4173 и адрес в браузере.

Введите «формы», выберите JavaScript и примените условия. В списке останется «Формы и события», а адрес получит q и kind=js. Метка campaign=letter и #list сохранятся.

Теперь обновите страницу. Должны восстановиться и поле поиска, и тема, и единственная строка выдачи. Скопируйте адрес в новую вкладку: результат должен совпасть. В этот момент у вас уже есть ссылка, которой можно поделиться, без зависимости от памяти первой вкладки.

Проверьте историю на изменении условий

После первого фильтра выберите все темы и сортировку по времени. Нажмите «Применить фильтры». Получится новая запись истории и два материала про формы. Затем нажмите «Назад»: вернутся тема JavaScript, прежняя сортировка и одна строка. «Вперёд» восстановит второй вариант.

В поле можно набрать другой текст, ещё не применяя его. При переходе истории черновой ввод заменится значением из посещаемой ссылки. Это правило нашего интерфейса: адрес описывает применённое состояние. Если продукту нужно хранить незавершённый ввод отдельно, его следует спроектировать как отдельный черновик, а не смешивать со ссылкой на выдачу.

На копии примера временно удалите обработчик popstate. Перейдите между двумя применёнными фильтрами и нажмите «Назад». Адрес изменится, а поля и выдача останутся прежними. Такой воспроизводимый сбой показывает, зачем нужен обработчик: History API меняет историю, но не умеет обновлять ваш DOM.

Верните обработчик до следующей проверки.

Найдите источник рассогласования

СимптомВероятное место ошибкиЧто должно происходить
После обновления форма пустаяИнициализация читает значения HTML вместо URLСтарт вызывает renderFromUrl
«Назад» меняет только адресНет реакции на popstateФорма и список заново читают URL
Собственное применение ничего не рисуетОжидание popstate после pushStateПосле записи вызывается отрисовка
Сброс стирает метку рассылкиСтрока запроса заменяется целикомУдаляются только q, kind, sort
История идёт по каждой буквеpushState стоит на каждом inputЗапись создаётся при подтверждении условий
Список и поля показывают разные темыДва независимых источника применённого состоянияОба восстанавливаются из одного URL
Неизвестная тема скрыла всёНет нормализации входных значенийНеизвестный код превращается в all
Повторный клик создаёт лишний шаг историиНовый адрес не сравнивают с текущимОдинаковая ссылка не добавляется повторно

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

Проверьте модель и расширьте её

Сохраните check.mjs:

import assert from 'node:assert/strict';
import { readState, writeState, selectItems } from './state.mjs';
const original = 'http://localhost/?campaign=letter&tag=a&tag=b&kind=wrong&q=%20%D1%84%D0%BE%D1%80%D0%BC%D1%8B%20#list';
const state = readState(new URL(original));
assert.deepEqual(state, { q: 'формы', kind: 'all', sort: 'title' });
const shared = writeState(original, { ...state, kind: 'js', sort: 'minutes' });
assert.deepEqual(readState(shared), { q: 'формы', kind: 'js', sort: 'minutes' });
assert.equal(shared.hash, '#list');
assert.equal(shared.searchParams.get('campaign'), 'letter');
assert.deepEqual(shared.searchParams.getAll('tag'), ['a', 'b']);
const cleared = writeState(shared, {});
assert.equal(cleared.searchParams.has('kind'), false);
assert.equal(cleared.searchParams.get('campaign'), 'letter');
const items = [
  { id: 1, title: 'Формы HTML', kind: 'html', minutes: 8 },
  { id: 2, title: 'Формы JS', kind: 'js', minutes: 12 },
];
assert.deepEqual(selectItems(items, readState(shared)).map((x) => x.id), [2]);
assert.equal(readState(new URL('http://localhost/?q=' + 'a'.repeat(100))).q.length, 80);
console.log('PASS: normalize, URL round trip, unknown params, reset, filter');

Оставьте сервер работать в первом терминале. Во втором терминале перейдите в папку примера и запустите node check.mjs. Проверка подтверждает нормализацию, запись и чтение ссылки, сохранение посторонних параметров и работу фильтра. Переходы браузерной истории дополнительно проверяйте по сценарию выше: этот файл работает с чистыми функциями и не нажимает кнопки браузера.

Сначала откройте адрес ?campaign=letter&kind=wrong&sort=wrong&q=%20формы%20. Убедитесь, что форма показывает все темы и сортировку по названию, а лишние значения наших фильтров исчезли после нормализации. Затем самостоятельно добавьте условие «не дольше 10 минут». Выберите для него собственный ключ, включите в чтение и запись, а после сброса проверьте сохранность campaign.

Если дальше хотите переносить такие состояния в приложение с компонентами, посмотрите курс React в HTML Academy. Перед переносом сохраните рабочий критерий этого примера: одна и та же ссылка даёт одинаковые применённые фильтры после открытия, обновления и перехода истории.


«Доктайп» — журнал о фронтенде. Читайте, слушайте и учитесь с нами.

ТелеграмПодкастБесплатные учебники