Сохранение в localStorage

проект≈ 7 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Пока страница открыта, данные живут в переменных — стоит нажать F5, и всё исчезает. localStorage — самый простой способ этого избежать: положил строку, закрыл вкладку, вернулся через неделю, а строка на месте.

Разделы темы:·

localStorage — маленький шкафчик, который браузер держит для каждого сайта отдельно. Внутри пары «ключ — значение», и всё это переживает перезагрузку страницы, закрытие вкладки и выключение компьютера. Шкафчик привязан к origin — схеме, домену и порту; на другом домене, да и на соседнем поддомене, будет уже другой шкафчик. У примеров этой статьи шкафчик свой: кадр песочницы живёт в уникальном происхождении, настоящий localStorage в нём запрещён, и учебник подставляет вместо него хранилище в памяти кадра. Оно ведёт себя как настоящее, но каждый запуск примера начинает память заново — «вернулся через неделю» проверяется только на своей странице.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Три метода закрывают почти всё: setItem, getItem и removeItem. Есть ещё clear(), но он сносит все данные сайта разом, поэтому зовут его редко и осознанно. А главная ловушка новичка — вот она: хранилище знает только строки. Число, положенное внутрь, вернётся строкой.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ключи можно перебрать: есть localStorage.length и localStorage.key(i), но проще взять Object.keys(localStorage). Отсюда полезная привычка — давать своим ключам общий префикс вроде app:. Так вы отличите своё от чужого (аналитики, виджеты и старые версии кода тоже пишут сюда) и сможете почистить только собственные записи.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Объект в хранилище напрямую не положишь. setItem молча приводит значение к строке — и вместо данных вы получаете знаменитую бесполезную надпись.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Правильный путь — сериализация: JSON.stringify сворачивает структуру в строку, JSON.parse разворачивает обратно. Числа, строки, булевы значения, массивы и вложенные объекты доезжают без потерь — этого хватает для настроек, черновиков и небольшого кэша.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

JSON понимает строки, числа, булевы значения, null, массивы и простые объекты — и больше ничего. Свойства со значением undefined и функции просто исчезают, Map и Set превращаются в пустой объект, а Date становится строкой: при чтении дату придётся оживлять руками через new Date(saved.when).

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Пустое хранилище — это норма, а не сбой: при первом визите там ничего нет, и getItem честно возвращает null. Приятная мелочь: JSON.parse(null) не падает, потому что null сначала превращается в строку "null". Значит, весь разбор «пустоты» сводится к оператору ??.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

А вот испорченная строка валит JSON.parse исключением — и вместе с ним весь скрипт. Испортить её несложно: старая версия кода писала другой формат, запись оборвалась на переполнении, кто-то поправил значение руками. Поэтому чтение оборачивают в try/catch и всегда держат наготове значение по умолчанию.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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

Шкафчик не бездонный: браузеры выделяют на один origin порядка нескольких мегабайт — ориентир около 5 МБ, текущий Chrome вмещает около 5,2 млн символов. Квота считает символы байтами UTF-16, по два на символ (эмодзи суррогатной парой — вдвое дороже), то есть один символ — это около двух байт, и обещанные мегабайты кончаются вдвое быстрее, чем подсказывает длина текста. Когда место заканчивается, setItem бросает исключение — обычно с именем QuotaExceededError.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Отсюда правило-близнец: запись тоже заворачиваем в try/catch. Тот же приём спасает в приватном режиме и при запрете на хранение данных — там обращение к localStorage может упасть ещё до всякой квоты, и падение не должно уносить с собой всю страницу.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Хранилище отлично держит мелочи, которые не жалко потерять: тему оформления, черновик комментария, последний открытый раздел. Но границы у него жёсткие. Токены и пароли сюда не кладут — данные лежат на диске в открытом виде, и любой скрипт на странице читает их целиком, так что одна XSS-дыра сдаёт всё разом. И ещё: записи не путешествуют между устройствами и браузерами, поэтому всё, что должно быть «у пользователя везде», живёт на сервере.

Второе ограничение — синхронность. Каждый setItem выполняется в вашем же коде и держит главный поток, пока значение не окажется в хранилище, так что сохранять на каждое нажатие клавиши — плохая идея: дождитесь паузы в вводе и запишите один раз. Срока годности у записей тоже нет — если он нужен, кладите рядом метку времени и проверяйте её при чтении.

Где хранитьСколько влезетСколько живётЧто туда класть
localStorageоколо 5 МБдо ручной очисткинастройки, черновики, мелкий кэш
sessionStorageоколо 5 МБдо закрытия вкладкишаги формы, временный выбор
Cookieоколо 4 КБпо сроку из атрибутовто, что нужно серверу в каждом запросе
IndexedDBсотни МБ и большедо ручной очисткимного данных, файлы, офлайн
Серверсколько дадитепока жив аккаунтвсё, что нужно на другом устройстве

Напоследок приятная деталь: соседние вкладки того же сайта узнают об изменениях сами — браузер шлёт им событие storage. А вот вкладка, которая записала значение, своего же события не получит.

  • localStorage хранит пары строк, живёт до ручной очистки и не выходит за пределы одного origin.
  • Объекты и массивы кладут через JSON.stringify и достают через JSON.parse; Date, Map, Set и undefined дорогу не переживают.
  • Отсутствующий ключ — это null, а испорченная строка — исключение: читайте через try/catch и всегда с значением по умолчанию.
  • Лимит около 5 МБ на сайт; переполнение и приватный режим ловятся тем же try/catch вокруг записи.
  • Секреты, большие объёмы и всё, что нужно на других устройствах, отправляйте мимо localStorage — в IndexedDB или на сервер.

1/3Что выведет typeof после записи числа: localStorage.setItem("n", 5); typeof localStorage.getItem("n")?

2/3Ключа "draft" в хранилище нет. Что сделает JSON.parse(localStorage.getItem("draft"))?

3/3Что из этого не стоит хранить в localStorage?

Счётчик запусков

Сделайте счётчик визитов на ключе app:count. Функция readCount() возвращает число (при первом запуске — 0), функция bump() увеличивает счётчик на единицу, сохраняет его и возвращает новое значение. Помните: хранилище отдаёт строки, а при первом запуске там пусто.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Читаем строку, превращаем в число и подстраховываемся нулём; при записи setItem сам приведёт число к строке.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Хитрость в Number(raw) || 0: если в ключе окажется мусор, Number("ой") даст NaN, и мы мягко упадём на ноль вместо того, чтобы получить NaN во всех дальнейших вычислениях.

Черновик, переживающий испорченные данные

Черновик формы — это объект { title, text }. Напишите saveDraft(draft) и loadDraft() для ключа app:draft. Условия: сохранение переживает перезагрузку, чтение возвращает { title: "", text: "" }, если ключа нет или в нём лежит не-JSON, и ни одна из функций не бросает исключений.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Обе стороны закрываем try/catch: запись может упасть по квоте, чтение — на испорченном JSON.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Проверка typeof data === "object" нужна на случай, когда в ключе лежит валидный, но не тот JSON — например строка "42". А { ...EMPTY, ...data } достраивает поля, которых не оказалось в сохранённом объекте: старая версия кода могла писать только title.