Pull to refresh

Стреляем себе в ногу из localStorage

Level of difficultyEasy
Reading time6 min
Views10K

Все фронтендеры любят 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. Сторадж — ненадежное хранилище, не стоит завязывать на него критичные бизнес-процессы.

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

Tags:
Hubs:
Total votes 24: ↑22 and ↓2+26
Comments16

Articles