Храним состояние в URL

Обновленная и дополненная версия от 2022 года

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

Например, вы продаете через интернет элитный картофель. Покупатель заходит на сайт и первым делом настраивает условия отбора картофелин:

  • строго из Боливии или ЮАР,
  • урожая 2022 года,
  • размер клубня от 3 до 7 см,
  • желательно в форме морского тюленя.

Получает список из 300 позиций (да, в ЮАР очень популярна картоха в форме тюленя), нарезанный на 6 страниц по 50 элементов каждая. Переходит на третью страницу, открывает карточку картофелины и застывает в немом восхищении на несколько секунд. А потом случайно нажимает на рефреш страницы. Как поступит ваше приложение?

Как хранить состояние

JavaScript-приложения традиционно работают с локальным состоянием в виде объектов в памяти. Это удобно, потому что нет ограничений на объем, поддерживаются сложные структуры данных, и не приходится заниматься сериализацией-десериализацией. Но подойдет ли это нашему картофельному магазину? Давайте разбираться.

Не хранить вовсе

Допустим, вы не стали заморачиваться с состоянием и держите его в памяти. Тогда при обновлении страницы текущий контекст благополучно потеряется, а пользователя перекинет на главную страницу, где он с негодованием уставится на аршинный заголовок «ЭЛИТНЫЙ КАРТОФЕЛЬ».

Так раньше вел себя гугл-календарь. Как бы вы не перемещались по календарю, какие бы фильтры не накладывали — урл всегда сохранял свой изначальный вид:

https://www.google.com/calendar/render

Обновляете страницу — и календарь радостно сбрасывает вас на текущую неделю. Удобненько.

Хранить локально

Большинство сервисов понимает, что терять контекст при обновлении страницы нехорошо. Они запоминают состояние на клиенте (в локал-сторадже или других браузерных хранилищах). Это решает проблему с рефрешем страницы, но поставить закладку все равно не получится.

Плюс такой подход создает проблему с конфликтующими изменениями состояния. Я открыл две вкладки браузера, зашел на ваш картофельный сайт, и в одной вкладке ищу «картофель молодой», а в другой — «ботва разлапистая». Какой из запросов сохраним?

Хранить набор URL-параметров

Еще со времен, когда динамическая природа веб-сайтов ограничивалась тегом <blink>, хорошим тоном считалось хранить состояние в урле («URL-параметры», также известны как «GET-параметры» или «строка запроса»). Такой URL хоть в почту, хоть в закладки — восстановить контекст по нему не проблема:

https://potato.shop/catalog/?search=wild+potatoes&country=bo,za&size=3-7&page=5

Состояние в урле делает каждую вкладку браузера полностью автономной. Нет общих данных в локальном хранилище — нет и конфликтов. Это упрощает жизнь разработчику, а у пользователя не вскипает мозг от загадочных глюков системы.

Хранить сериализованное состояние

URL-параметры отлично справляются со скалярными значениями (строками, числами, логическими). Но меньше подходят для коллекций и более сложных структур. Поэтому программисты иногда делают так:

  • представляют состояние в виде словаря;
  • сериализуют его в Base64;
  • записывают одним параметром в URL.

Например, для такого состояния:

{
    "search": "wild potatoes",
    "country": ["bo", "za"],
    "size": { "min": 3, "max": 7 },
    "page": 5
}

Получится такой URL:

https://potato.shop/catalog/?state=eyJzZWFyY2giOiJ3aWxkIHBvdGF0b2VzIiwiY291bnRyeSI6WyJibyIsInphIl0sInNpemUiOnsibWluIjozLCJtYXgiOjd9LCJwYWdlIjo1fQ==

Гибридные подходы

Можно сохранять в урле только основные параметры, а дополнительные — в локальном хранилище:

https://potato.shop/catalog/?search=wild+potatoes&page=10

Можно основные параметры передавать явно, а дополнительные — сериализовать:

https://potato.shop/catalog/?search=wild+potatoes&state=eyJjb3VudHJ5IjpbImJvIiwiemEiXSwic2l6ZSI6eyJtaW4iOjMsIm1heCI6N30sInBhZ2UiOjV9

Встречаются и более творческие варианты.

Например, хранить состояние списка локально, а при переходе к конкретному элементу открывать его в новой вкладке — чтобы не заморачиваться с возвратом к списку.

Или реализовать «короткие ссылки» по принципу bit.ly. Сохранять полное состояние на сервере, генерить для него уникальную ссылку вроде https://potato.shop/catalog/xKda7, ее и показывать ее на клиенте.

С вариантами разобрались, теперь давайте рассмотрим один из них подробнее.

Как хранить состояние в URL-параметрах

На первый взгляд, хранить состояние в виде набора параметров URL легко и приятно.

Было:

{
    "search": "wild potatoes",
    "page": 5
}

Стало (здесь и далее примеры без url-кодирования):

?search=wild+potatoes&page=5

Действительно, строки и числа передать в параметрах несложно. С другими типами данных интереснее.

Логическое значение

Обычно используют true / false, либо 1 / 0.

Было:

{
    "search": "potatoes",
    "popular": true
}

Стало:

?search=potatoes&popular=true
?search=potatoes&popular=1

Дата и время

Обычно используют RFC3339 (2020-01-02T10:11:12Z) либо Unix Time (секунды от полночи 01.01.1970).

Было (поскольку пример в JSON, привожу дату в RFC3339; у вашего языка есть «родное» представление):

{
    "search": "potatoes",
    "after": "2020-01-02T10:11:12Z"
}

Стало:

?search=potatoes&after=2020-01-02T10:11:12Z
?search=potatoes&after=1577959872

Реже используют unix time в миллисекундах:

?search=potatoes&after=1577959872000

Пустое значение

Если какое-то свойство не задано, его обычно передают пустым, либо не передают вовсе.

Было:

{
    "search": "potatoes",
    "country": null
}

Стало:

?search=potatoes&country=
?search=potatoes

Иногда передают специальное значение (например, null):

?search=potatoes&country=null

Список (массив)

До сих пор мы имели дело со скалярными значениями. Со списками становится веселее.

{
    "search": "potatoes",
    "country": ["bo", "za"]
}

Классический вариант — повторять название свойства для каждого значения, как диктует RFC 6570:

?search=potatoes&country=bo&country=za

Иногда к названию добавляют [], чтобы показать, что это список:

?search=potatoes&country[]=bo&country[]=za

Бывает, что и индекс указывают:

?search=potatoes&country[0]=bo&country[1]=za

Любители краткой записи указывают название свойства один раз, а значения перечисляют через запятую:

?search=potatoes&country=bo,za

Словарь (карта)

Список перечислял набор значений. Словарь же содержит вложенные свойства:

{
    "search": "potatoes",
    "size": { "min": 3, "max": 7 }
}

Интернет-стандарты не предусматривают передачу составных объектов в URL. Поэтому разработчики изобрели свои варианты.

Чаще всего название основного свойства дублируют, а названия вложенных указывают в []:

?search=potatoes&size[min]=3&size[max]=7

Реже используют .-нотацию:

?search=potatoes&size.min=3&size.max=7

Вложенность больше двух уровней обычно не используют, потому что развернуть такие URL-параметры обратно в объект совсем уж нетривиально.

Итого

Основные способы сохранения локального состояния:

  • не хранить вовсе;
  • локальное хранилище;
  • набор URL-параметров;
  • сериализованный объект.

Память и локальное хранилище отлично подходят для «приватного» состояния — настроек, кешей, истории. URL-параметры — для «публичного» состояния, чтобы ссылку с состоянием можно было сохранить или переслать. URL с параметрами получается наглядным и позволяет передавать достаточно сложные структуры данных.

Строка или число
Логическое значение
Дата и время
Пустое значение
Список (массив)
Словарь (карта)

Главное, какой бы подход вы не выбрали — сохраняйте и восстанавливайте контекст прозрачно для пользователя.

Подписывайтесь на канал и рассылку, чтобы не пропустить новые заметки 🚀