Як зберігання в браузері тихо пошкоджує дані користувача та як цього уникнути
Зберігання в браузері може пошкодити дані користувача без жодних помилок: втрачені оновлення, застарілі знімки, зміни схеми та спільне використання пристроїв. Ефективні підходи для localStorage.
Джерела: 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" є допустимою рядком. Чорновик, у якого відсутній ID, що пов’язує його з документом, є допустимим об’єктом. Жоден з них не заслуговує на автоматичну довіру як стан додатку, оскільки браузер зберігає рядки та нічого не знає про правила вашого домену.
Надійна обробка даних перевіряє те, що додаток готовий прийняти, використовуючи бібліотеки схем, невеликі вручну написані парсери чи ретельно розроблені конструктори. Важливо те, що процес перевірки забезпечує відому внутрішню модель замість того, щоб подавати у додаток сирі дані після обробки.
У процесі перевірки також визначаються значення за замовчуванням. Відсутня необов’язкова налаштування може безпечно отримати значення за замовчуванням. Відсутній ідентифікатор у незбереженому проєкті може означати, що його неможливо додати до жодного документа, і його не варто автоматично відновлювати. Розглядати обидва випадки як безневинну відсутність створює хибну впевненість, яка приховує проблеми. Захід за замовчуванням не повинен приховувати невизначеність. Якщо додаток не може підтвердити, що збережені дані належать поточному користувачеві, схемі та запису, їх відновлення може завдати більше шкоди, ніж ігнорування.
Ідентичність має бути частиною угоди про зберігання
Спільні браузери створюють ще одне хибне припущення — що зберігання належить тому, хто увійшов у систему. Насправді воно належить походженню браузера.
Уявіть собі, що одна людина виходить з системи, а інша входить на тому самому пристрої. Залишки чернеток, фільтрів, налаштувань чи кешованих даних залишаються, якщо лише додаток їх не видаляє чи не присвоює їм окремі іменовані простори. Універсальні ключі, такі як draft, recent-items чи dashboard-settings, можуть призвести до того, що дані першої людини потраплять у сесію другої. Навіть якщо там немає нічого конфіденційного, це сприймається як пошкодження даних, оскільки коректне значення було присвоєне неправильному користувачеві.
Видалення всіх даних під час виходу з системи допомагає, але є занадто радикальним заходом. Це може призвести до видалення законних налаштувань пристрою, таких як мова чи налаштування доступності, а також може зазнати невдачі, якщо вихід з системи відбувається в іншій вкладці, тоді як поточна вкладка все ще зберігає копію даних у оперативній пам’яті.
Більш ретельно спланована структура сортує дані за критеріями: специфічні для користувача, сеансу, пристрою та загальнодоступні. Записи, якими володіє користувач, можуть містити його ідентифікатор у ключі або у об’єкті-обгортці, а процес ініціалізації даних дозволяє підтвердити, що збережений власник збігається з автентифікованою особою, перш ніж прийняти значення.
Часто для чернеток також потрібен ідентифікатор документа чи ресурсу. Відновлення „найновішої чернетки“ у глобальному масштабі є небезпечним, коли відкрито кілька записів, адже контент з одного може з’явитися в іншому, якщо у ключі не було вказано, який об’єкт ним володіє. Нормування імен є чимось більшим, ніж просто порядок — воно запобігає потраплянню коректних даних у неправильний контекст.
Шифрування не виправляє пошкодження
Обговорення ризиків зберігання даних у браузері часто зосереджуються на безпеці. Конфіденційна інформація не повинна знаходитися без нагляду у зберіганні, доступному через браузер, особливо там, де скрипти можуть її прочитати. Це справді проблема, але вона відрізняється від проблеми з послідовністю.
Шифрування збережених даних нічим не запобігає перезапису однієї вкладки іншою. Воно не додає функцій транзакцій, перевірки версій, визначення власника чи міграції даних. Додаток може без проблем зашифрувати застарілий знімок даних, а потім так само без проблем знищити більш свіжий. Команди іноді вдаються до кодування, засліплення чи шифрування, щоб вирішити проблему, яка насправді є проблемою проектування. Ці заходи можуть допомогти зберегти конфіденційність у певній моделі загроз, але процес оновлення залишається таким само ненадійним.
Аналогічно, перехід від localStorage до IndexedDB може забезпечити більший обсяг пам’яті, структуроване зберігання, індексування та транзакційну поведінку. Однак сам по собі це не створює правильної логіки додатку. Транзакція має охоплювати відповідні записи, оновлення все одно мають дотримуватися правил власності, а оновлення схеми все ще потребують розробки. Ця технологія пропонує більш потужні примітиви, і додаток має використовувати їх відповідно до чіткої моделі послідовності.
Почніть з визначення власника
Замість того, щоб запитувати, який API зберігання обрати, спочатку визначте, хто є власником даних та як слід обробляти зміни від іншого користувача.
Звичайні налаштування часто можуть працювати за принципом „останній запис — останній переможець“. Якщо два вкладки встановлюють різні теми, збереження найновішої може бути саме тим, що потрібно, проте додаток все одно має стежити за змінами в інших вкладках, якщо візуальний інтерфейс потребує негайного оновлення.
Незавершений чернеток — це вже інша справа. Правило „останній, хто написав, перемагає“ може коштувати користувачеві справжньої роботи, тому запису може знадобитися номер ревізії, час створення, ідентифікатор документа та політика вирішення конфліктів. Перед перезаписом додаток може порівняти ревізії, зберегти обидві конфліктуючі версії або попередити користувача про те, що інша вкладка змінила той самий чернеток.
Для важливих записів, які діляться між пристроями, пам’ять браузера зовсім не повинна бути джерелом істини. Вона може виконувати функцію офлайн-кешу чи тимчасової робочої копії, тоді як сервер зберігає авторитетні версії та вирішує конфлікти.
Правильний дизайн залежить від того, наскільки цінні дані та скільки користувачів ними користуються. Якщо порівняти налаштування відображення з юридичною формою, чернеткою довгої статті чи офлайн-фінансовим записом, ступінь ризику значно відрізняється. Розглядати їх усі як рядки під довільними ключами означає ігнорувати наслідки втрати одного оновлення.
Єдиний шлях запису також допомагає. Коли будь-який компонент може читати, об’єднувати та замінювати ту саму ключову інформацію у будь-який час, ніхто не може перевіряти правила послідовності. Один модуль зберігання може централізувати процеси верифікації, міграції, синхронізації та вирішення конфліктів. Для цього не потрібен складний рівень репозиторію, створений навколо кількох налаштувань. Це просто запобігає тому, щоб код зберігання ставав все більш складним, аж доки ніхто не зможе визначити, який саме знімок є поточним.
Зберігайте дані, не таємно їх не переінтерпретуйте
Найнебезпечніші помилки в системі зберігання не проявляються через QuotaExceededError чи невдачу парсингу JSON. Це помилки, які видимі, їх можна записувати, тестувати та усувати. Найбільшої шкоди завдає запис, який вдається, але зберігає неправильну версію інакше коректних даних.
Уявіть собі застарілий таб, який змінює одне поле та повертає п’ять старих, затриманий виклик, який стирає новішу версію проекту, стару версію програми, яка зберігає застарілу схему, або новий процес входу, який успадковує стан попереднього облікового запису. З точки зору API зберігання кожна з цих операцій виконалася успішно. Пошкодження виникають тому, що додаток ніколи не визначав версії, права власності чи правила конфліктів.
Браузерне зберігання знаходить своє застосування тоді, коли його обмеження відповідають завданню: зберіганню налаштувань, кешуванню некритичних даних, підтримці роботи офлайн та відновленню незавершеної роботи. Однак зберігання чогось перетворює тимчасовий стан на постійний вхідний даний, і це вимагає такої ж уваги, як і дані з сервера чи бази даних.
На практиці це означає перевірку того, що повертається, версіонування даних, розділення несуміжних записів, вказування ідентифікаторів користувачів та ресурсів там, де важливо визначення власника, а також уникнення переписування великих знімків стану для дрібних змін. Це також означає свідомий вибір того, чи є прийнятним принцип „останній, хто написав, має перевагу“, щоб він випадково не став загальноприйнятою політикою.
Браузер буде вірно зберігати останній рядок даних, який він отримав. Забезпечення того, що останній користувач справді мав право вирішувати, що залишиться, — це завдання, яке залишається за вами.