Стреляем себе в ногу из localStorage
- вторник, 16 июля 2024 г. в 00:00:05
Все фронтендеры любят localStorage
— ведь в него можно прикопать данные без всяких баз и серверов. Но из localStorage
можно отлично обстрелять себе ногу — сегодня расскажу про 6 встроенных пулеметов:
Коллизии ключей
Изменение схемы данных
Рассинхрон схемы на чтение и на запись
Ошибки setItem
Чтение localStorage в SSR
Отсутствие изоляции между пользователями
Мне надоело бояться и подпирать эти проблемы в каждом проекте, и я создал библиотеку 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 с уверенностью, что он сработает:
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 нужно на слое хелпера, чтобы ничего не протекло. Что делать, если место кончилось? Хорошего ответа нет: либо чистить сторадж со всеми сладкими данными, либо смириться с тем, что больше мы в этом браузере ничего в сторадж не положим.
Вроде намазали 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 вывода:
В localStorage
не стоит класть чувствительные данные, потому что мы не управляем доступом к ним. С этим вам не поможет ничто, кроме как думать головой.
Данные пользователя (или других переключаемых сущностей — компаний, корзин и т.п.) надо изолировать — не для безопасности (её нет), а для удобства. Элегантное решение: добавить в префикс кроме id приложения еще и userId. То есть ключи — не просто banner
, и даже не myfood:banner
, а целый myfood:ab3ab890:banner
. Ура, состояние не протекает между пользователями, и несколько человек могут использовать приложение с одного девайса.
Если вы нервный, как я, то в каждом вашем проекте вокруг 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, которая помогает работать со стораджем удобно и безопасно: валидирует данные, ловит ошибки и помогает с сериализацией. Но несколько вещей всё равно стоит держать в голове:
Класть чувствительные данные в localStorage — небезопасно
В сторадже может кончиться место, и setItem перестанет работать.
Сторадж — ненадежное хранилище, не стоит завязывать на него критичные бизнес-процессы.
Как обычно, лучший подарок для меня — ваши звездочки на гитхабе и успешные внедрения. Жду отзывов!