Существует множество вариантов хранить данные на стороне пользователя: от старых добрых cookies до полноценной браузерной базы данных IndexedDB. Часто мы используем их для решения своих задач, искренне ожидая, что у реальных пользователей всё будет работать так же гладко, как у нас на локальном сервере. Но на деле это далеко не всегда так.
Разрабатывая фичу, мы тестируем её в своем окружении, которое часто гораздо лучше, чем у реальных пользователей. Однако пользователи открывают сайты в режиме инкогнито, используют строгие настройки приватности или банально сидят на устройствах с забитой под завязку памятью. В результате те механизмы, которые казались надежными, внезапно начинают выбрасывать ошибки или молча стирать наши заботливо сохраненные данные.
В этой статье мы не будем затрагивать совсем уж тривиальные вещи вроде хранения состояния в переменных JavaScript или в дата-атрибутах HTML-разметки. Такие данные живут ровно до первого обновления страницы. Нас будет интересовать именно персистентное хранение, когда состояние должно переживать перезагрузку вкладки или даже закрытие браузера. Давайте разберёмся, какие инструменты у нас есть и с какими реальными проблемами сталкиваются пользователи.
Доступные хранилищаДля начала базово пройдемся по тому, какие механизмы хранения нам доступны.
Web StorageНаверное, самым популярным и простым для понимания остаётся Web Storage API, который делится на localStorage и sessionStorage. Это простое синхронное хранилище, куда можно записывать данные в формате «ключ-значение». Разница между ними лишь в том, что sessionStorage живёт только пока открыта конкретная вкладка, а localStorage сохраняет данные между сессиями.
Важно помнить, что хранилище принимает только строки. Если нужно сохранить сложный объект или массив, его придётся пропустить через сериализацию. Ну и раз хранилище синхронное, запись и чтение блокируют основной поток, поэтому часто записывать и читать большие объекты будет не лучшей идеей.
Куки довольно популярное, но более строгое хранилище. По сути, это просто небольшие текстовые строки, которые браузер автоматически прикладывает к HTTP-запросам на заданный домен. Строка может содержать атрибуты, которые определяют её поведение. В стандарте прописаны очень жесткие ограничения: размер одной куки не должен превышать 4096 байт, включая её имя и атрибуты, а безопасно хранить на одном домене можно не более полусотни штук. Поскольку куки передаются на сервер при каждом запросе, они незаменимы для синхронизации состояния с сервером.
IndexedDBКогда данных становится действительно много или они имеют сложную структуру, стоит присмотреться к IndexedDB. Это уже настоящая асинхронная база данных прямо в браузере. Она поддерживает индексы, транзакции и даже версионирование схемы. Поскольку база асинхронная, она не подвешивает интерфейс во время тяжелых операций записи.
Cache APIМенее популярным хранилищем является Cache API. Это специализированное хранилище пар из HTTP-запроса и HTTP-ответа. Обычно разработчики взаимодействуют с ним через сервис-воркеры, чтобы кэшировать ресурсы и заставлять приложение работать в офлайн-режиме, создавая, например, PWA (Progressive Web Apps).
Адресная строка (URL)Иногда отличным хранилищем выступает содержимое адресной строки браузера, а именно query-параметры и хэш url-а. Чаще всего здесь хранится состояние текущей страницы, например фильтры и пагинация. Но есть множество сценариев использования этого вида хранилищ. К тому же пользователь может поделиться такой ссылкой с кем угодно, и тем самым передать ему эти данные.
File System Access API и OPFSНе лишним будет упомянуть File System Access API и его часть OPFS (Origin Private File System). Это инструмент, дающий доступ к виртуальной файловой системе прямо на диске пользователя. Именно на нём сейчас работают тяжеловесные веб-приложения вроде браузерных версий Figma или Photoshop. Однако это API довольно низкоуровневое, требует работы через Web Workers, чтобы не вешать интерфейс, и браузерная поддержка все еще очень низкая (caniuse).
Shared Storage APIShared Storage API ещё одна новая и довольно специфичная технология, которая сейчас активно внедряется в Chromium-браузерах. Она создана для решения очень узкой проблемы: как передать данные между разными сайтами, не нарушая приватность пользователя и не используя сторонние куки, которые сейчас везде блокируются. Данные в Shared Storage можно записать откуда угодно, но вот прочитать их напрямую нельзя... Доступ к ним есть только в изолированных средах, которые не могут передать прочитанную информацию обратно на страницу. Чаще всего это API используется рекламными сетями. Например, для ограничения показов одной и той же рекламы на разных сайтах, или для A/B тестирования и сбора анонимной аналитики.
Другие виды хранилищДля полноты картины можно вспомнить про WebSQL, который когда-то был попыткой внедрить реляционную базу данных на основе SQLite прямо в браузер. Сегодня этот стандарт полностью вырезан из современных версий Chrome.
Еще есть хак с глобальным свойством window.name. Объект window в браузере представляет собой глобальный контекст окна. Его можно использовать для проброса данных между страницами без сброса состояния при навигации. Раньше это поведение активно использовали, но сейчас это считается исключительно плохой практикой из-за очевидных проблем с безопасностью.
Самой частой проблемой является нехватка свободного места. В отличие от бэкенда, где можно докинуть терабайты на сервере, в браузере мы полностью зависим от устройства пользователя. Если у человека закончилось место на диске или превышена выделенная браузером квота, браузер просто выбросит ошибку QuotaExceededError.
Для localStorage квота обычно составляет скромные 5-10 мегабайт на весь источник(origin), для других хранилищ квоты могут быть другими. Если ошибку переполнения не отловить в коде, выполнение скрипта прервётся, и интерфейс приложения может сломаться. Причём важно понимать, что некоторые хранилища делят квоту между собой. Например, Cache API и IndexedDB используют общее дисковое пространство. Если приложение активно сохраняет тяжелые данные в кэш, место под пользовательские данные в базе может внезапно закончиться.
Другая классическая проблема связана с настройками приватности и режимом инкогнито. В приватном режиме поведение браузеров кардинально меняется: обычно все данные сохраняются только в оперативную память и безвозвратно удаляются, как только пользователь закрывает вкладку. То есть хранилище из персистентного превращается в сессионное.
Но настоящие проблемы начинаются, когда пользователи или их расширения начинают усиливать настройки приватности. Если человек в настройках браузера включит строгую блокировку сторонних данных (например, опцию «Блокировать сторонние файлы cookie» в Chrome), доступ к хранилищам может быть полностью перекрыт. В таких случаях любой скрипт, пытающийся обратиться к localStorage или IndexedDB (особенно если код выполняется внутри стороннего iframe или виджета), получит жесткий отказ и исключение SecurityError. И интерфейс должен быть готов к такому повороту событий, иначе получим ошибку в рантайме.
Отдельной головной болью разработчиков фронтенда в последние годы стала активная борьба браузеров с отслеживанием. Яркий пример: технология ITP (Intelligent Tracking Prevention) от Apple в браузере Safari. Если алгоритмы Safari решат, что домен используется для слежки, или если пользователь не заходил на сайт напрямую в течение семи дней, браузер молча и без предупреждений удалит вообще всё: и куки, и данные из localStorage, и базы IndexedDB. Заботливо сохраненные настройки интерфейса или черновики форм просто исчезнут.
Проверяем лимиты и предотвращаем переполнениеЧтобы не доводить до ситуации с QuotaExceededError, можно превентивно проверять, сколько места осталось. У современных браузеров для этого есть Storage API.
Вызвав метод navigator.storage.estimate(), получим объект с двумя свойствами:
quota(сколько всего байт выделено вашему источнику)usage(сколько уже занято) Имея эти данные, можно предупредить пользователя или начать подчищать старые данные до того, как база упрётся в потолок.
async function checkStorageQuota() {
if (navigator.storage && navigator.storage.estimate) {
const { quota, usage } = await navigator.storage.estimate();
// Переводим байты в мегабайты для удобства
const quotaMB = (quota / 1024 / 1024).toFixed(2);
const usageMB = (usage / 1024 / 1024).toFixed(2);
console.log(`Использовано: ${usageMB} МБ из ${quotaMB} МБ`);
// Если занято больше 80%, сообщим об этом
if (usage / quota > 0.8) {
console.warn("Память почти заполнена, запускаем очистку!");
// cleanupOldData(); Или удалим старые данные
}
} else {
console.log("Storage API не поддерживается в этом браузере.");
}
}
Также полезно знать, что браузер может в любой момент молча удалить данные (особенно из IndexedDB или Cache API), если на устройстве пользователя критически мало свободного места. Браузер сам решает, что удалять, руководствуясь своей внутренней эвристикой, и как бы вытесняет данные сайтов, которыми давно не пользовались. Чтобы этого избежать, можно запросить у браузера персистентное хранение:
async function requestPersistentStorage() {
if (navigator.storage && navigator.storage.persist) {
const isPersistent = await navigator.storage.persist();
if (isPersistent) {
console.log("Данные защищены от автоматического удаления браузером.");
} else {
console.log("Браузер может удалить данные при нехватке места на диске.");
}
}
}
Это не значит, что данные никогда не удалятся, но браузер старается защищать такое хранилище дольше, чем обычное.
Помимо запроса персистентности, считается хорошим тоном реализовывать собственные механизмы самоочистки. Если кэшируются объемные данные для офлайн-доступа, обязательно нужно внедрять логику инвалидации. Например, удалять из хранилища записи старше определённой даты или держать кэш ограниченного размера по принципу вытеснения самых старых неиспользуемых элементов.
РекомендацииВ первую очередь, всегда проверяйте доступность хранилища, с которым работаете. Всегда оборачивайте код, работающий с хранилищем, в конструкцию try/catch. Если браузер выбросит ошибку из-за нехватки места или из-за того, что пользователь запретил сайтам сохранять данные, приложение не должно упасть.
Идеально всегда иметь резервный вариант, какой-то fallback. Если запись на диск не удалась, пусть данные хранятся в переменной в памяти. Да, они пропадут при перезагрузке страницы, но хотя бы текущий сценарий использования не прервётся.
Если код работает с серверным рендерингом, стоит взять за правило всегда проверять, где именно он выполняется. Достаточно убедиться, что тип window не равен undefined. А лучше всего откладывать чтение из локальных хранилищ до момента гидратации приложения на клиенте. Это гарантирует, что серверный и клиентский HTML совпадут, и интерфейс не будет дергаться при загрузке.
Чтобы не писать все эти обёртки и проверки руками, существуют отличные библиотеки-помощники. Например, localForage. Она предоставляет очень простой интерфейс, похожий на localStorage. Библиотека сама определяет, что поддерживает браузер пользователя, и выбирает оптимальное хранилище по принципу "Graceful Degradation". Если доступна IndexedDB, то используется она, иначе библиотека откатывается до localStorage. Вдобавок она сама берёт на себя всю рутину с сериализацией сложных объектов.
О чем еще необходимо помнитьРаботая с клиентскими хранилищами, мы часто забываем о некоторых фундаментальных ограничениях самой среды.
Во-первых, всё, что сохранено в браузере, остаётся только в этом конкретном браузере на этом конкретном устройстве. Если пользователь добавил товар в корзину на рабочем ноутбуке, он очень расстроится, не увидев его в телефоне по пути домой. Если нужен бесшовный пользовательский опыт между устройствами. Тут без бэкенда и классической базы данных не обойтись.
Во-вторых, клиентская среда глубоко небезопасна по своей природе. Никогда не сохраняйте в localStorage пароли или чувствительные токены доступа. Любой сторонний скрипт, случайно оказавшийся на странице через уязвимый npm-пакет или даже расширение браузера, получит к ним полный и беспрепятственный доступ.
В-третьих, современные браузеры активно внедряют изоляцию хранилищ (Storage Partitioning). Это значит, что если виджет или плеер открыт внутри тега iframe на чужом сайте, его хранилище будет полностью изолировано от того хранилища, которое используется, когда пользователь заходит на сайт напрямую. Доступ к данным всегда жёстко привязан к конкретному источнику, а теперь еще и к контексту использования этого источника.
Ну и напоследок, не забывайте, что браузер может управлять некоторыми кэшами самостоятельно, вообще без нашего участия. Существует HTTP-кэш, куда браузер складывает скрипты и картинки, чтобы не качать их дважды. Мы не можем точечно удалить оттуда файл через JavaScript, но можем его переименовать при сборке проекта. А еще есть Bfcache (Back/Forward Cache), который сохраняет полный слепок состояния страницы, когда пользователь нажимает кнопку «Назад» в браузере. Это делает навигацию моментальной, но может сломать логику, если рассчитывалось, что все скрипты при возврате на страницу запустятся заново.
Помня об этих нюансах и закладывая запасные варианты поведения, мы повышаем надежность интерфейсов, чтобы они не развалились при первом же столкновении с суровой реальностью пользователей и их браузеров.