Svelte
Глобальное состояние в модулях и подводные камни SSR
Разработчик выносит текущего пользователя в модуль: export const user = `$state({ name: '' })`. На локальной машине всё работает. После деплоя приходит баг-репорт: пользователь Анна иногда видит имя Бориса. Причина в том, что на сервере один процесс обслуживает оба запроса, и переменная модуля одна на всех. Запрос Бориса перезаписал user, а запрос Анны прочитал чужое значение. Это классический подводный камень SSR, и понимание его границ отделяет рабочий код от утечки данных.
- Утечка сессии: пользовательские данные в переменной модуля смешиваются между одновременными запросами на сервере
- Кеши: общий кеш в модуле уместен для одинаковых для всех данных, но не для персональных
- Конфигурация: неизменяемые настройки приложения в модуле безопасны, потому что одинаковы для всех запросов
- Корзина и предпочтения: персональное состояние держат в контексте на сервере, чтобы оно не утекало между пользователями
Предварительные знания
- Понимание универсальной реактивности рун: `$state` работает и вне компонента в .svelte.ts
- Базовое представление о том, что на сервере один процесс обслуживает много запросов одновременно
- Знание Context API: setContext и getContext для состояния, привязанного к дереву
Общее состояние через руны в .svelte.ts
Руны не ограничены файлами компонентов. В модуле с расширением .svelte.ts (или .svelte.js) компилятор Svelte обрабатывает руны так же, как в компоненте. Это даёт способ вынести реактивное состояние в отдельный модуль и переиспользовать его в нескольких компонентах. На клиенте это удобный способ держать общее состояние приложения в одном месте.
Здесь createCounter это фабрика: каждый её вызов создаёт независимый экземпляр состояния. Это важное отличие от состояния, объявленного прямо на верхнем уровне модуля. Фабрика возвращает объект с геттером и методом, и реактивность сохраняется, потому что геттер читает руну при каждом обращении. Экспортировать саму переменную `$state` напрямую нельзя: при импорте получился бы её снимок, оторванный от реактивности.
Различие между фабрикой и состоянием на уровне модуля критично. Фабрика даёт новый экземпляр на каждый вызов. Состояние, объявленное прямо в модуле, это один экземпляр на всех импортёров. На клиенте это часто и нужно, но именно второй вариант становится опасным на сервере, как разбирается дальше.
Чем отличается состояние, возвращаемое фабрикой createCounter(), от состояния, объявленного через export const appState = `$state(...)` прямо в модуле?
Почему модульное состояние опасно на сервере
В браузере модуль загружается один раз и обслуживает одного пользователя. На сервере картина иная: один процесс Node живёт долго и обслуживает запросы многих пользователей одновременно. Модуль на сервере тоже загружается один раз, и переменная, объявленная на его верхнем уровне, существует в единственном экземпляре на весь процесс. Все одновременные запросы читают и пишут одну и ту же переменную.
Если в load или в серверном коде записать в currentUser имя пользователя текущего запроса, параллельный запрос другого пользователя перезапишет его. Затем первый запрос при рендеринге прочитает уже чужое значение. Результат это утечка персональных данных между пользователями, плавающая и трудно воспроизводимая, потому что зависит от тайминга одновременных запросов.
Не всё модульное состояние опасно. Неизменяемая конфигурация, справочники, общие для всех данные безопасны, потому что одинаковы для каждого запроса и не зависят от того, кто запросил. Опасно именно изменяемое состояние, привязанное к конкретному пользователю или запросу, на уровне модуля на сервере.
Почему export const currentUser = `$state(...)` на уровне модуля приводит к утечке данных на сервере, но не на клиенте?
Состояние, своё для каждого запроса, через контекст
Лекарство от утечки это привязать персональное состояние не к модулю, а к чему-то, что создаётся заново для каждого запроса. На сервере таким якорем служит дерево компонентов: SvelteKit рендерит свежее дерево на каждый запрос. Контекст, установленный в корне этого дерева, виден всем компонентам запроса и не пересекается с другими запросами. Это и есть рекомендованный способ держать пользовательское состояние.
В корневом layout вызывается setUserState с данными текущего запроса, и состояние оказывается привязанным к дереву этого запроса. Любой вложенный компонент достаёт его через getUserState. Поскольку дерево создаётся заново на каждый запрос, состояние одного пользователя физически отдельно от состояния другого, и утечка невозможна. Фабрика обеспечивает новый экземпляр, а контекст привязывает его к запросу.
| Подход | Безопасно на сервере | Для чего |
|---|---|---|
| Состояние на уровне модуля | Только для неизменяемых общих данных | Конфигурация, справочники, одинаковые для всех |
| Фабрика плюс контекст | Да, изолировано по запросам | Пользователь, корзина, сессия, любые персональные данные |
| Фабрика без контекста (на клиенте) | Неприменимо к серверу | Общее состояние в рамках одной вкладки браузера |
Практическое правило для SSR: если состояние различается от пользователя к пользователю, оно не должно жить в переменной на уровне модуля на сервере. Заверните его в фабрику и положите в контекст в корне дерева. Модульное состояние оставьте для того, что действительно одинаково для всех.
Как правильно держать состояние корзины пользователя в приложении с SSR, чтобы избежать утечки между запросами?
Связь с другими темами
Этот урок про границы безопасного глобального состояния. Он опирается на реактивность рун и контекст:
- Универсальная реактивность — Руны в .svelte.ts дают реактивное состояние вне компонента, на чём и строится модульное состояние
- Context API — Контекст это безопасная альтернатива модульному состоянию для персональных данных на сервере
- Тонкая реактивность — Сигнальная модель объясняет, почему руна остаётся реактивной в обычном модуле
Итог
- Руны работают в .svelte.ts-модулях, что позволяет вынести общее реактивное состояние за пределы одного компонента
- На сервере один процесс обслуживает много запросов, поэтому изменяемая переменная модуля делится между всеми пользователями
- Хранение персональных данных в переменной модуля на сервере приводит к их утечке между одновременными запросами
- Неизменяемая конфигурация и общие для всех данные в модуле безопасны, потому что одинаковы для каждого запроса
- Персональное состояние, своё у каждого запроса, держат в контексте, который изолирован по дереву и по запросам
Связанные уроки
- sv-10-universal-reactivity — Глобальное состояние строится на универсальной реактивности рун, доступной и вне компонентов в .svelte.ts
- sv-31-context-api — Контекст это рекомендованное лекарство от утечки состояния между запросами, в которое упирается весь урок
- sv-30-fine-grained-reactivity — Сигнальная модель объясняет, почему руна в .svelte.ts остаётся реактивной вне компонента