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

  1. Коллизии ключей

  2. Изменение схемы данных

  3. Рассинхрон схемы на чтение и на запись

  4. Ошибки setItem

  5. Чтение localStorage в SSR

  6. Отсутствие изоляции между пользователями

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

Коллизии ключей

Ключи стораджа — глобалные на ориджин, и если два разных вида данных класть по одному ключу, не произойдет ничего хорошего. Окей, в одной кодовой базе мы можем как-то сделать ключи уникальными. Самая веселуха начнется, когда на одном хосте живут два приложения (их делают разные команды). И ещё лендинг (спасибо за вопрос, его делал школьник-фрилансер). А вы на 100% уверены, что ни один из ваших нпм-пакетиков не шалит с ключом вроде state? (нет, не на 100%).

Таких проблем нам не надо, так что сразу бахнем всем ключам в приложении уникапльный префикс — не setItem('bannerShown'), а setItem('myapp:bannerShown'). Ну и зафорсить уникальные ключи в рамках приложения — например, заставить описывать все ключи в одном файле — не помешает.

Меняем формат данных

Дальше чуть сложнее. Продакт принес джуну Пете задачу — показывать баннер с уникальным предложением только один раз, чтобы подчеркнуть уникальность. Сказано — сделано!

const key = 'myfood:bannerShown'
const bannerVisible = !localStorage.getItem(key)
// все, в следующий раз не покажем
localStorage.setItem(key, 'yes')

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

const count = Number(localStorage.getItem(key) || 0)
const isVisible = count < 3
localStorage.setItem(key, String(count + 1))

Код собрался, тесты прошли, а на деле баннер все еще не показывается, потому что Number('yes') -> NaN, а NaN < 3 == false. localStorage выкинул свой типичный трюк — я называю его "взрыв из прошлого".

Сторадж кажется частью нашего приложения, но на самом деле это скорее внешний сервис, да еще и ненадежный — там может лежать всё что угодно. Может, джун Ваня положил что-то всем в сторадж 2 года назад, потом удалил код, а значение завалялось. А может, по клавиатуре пользователя пробежала кошка и поковыряла сторадж. В общем, доверять локалстораджу не стоит.

Как мы работаем с ненадежными данными? Мы их валидируем. Например, если мы кладем туда число — точно проверим, что и прочитали тоже число:

const value = localStorage.getItem(key)
if (!value) return 0
const num = Number(value)
if (Number.isNaN(num)) return 0
return num

Обратите внимание, что мы занимаемся ручной сериализацией, а это довольно скучно. Стоит делегировать эту задачу стандартному инструменту (например, JSON) и не забыть обернуть парсинг в try / catch.

Рассинхрон схемы

Те же яйца, только в профиль — расинхрон типов при записи и чтении. Если работа с localStorage размазана по коду, несложно в одном месте положить одни данные...

storage.setItem(keys.banner, { lastShown: Date.now() })

А потом в совершенно другом месте пытаться прочитать другие:

storage.getItem<{ count: number }>(keys.banner).count > 3

К счастью, тут есть целых два отличных решения. Во-первых, можно завернуть пару ключ + тип в хелпер — это приятное апи, в котором клиентскому коду не надо знать ни про какие ключи:

const storage = <T>(key) => ({
  get: (): T | null => {...},
  set: (value: T) => {...},
});
const bannerStorage = storage<{ showCount: number }>('banner')

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

interface TypedStorage {
  banner: { showCount: number }
}
function getItem<K extends keyof TypedStorage>(key: K): TypedStorage[K]
function setItem<K extends keyof TypedStorage>(key: K, v: TypedStorage[K]): void

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

Не ловим ошибки setItem

Но хватит о типах. Спокойно себе дергаем setItem с уверенностью, что он сработает:

function onClose() {
  // ну что плохого может произойти, верно?
  storage.setItem('promo', { closed: Date.now() })
  setPromoVisible(false)
}

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

for (let i = 0; i < 2000; i++) {
  localStorage.setItem(i, '1'.repeat(10000))
  // рано или поздно полетят эксепшены
}

Тут не поможет никакой TS — флоу эксепшенов он не анализирует, и забыть обертку в клиентском коде очень легко. Так что навернуть try / catch на setItem нужно на слое хелпера, чтобы ничего не протекло. Что делать, если место кончилось? Хорошего ответа нет: либо чистить сторадж со всеми сладкими данными, либо смириться с тем, что больше мы в этом браузере ничего в сторадж не положим.

А что, если localStorage нет?

Вроде намазали try / catch везде, но остался один фатальный недостатокloсalStorage может вообще не быть (совсем-совсем, localStorage is not defined), и если вы неаккуратны — упадет не просто один хендлер, а все приложение. И обычно не потому что вы целитесь в IE6, а потому что вы случайно почитали сторадж на SSR в nodejs.

Если помнить о таком риске, обойти его довольно легко — или наверните try / catch пошире, чтобы они поймали ReferenceError, или проверяйте существование стораджа перед обращением к нему (typeof localStorage !== 'undefined' && localStorage). Возвращать можно, как обычно, любой фоллбек или null.

Раз уж речь про SSR, вспомним, что когда в SSR мы читаем одно значение, а на клиенте из настоящего стораджа подтягивается другое, получается так себе: или всё ломается (привет реакт, привет hydration mismatch), или интерфейс некрасиво прыгает (исчез / появился баннер). Не будем вскрывать эту тему сегодня.

Протекание данных

Мы можем работать с localStorage только когда пользователь находится на нашем сайте. А что если я разлогинился? Допустим, по клику на кнопку «выйти» сторадж почистили. А если я с другого устройства нажал «выйти из всех сессий»? До всех стораджей точно не дотянемся, и если в том же браузере зайдет другой пользователь, его ждет сюрприз — чужие баннеры, настройки, черновики и всё такое. Можно агрессивно чистить сторадж при логине, но это не добавит безопасности (я же могу отключить JS и смотреть сторадж сколько хочу), а UX ухудшит — при любом разлогине по таймауту все локальные настройки теряются.

Сделаем 2 вывода:

  1. В localStorage не стоит класть чувствительные данные, потому что мы не управляем доступом к ним. С этим вам не поможет ничто, кроме как думать головой.

  2. Данные пользователя (или других переключаемых сущностей — компаний, корзин и т.п.) надо изолировать — не для безопасности (её нет), а для удобства. Элегантное решение: добавить в префикс кроме id приложения еще и userId. То есть ключи — не просто banner, и даже не myfood:banner, а целый myfood:ab3ab890:banner. Ура, состояние не протекает между пользователями, и несколько человек могут использовать приложение с одного девайса.

Как вам поможет banditstash

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

type BannerState = { showCount: number };
// типизированное хранилище
const bannerStash = banditStash<BannerState>({
  // можно обернуть sessionStorage или любой объект с совместимым интерфейсом
  storage: typeof localStorage !== 'undefined' ? localStorage : undefined,
  // префикс для ключей
  scope: 'myfood'
  // все данные из стораджа обязательно нужно провалидировать
  parse: (raw) => {
    if (raw instanceof Object && 'showCount' in raw && typeof raw.showCount === 'number') {
      // удобнее использовать библиотеку для валидации вроде superstruct
      return { showCount: raw.showCount };
    }
    // или кидаем ошибку
    fail();
  },
  // если нет значения по ключу или не прошла валидация
  fallback: () => ({ showCount: 0 }),
  // не JSON-сериализуемые типы нужно явно преобразовать в POJO
  // сейчас такой проблемы нет
  prepare: (data) => data,
});

У обертки классический интерфейс getItem / setItem, чтобы проще мигрировать с голого localStorage. Но можно создать курсор для работы с одним полем:

const welcomeBannerStash = bannerStash.singleton('welcome');

const welcomeState = welcomeBanner.get();
welcomeBanner.set({ showCount: welcomeState.showCount + 1 });

Кроме базового API есть слоеная версия: делаем "заготовочку", и на ее основе строим несколько хранилищ:

// хранилище
const appStorage = makeBanditStash(localStorage)
  // с префиксом для всех ключей
  .use(scope('app'))
  // json-сериализацией
  .format(json())
  // ловим эксепшены setItem
  .use(safeSet())

// типизированные интерфейсы
const bannerStorage = appStorage.format<number | null>({
  parse: raw => typeof raw === 'number' ? raw : null,
})

type Theme = 'dark' | 'light';
const themeStorage = appStorage.format<Theme>({
  parse: raw => raw === 'dark' || raw === 'light' ? raw : fail(),
}).use(safeGet((): Theme => 'light')).singleton('theme');

Если некоторые из проблем вас не трогают (например, вы хотите явно ловить ошибки setItem) — не проблема, не подключайте этот плагин.

Библиотека очень маленькая — меньше 500 байт. Нет повода не попробовать в своем проекте!


Сегодня я рассказал вам про 6 проблем localStorage и библиотеку banditStash, которая помогает работать со стораджем удобно и безопасно: валидирует данные, ловит ошибки и помогает с сериализацией. Но несколько вещей всё равно стоит держать в голове:

  1. Класть чувствительные данные в localStorage — небезопасно

  2. В сторадже может кончиться место, и setItem перестанет работать.

  3. Сторадж — ненадежное хранилище, не стоит завязывать на него критичные бизнес-процессы.

Как обычно, лучший подарок для меня — ваши звездочки на гитхабе и успешные внедрения. Жду отзывов!

Комментарии (15)


  1. gun_dose
    15.07.2024 10:33
    +3

    Смена формата данных - очень распространённая ошибка. Особенно при использовании redux-persist. Часто бывает поменяли структуру хранилища, потом это прилетает на клиент и там сразу всё ломается так, что вообще ничего нельзя сделать. Юзер обновляет страницу - ничего не меняется. А как очистить localStorage абсолютное большинство юзеров не имеет понятия. Особенно на мобиле.

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


    1. thoughtspile Автор
      15.07.2024 10:33
      +3

      Если подойти к вопросу серьезно, то дело уже попахивает миграциями. Но я все таки пришел к выводу что если состояние стораджа нельзя безболезненно выкинуть и пересоздать заново, то в 95% кейсов мы выбрали неправильный инструмент (за исключением всякой offline-first экзотики)


      1. gun_dose
        15.07.2024 10:33
        +2

        Тот способ решения, что я описал, по сути и есть миграция. Выкинуть значение - это в принципе ок, но выкинуть подразумевает, что нужно перестать использовать старый ключ и создать новый. В противном случае получим всё ту же ошибку вроде cannot read property of undefined, и всё опять станет раком. А если вы собираетесь для выкидывания старого значения писать код с проверкой формата значения и удалять его, если формат старый, то это уже и есть миграция, пусть и упрощённая.


        1. thoughtspile Автор
          15.07.2024 10:33
          +2

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

          Раз мы всегда умеем работать с кейсом "в сторадже пусто", то проще свести кейс "кривые данные" к нему небольшой валидацией. Если это попадает в ваш концепт миграции, то можем называть это так =)


          1. gun_dose
            15.07.2024 10:33

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

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


    1. WFF
      15.07.2024 10:33

      А не является ли более правильным вариантом использовать indexDB? Есть версионность, разные приложения можно разнести по разным базам.


      1. thoughtspile Автор
        15.07.2024 10:33

        Зависит от кейса, но по дефолту скорее нет чем да — там замороченное (и довольно мерзкое) асинхронное апи + технология менее популярная. Для offline-first приложения попробовать стоит, для настроек / баннеров / кеша лучше не надо.


        1. WFF
          15.07.2024 10:33

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


      1. gun_dose
        15.07.2024 10:33

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


    1. KasperGreen
      15.07.2024 10:33
      +1

      Я пошëл по относительно простому пути. Данные из localStorage перед восстановлением кастуются через yup — так если схема больше не соответствует данным у юзера в локалСторадже — они будут удалены либо модифицированы в соответствии со схемой.


  1. Razunter
    15.07.2024 10:33

    Хм… А не будет проблем с отсутствием поддержки localStorage на устройстве? Есть вот такая обёртка, проверяющая доступ: https://github.com/etiennea/storage-factory


    1. thoughtspile Автор
      15.07.2024 10:33

      Вряд ли стоит об этом сильно переживать, там очень хорошая поддержка https://caniuse.com/?search=localstorage


      1. Razunter
        15.07.2024 10:33
        +1

        Насколько я понимаю, тут не в поддержке вопрос, а в настройках приватности. Т.е. кто-то может просто запретить сохранять какие-либо данные у себя.


        1. thoughtspile Автор
          15.07.2024 10:33
          +1

          Когда я приступал к статье, у меня было смутное воспоминание что в приватном режиме сафари localStorage отключен, но к счастью эту фичу убрали еще в сафари 11

          Кейс с явным полным отключением localStorage точно нишевый уровня "пользователь отключил js" и покрывается фолбеком аналогично SSR (единственное отличие — в firefox при этом localStorage === null, а не is not defined)

          Спасибо, хороший пойнт!


  1. vagon333
    15.07.2024 10:33
    +2

    Все ваши примеры - это как не надо программировать.
    И речь даже не о localStorage, а о ненадежном программировании.