Главная / Статьи

Как хранилище браузера тайно повреждает данные пользователей и как этого избежать

Хранилище браузера может повредить данные пользователя без появления ошибок: потеря обновлений, устаревшие снимки состояния, изменение структуры данных и совместное использование устройств. Эффективные подходы к использованию localStorage.

Издатель: REACTAPP.TOP14 мин чтения3109 слов

Источники: The Browser Storage Mistake That Quietly Corrupts User Data

Хранение данных в браузере кажется поразительно простым. Сериализуйте объект с помощью JSON.stringify, снова прочитайте его с помощью JSON.parse, и предпочтения пользователя, незаконченная форма, фильтры или черновики сохранятся после следующей загрузки. Никаких схем, никаких сетевых запросов, никакой миграции на сервер. Несколько строк JavaScript могут сделать приложение казаться постоянным в течение нескольких минут.

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

Проблемы обычно начинаются с одного большого объекта состояния. Приложение загружает его полностью из localStorage, изменяет какое-то свойство в памяти и снова записывает всё обратно. Каждый шаг кажется разумным: JSON валиден, запись проходит успешно, и на экране отображается ожидаемое значение. Ничего не кажется сломанным.

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

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

Вина не столько в API хранения. Ошибка заключается в ожидании, что простой хранилище строк, где побеждает последняя запись, будет вести себя так, будто оно знает о записях, полях, транзакциях, версиях, правах собственности и одновременном доступе. На самом деле оно ничего об этом не знает и просто запоминает последнюю строку, присвоенную определенному ключу.

Невинная операция читать-изменить-записать

Типичная реализация выглядит следующим образом:

function updatePreferences(changes) {
  const saved = localStorage.getItem("user-settings");
  const settings = saved ? JSON.parse(saved) : {};

  const nextSettings = {
    ...settings,
    ...changes,
  };
  localStorage.setItem(
    "user-settings",
    JSON.stringify(nextSettings)
  );
}

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

Риск возникает, когда между считыванием и записью проходит время или когда приложение хранит копию объекта в оперативной памяти.

Предположим, две вкладки загружают одни и те же настройки:

{
  "theme": "light",
  "fontSize": 16,
  "sidebarCollapsed": false
}

Первая вкладка меняет тему на темную. Вторая увеличивает размер шрифта до восемнадцати. Если каждая из них редактирует свою собственную копию в памяти и сохраняет весь объект, изменения той, которая сохраняет последней, стираются.

Первая вкладка может хранить следующее:

{
  "theme": "dark",
  "fontSize": 16,
  "sidebarCollapsed": false
}

Позже вторая вкладка, всё ещё содержащая более старый снимок, сохраняет следующее:

{
  "theme": "light",
  "fontSize": 18,
  "sidebarCollapsed": false
}

Изменение размера шрифта сохраняется, а тема тихо возвращается к светлой версии.

Это повреждение из-за некорректной обновления. Конечный объект не имеет ошибок форматирования, и каждое поле содержит допустимое значение. Проблема в том, что одно корректное обновление исчезло без каких-либо признаков конфликта.

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

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

Снимок состояния — это не текущее состояние

Более серьезная ошибка — это вера в то, что объект, загруженный из хранилища, остается подключенным к нему. Это не так. JSON.parse создает временную копию хранящейся строки в памяти в определенный момент времени; после этого это лишь снимок состояния. Тем временем в другой вкладке может быть обновлен ключ, компонент может его заменить, или новая версия приложения может перенести данные, при этом копия в памяти об этом не знает.

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

Задержка сохранения увеличивает время между действиями пользователя. Многие приложения используют механизм дебаунсинга для предотвращения постоянного сохранения данных, поэтому функция localStorage.setItem не выполняется при каждом нажатии клавиши. Пользователь вносит изменения в поле, сохранение запланируется на несколько сотен миллисекунд позже, и клоузур сохраняет объект состояния в тот момент. До того, как произойдёт сохранение, другая вкладка или компонент могут сохранить более свежие данные. Когда обратный вызов наконец выполняется, он записывает свою старую копию данных поверх новых значений.

Механизм дебаунсинга улучшал производительность, но снижал согласованность, поскольку никто не определил, что делать со старыми копиями данных.

Асинхронная инициализация приводит к аналогичной ситуации. Компонент отрисовывается с параметрами по умолчанию, пользователь сразу начинает взаимодействовать с интерфейсом, а сохранённое состояние поступает позже через эффекты фреймворка или механизмы абстракции. Затем на этапе гидратации текущее состояние заменяется на более старый сохранённый снимок. Для пользователя кажется, что изменения отменяются сразу после их внесения.

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

Персистентность без транзакций

localStorage обычно описывается как синхронное хранилище в формате «ключ-значение». Это верно, но это может создавать ложное чувство уверенности, превышающее реальные возможности данного API.

Один вызов setItem синхронно заменяет значение в текущем контексте. Эта атомарность касается только одного вызова. Последовательность чтения, изменения и записи, распределённая на несколько шагов, не является транзакцией между вкладками или рабочими процессами. Нет механизма сравнения и замены, блокировки строк, конфликтов версий, объединения полей на уровне элементов и возможности отката.

Концептуально последовательность остаётся прежней:

const current = read();
const next = modify(current);
write(next);

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

Разделение категорий на отдельные ключи снижает вероятность конфликтов. Тема, хранящаяся под одним ключом, не может перезаписать не связанный с ней черновик, находящийся под другим ключом. Это лучше, чем хранение одного огромного объекта состояния, хотя это не решает всех проблем с одновременным доступом, поскольку два пользователя всё равно могут конкурировать за один и тот же черновик или запись предпочтений.

Браузер также генерирует событие storage, чтобы сообщить другим документам о изменении ключа localStorage. Это способствует синхронизации, но не является механизмом транзакций. Как только другая вкладка получает это событие, она уже может иметь действия, выполненные на устаревшей версии данных, или запланировать запись для восстановления более старой версии. Событие сообщает о том, что произошла ещё одна запись. Приложению всё равно приходится решать, как согласовать её с несохранёнными локальными изменениями.

Здесь многие реализации становятся сложными. Разработчики добавляют обработчики событий, объединяют объекты, обновляют хранилища и подавляют циклические записи, и вскоре они создают частичную систему синхронизации на основе API, выбранного из-за кажущейся простоты по сравнению с настоящим слоем данных.

Всё это не означает, что localStorage не должен хранить данные приложения. Это означает, что необходимые гарантии должны совпадать с тем, что предоставляет API. Для небольших значений с явным владельцем и допуском к перезаписи это хороший вариант. Однако такой подход становится хрупким, когда несколько пользователей независимо редактируют одну общую, структурированную запись.

Один большой JSON-объект создаёт большой радиус поражения

Хранение всего состояния под одним ключом удобно, поскольку для восстановления данных требуется лишь одно чтение и парсинг. Однако это также означает, что каждая обновление перезаписывает всё содержимое.

Изменение настройки боковой панели приводит к перезаписи всего остального, что использует этот объект: незавершенных черновиков, метаданных авторизации, недавних поисков, отклонённых баннеров, значения формы из кэша. Если автор сохраняет устаревшие данные хотя бы для одного из этих элементов, независимая правка возвращает прежнее значение. Чем больше тем включено в один ключ, тем выше вероятность того, что один автор не увидит изменения другого.

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

Разделение хранилища по владельцу и жизненному циклу снижает количество таких конфликтов. Предпочтения тем, временные черновики, отклонённые уведомления и записи кэша не обязаны иметь общую версию, политику истечения срока действия или путь записи. Независимые ключи затрудняют ситуацию, когда изменения в одной области стирают данные из другой.

Разделение также не должно быть механическим. Создание отдельного ключа для каждой мелкой характеристики делает систему неуправляемой, а при необходимости изменения нескольких значений одновременно могут возникнуть противоречивые комбинации. Хорошей границей для разделения является элемент состояния, имеющий единого владельца, единый схематизм и единое правило обновления. Значения, которые должны сохранять целостность как единое целое, могут находиться вместе. Значения, меняющиеся по независимым причинам, не получают никакой выгоды от совместного хранения копий, кроме дополнительной уязвимости.

Рассматривайте ключ как средство хранения одной целостной записи. Он не должен заменять всю память приложения.

Изменение схемы превращает старые данные в новые ошибки

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

Предположим, более старая версия хранила булево значение:

{
  "notifications": true
}

В более новой версии его заменяют на более сложный объект:

{
  "notifications": {
    "email": true,
    "push": false
  }
}

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

Переименованные поля также вызывают подобные проблемы. Оставшаяся флаговая переменная darkMode может соседствовать с более новым значением theme. Одна часть приложения считывает новое поле, а старая часть записывает старую структуру данных, в результате чего предпочтения пользователя меняются непредсказуемо между сессиями.

TypeScript не решает эту проблему. Компилятор может описать текущую ожидаемую структуру данных, но функция JSON.parse возвращает данные, сгенерированные ранее неизвестным кодом во время выполнения. Утверждение типа заглушает предупреждения компилятора, что затрудняет обнаружение несоответствий:

const settings =
  JSON.parse(savedValue) as CurrentSettings;

Эта строка не преобразует старое хранилище в текущую схему. Она лишь указывает TypeScript на необходимость доверия к нему.

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

Номер версии, хранящийся рядом с данными, делает переходы более понятными:

{
  "version": 3,
  "data": {
    "theme": "dark"
  }
}

Номер версии не выполняет миграцию, но указывает коду, какие предположения по-прежнему справедливы.

Успешный парсинг — это не гарантия безопасности

Разработчики часто оборачивают JSON.parse в блоки try/catch и обеспечивают защиту уровня хранения данных. Это помогает справляться с некорректным JSON, но синтаксическая корректность — это лишь первое требование.

Объект после парсинга может не содержать необходимых полей, иметь невозможные значения, соответствовать устаревшей схеме или быть изменённым вручную с помощью инструментов разработчика или расширений. Значения также могут иметь правильный тип, но неверное значение. Размер страницы, сохранённый как -500, является допустимым числом в соответствии с правилами JSON. Тема с названием "ultraviolet" — допустимая строка. Черновик, в котором отсутствует идентификатор, связывающий его с документом, также считается допустимым объектом. Ни один из них не заслуживает автоматического доверия в качестве состояния приложения, поскольку браузер хранит только строки и ничего не знает о правилах вашего домена.

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

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

Идентификатор должен быть частью контракта хранения

Общие браузеры создают ещё одно ложное предположение — что хранилище принадлежит тому, кто вошёл в систему. На самом деле оно принадлежит источнику браузера.

Представьте, что один человек выходит из приложения, а другой входит в него на том же устройстве. Оставшиеся черновики, фильтры, настройки или кэшированные данные остаются на месте, если только приложение их не удаляет или не помечает специальными именами. Общие ключи вроде draft, recent-items или dashboard-settings могут привести к тому, что данные первого пользователя попадут в сессию второго. Даже если речь не идет о чем-то конфиденциальном, это считается повреждением данных, поскольку действительные значения прикрепляются к неверному пользователю.

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

Более тщательно спроектированная система сортирует данные по критериям: уникальные для пользователя, уникальные для сессии, уникальные для устройства и общедоступные. Записи, принадлежащие пользователю, могут содержать его идентификатор в ключе или в объекте-обертке, и процедура проверки может подтвердить, что сохранённый владелец совпадает с авторизованным пользователем, прежде чем принять значение.

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

Шифрование не устраняет повреждения данных

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

Шифрование сохраняемых данных никак не предотвращает перезапись одной вкладки другой. Оно не добавляет функций транзакций, проверки версий, определения владельца или миграции данных. Приложение может без проблем зашифровать устаревшую копию данных, а затем так же без проблем уничтожить более свежую копию. Иногда команды прибегают к кодированию, заслонению или шифрованию, чтобы решить проблему, которая на самом деле является проблемой проектирования. Такие меры могут способствовать сохранению конфиденциальности в определенных сценариях угроз, но они не делают процесс обновления более надежным.

Аналогично, переход с localStorage на IndexedDB позволяет получить больший объем хранения, структурированное хранение, возможность индексации и транзакционное поведение. Однако сам по себе он не гарантирует правильной логики приложения. Транзакция должна охватывать нужные записи, обновления по-прежнему должны соблюдать правила принадлежности данных, а обновление схемы требует дополнительной разработки. Эта технология предоставляет более мощные примитивы, и приложение должно использовать их в рамках четкой модели консистентности.

Начните с определения принадлежности данных

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

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

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

Для важных записей, которыми пользуются несколько устройств, хранилище браузера вообще не должно считаться источником истины. Оно может служить офлайн-кэшем или временной рабочей копией, в то время как сервер хранит авторитетные версии и разрешает конфликты.

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

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

Сохраняйте данные, не тайком их переинтерпретируйте

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

Представьте себе устаревшую вкладку, которая изменяет одно поле, но возвращает пять старых значений; задержанную обратную вызов, уничтожающую более новую версию документа; старую версию приложения, сохраняющую устаревшую схему; или новый процесс входа, наследующий состояние предыдущей учетной записи. С точки зрения API хранилища каждая из этих операций считается успешной. Порча данных возникает потому, что приложение так и не определило версии, права владения или правила разрешения конфликтов.

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

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

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