Корзина в localStorage: восстановление, битый JSON и новая версия данных
- 30 сентября 2026
После перезагрузки страницы корзина должна вернуться. Но сохранённая строка может оказаться повреждённой, а формат — устаревшим. Сохраним позиции с версией, проверим их при чтении и оставим корзину рабочей при ошибке хранилища.
Соберём пример для учебного каталога. Товары вымышлены; у каждой позиции есть идентификатор и количество. После нажатия «Добавить учебную кружку» на странице появится товар с productId: 7. После перезагрузки его количество восстановится, если браузер разрешает хранение и запись прошла успешно.
Общее устройство хранилища уже разобрано в статье «Что такое localStorage». Здесь займёмся корзиной, которую можно прочитать после изменения кода.
Сохраните позиции с версией
Для текущего формата выберем версию два. Под ключом academy-demo-cart хранится такая строка JSON:
{"version":2,"items":[{"productId":7,"quantity":2}]}
Ограничим учебную корзину: не больше 100 разных товаров, количество каждого — от одного до 99. Идентификатор должен быть положительным безопасным целым числом. Два ряда с одним productId считаем ошибкой формата: такая корзина затруднит изменение количества.
Цену и название восстанавливайте из актуального каталога по идентификатору. Сохранённая цена может устареть; при оформлении заказа сервер проверяет стоимость и доступность товара заново. Платёжные данные и секреты в этот пример не входят.
У старой версии был другой договор: {version: 1, lines: [{id: 7, count: 2}]}. Мы знаем, как преобразовать его в новый. Если встретится версия три, текущий код остановит запись: так он не затрёт данные, которые могла сохранить более новая сборка приложения.
Проверьте каждую позицию
Сохраните следующие функции в cart.js. Они работают с уже разобранными данными; обратиться к хранилищу нам ещё предстоит.
export const CART_KEY = 'academy-demo-cart';
const MAX_LINES = 100;
const MAX_QUANTITY = 99;
function isRecord(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export function validateItems(items) {
if (!Array.isArray(items) || items.length > MAX_LINES) {
throw new Error('Неверный список корзины');
}
const seen = new Set();
return Array.from(items, (item) => {
if (!isRecord(item) || !Number.isSafeInteger(item.productId) ||
item.productId <= 0 || !Number.isSafeInteger(item.quantity) ||
item.quantity < 1 || item.quantity > MAX_QUANTITY ||
seen.has(item.productId)) {
throw new Error('Неверная позиция корзины');
}
seen.add(item.productId);
return {productId: item.productId, quantity: item.quantity};
});
}
Number.isSafeInteger отклонит строки с цифрами, дроби и NaN. Все товары удалены? Сохраните пустой массив. Это корректное состояние корзины. Нулевая позиция, напротив, не нужна: удалите её из массива.
Функция создаёт новые объекты только с нужными свойствами. Даже если рядом с количеством записана цена, в результат она не попадёт. Множество seen помогает заметить повтор идентификатора без второго прохода по всему списку.
Попробуйте предсказать, прочитает ли JSON.parse строку {"version": 2, "items": "товары"} и можно ли после этого использовать её как корзину. Запишите свой вывод. Затем сверьте его с разбором.
Успешный JSON.parse сообщает только, что строка соответствует синтаксису JSON: null и {"version": 2, "items": "товары"} тоже допустимы, хотя позиций корзины в них нет. Формат придётся проверить отдельно. Имена свойств в JSON заключаются в двойные кавычки; MDN приводит примеры недопустимого синтаксиса.
Для самопроверки разберите эту строку в консоли через JSON.parse. После добавления функции decodeCart из следующего блока передайте ей результат: разбор JSON пройдёт, а проверка корзины даст ошибку «Неверный список корзины».
Преобразуйте известный старый формат
Добавьте в cart.js функцию разбора версии. Названия полей меняются только в известной миграции из версии один в версию два.
export function decodeCart(value) {
if (!isRecord(value) || !Number.isSafeInteger(value.version)) {
throw new Error('Неверный формат корзины');
}
if (value.version === 2) {
return {items: validateItems(value.items), canPersist: true, reason: 'ok'};
}
if (value.version === 1) {
if (!Array.isArray(value.lines) || value.lines.length > MAX_LINES) {
throw new Error('Неверная старая корзина');
}
const items = value.lines.map((line) => {
if (!isRecord(line)) throw new Error('Неверная старая позиция');
return {productId: line.id, quantity: line.count};
});
return {items: validateItems(items), canPersist: true, reason: 'migrated'};
}
return {items: [], canPersist: false, reason: 'unsupported'};
}
Миграция переименовывает поля и прогоняет позиции через ту же проверку. Старый контракт тоже требовал число. Поэтому строковый count: '2' не превратится в количество случайно и завершит разбор ошибкой, как неверное поле в текущей версии.
У неизвестной числовой версии возвращаем пустую корзину для текущей вкладки и canPersist: false. Запись остановлена. Пользователь сможет изменить временную корзину, но исходная строка останется в хранилище; текущая программа не знает, какие правила нужны для её разбора. Автоматически принимать любую версию как текущую мы не рекомендуем.
Прочитайте строку с запасным состоянием
В браузере может завершиться ошибкой уже обращение к window.localStorage, например из-за политики хранения. Вызов находится внутри try; подробности ограничений есть в описании localStorage на MDN.
export function readCart(getStorage = () => window.localStorage) {
let raw;
try {
raw = getStorage().getItem(CART_KEY);
} catch {
return {items: [], canPersist: false, reason: 'unavailable'};
}
if (raw === null) return {items: [], canPersist: true, reason: 'empty'};
try {
return decodeCart(JSON.parse(raw));
} catch {
return {items: [], canPersist: true, reason: 'invalid'};
}
}
У отсутствующего ключа результат getItem — null. Читать пока нечего. Возвращаем пустую корзину. Повреждённая строка или неверные позиции также дают запасную пустую корзину; функция ничего не записывает и не удаляет, чтобы само открытие страницы не изменяло сохранённые данные.
Различайте причины в интерфейсе. При invalid сообщите, что сохранённую корзину восстановить не получилось и можно начать новую. При unsupported объясните, что сохранение временно остановлено. Когда пользователь изменит новую корзину после повреждения, следующая успешная запись заменит прежнюю строку. Само открытие страницы её не затирает.
Параметр getStorage нужен для проверки ошибок: тест может передать хранилище, которое выбрасывает исключение. При обычном вызове readCart() используется браузерное хранилище.
Запишите корзину после изменения
Добавьте последнюю функцию в cart.js. Формат на запись всегда текущий, а разрешение canPersist берётся из результата чтения.
export function saveCart(items, canPersist, getStorage = () => window.localStorage) {
if (!canPersist) return false;
const value = {version: 2, items: validateItems(items)};
try {
getStorage().setItem(CART_KEY, JSON.stringify(value));
return true;
} catch {
return false;
}
}
Позиции проверяем перед try намеренно: неверная модель в памяти — ошибка нашего кода, её не следует скрывать сообщением о переполненном хранилище. Внутри try ловим сбой самой записи. Если закончилось доступное место, setItemможет выбросить QuotaExceededError.
В main.js заведите состояние один раз при запуске. Для короткого примера подготовьте в HTML блоки <p id="cart"></p>, <p id="status" role="status"></p> и кнопку <button id="add" type="button">Добавить учебную кружку</button>.
import {readCart, saveCart} from './cart.js';
const restored = readCart();
let items = restored.items;
const list = document.querySelector('#cart');
const status = document.querySelector('#status');
function render() {
list.textContent = items.length
? items.map((item) => `Товар ${item.productId}: ${item.quantity} шт.`).join('; ')
: 'Корзина пуста';
}
document.querySelector('#add').addEventListener('click', () => {
const line = items.find((item) => item.productId === 7);
if (line?.quantity === 99 || (!line && items.length >= 100)) {
status.textContent = 'Достигнут лимит учебной корзины';
return;
}
items = line
? items.map((item) => item.productId === 7
? {...item, quantity: item.quantity + 1} : item)
: [...items, {productId: 7, quantity: 1}];
render();
status.textContent = saveCart(items, restored.canPersist)
? 'Корзина сохранена'
: 'Изменения останутся в этой вкладке до перезагрузки';
});
render();
const messages = {
ok: 'Корзина восстановлена', empty: 'Сохранённой корзины пока нет',
migrated: 'Прочитан старый формат; следующая запись обновит его до версии 2',
invalid: 'Сохранённая корзина повреждена; начните новую',
unavailable: 'Хранилище недоступно; корзина работает до перезагрузки',
unsupported: 'Формат неизвестен; сохранённые данные оставлены без изменений',
};
status.textContent = messages[restored.reason];
Состояние меняется в памяти до записи. Корзина уже на экране. Даже если места нет, добавленный товар остаётся в массиве, а пользователь видит сообщение о том, что изменения пропадут после перезагрузки страницы. Сообщение «Корзина сохранена» показываем только после успешного setItem.
Подключите main.js как модуль и откройте страницу через сервер разработки по HTTP. Для страниц file: поведение localStorage не определено единообразно. HTTP и HTTPS, а также разные порты дают разное хранилище. В инструментах разработчика проверяйте origin — сочетание протокола, хоста и порта страницы.
Этот пример читает корзину при запуске и пишет после действия. Согласованного обновления между несколькими вкладками в нём нет: для этого потребуются отдельная обработка изменений и правила конфликтов.
Испытайте восстановление корзины
Откройте хранилище во вкладке Application или Storage инструментов разработчика. Меняйте только academy-demo-cart; localStorage.clear() удалит и остальные настройки этого origin, поэтому для проверки корзины он не подходит.
| Содержимое ключа | Ожидаемый результат |
|---|---|
| Ключ отсутствует | Пустая корзина, запись разрешена. |
| Текущий формат с количеством два | Товар восстановлен. |
Одна открывающая скобка { | Пустая корзина с сообщением о повреждении. |
Версия один с id и count | Позиция преобразована; следующая запись сохранит версию два. |
| Версия три | Временная пустая корзина; исходная строка остаётся после добавления. |
Текущая версия с повторным productId | Повреждённая корзина; дубли не складываются автоматически. |
Доработка с подсказкой: добавьте кнопку удаления кружки. Используйте filter. Полученный новый массив сохраните через saveCart с тем же разрешением, которое было возвращено при чтении корзины. Последний товар удалён? Должен сохраниться items: []. После перезагрузки кружка не вернётся.
Проверьте новую версию самостоятельно
Для самостоятельной задачи подготовьте версию три с другим именем поля количества. Добавьте только известный переход из версии два и отрицательный пример с неверным количеством. До изменения миграции проверьте, что старая программа действительно оставляет такую строку нетронутой.
Продолжить работу с состоянием приложения можно на курсе «JavaScript. Архитектура клиентских приложений». Сначала сохраните и восстановите свою корзину, включая сценарий повреждения данных.
Для самопроверки: почему корректный JSON ещё не означает корректную корзину? И какое сообщение должен увидеть пользователь, если товар добавился, а запись завершилась ошибкой квоты?
«Доктайп» — журнал о фронтенде. Читайте, слушайте и учитесь с нами.