Svelte
Состояние в SvelteKit: SSR-безопасно
Разработчик переносит привычку из чистого Svelte: заводит общий стор в модуле, кладёт туда текущего пользователя и импортирует где надо. В браузере это работает. На сервере SvelteKit та же строка превращается в трудноуловимый баг: модуль на сервере один на всех, и пользователь Алиса вдруг видит данные Боба, потому что их запросы поделили одну модульную переменную. Корень в том, что сервер обслуживает много запросов параллельно одним и тем же кодом, а клиент - один пользователь на вкладку. Состояние в SvelteKit требует понимания этой границы.
- Авторизация: пользователь в модульной переменной утекает между запросами, Алиса видит сессию Боба
- Корзина: общий серверный стор смешивает товары разных покупателей под нагрузкой
- Текущий маршрут и параметры: читаются из page объекта $app/state без своих глобальных переменных
- Тема и язык интерфейса: request-scoped состояние через контекст, своё для каждого SSR-запроса
- Кэш конфигурации: неизменяемые данные в модуле безопасны, изменяемое пользовательское - нет
Предварительные знания
- Руны $state и $derived и универсальная реактивность в файлах .svelte.js
- Контекст компонентов Svelte: setContext и getContext
- Понятие SSR: серверный код исполняется для каждого входящего запроса
Почему модульная переменная небезопасна на сервере
В чистом клиентском Svelte распространён приём: объявить реактивное состояние на верхнем уровне модуля и импортировать его в любой компонент. В браузере вкладка - это один пользователь, и общий модуль обслуживает только его. На сервере картина иная: один процесс Node обрабатывает запросы многих пользователей параллельно, и модуль загружается один раз на всех. Изменяемая переменная на уровне модуля становится общей для всех этих запросов.
Проблема проявляется только под параллельной нагрузкой, поэтому в разработке с одним пользователем её часто не замечают. В проде же два одновременных запроса делят одну переменную: запись одного перетирает данные другого. Это не редкая экзотика, а прямое следствие того, что серверный модуль - синглтон на процесс.
- Браузер — Модуль живёт в одной вкладке одного пользователя. Общая переменная обслуживает только его, перемешивать нечего
- Сервер — Один модуль на процесс обслуживает все параллельные запросы. Изменяемая общая переменная утекает между пользователями
Неизменяемые данные в модуле безопасны: константы, загруженная один раз конфигурация, чистые функции. Опасно именно изменяемое состояние, специфичное для пользователя или запроса. Правило простое: пользовательское и запрос-специфичное состояние не живёт в модульных переменных на сервере.
Почему хранение текущего пользователя в изменяемой переменной на уровне модуля опасно при SSR?
Чтение состояния страницы через `$app/state`
Часть состояния не нужно хранить самому - его уже ведёт SvelteKit. Текущий url, params маршрута, данные из load, результат form action доступны через объект page из модуля `$app/state`. Это безопасно при SSR: page привязан к текущему запросу на сервере и к текущей странице в браузере, без общих переменных.
Поля page реактивны через руны Svelte 5: при навигации page.url и page.params обновляются, и разметка перерисовывается сама. page.data собирает данные всех load по цепочке layout и page, поэтому пользователя, загруженного в layout, видно на любой вложенной странице без повторного запроса.
Модуль `$app/state` на рунах пришёл на смену прежнему `$app/stores`, где те же данные были стором и читались как `$page` с автоподпиской. В свежих проектах SvelteKit 2 используют `$app/state`; обращение к page.url теперь прямое, без знака стора в шаблоне.
| Поле page | Что содержит |
|---|---|
| page.url | Текущий URL со строкой запроса |
| page.params | Значения динамических сегментов маршрута |
| page.data | Объединённые данные всех load (layout + page) |
| page.form | Результат последнего form action |
| page.status | HTTP-статус текущего ответа |
Компоненту нужен текущий путь и параметр маршрута id, реактивно обновляемые при навигации. Откуда их взять?
Request-scoped состояние через контекст
Когда приложению нужно собственное общее состояние - тема, язык, корзина - и оно должно быть своим у каждого запроса, его строят через контекст компонентов. setContext в корневом +layout.svelte создаёт экземпляр состояния и кладёт его в дерево компонентов текущего рендера. getContext в любом потомке достаёт тот же экземпляр. Поскольку дерево компонентов создаётся заново на каждый SSR-запрос, состояние не делится между пользователями.
Разница с модульной переменной принципиальна. Модуль один на процесс - состояние общее. Контекст создаётся вызовом createTheme внутри рендера layout, а рендер у каждого запроса свой - значит, у каждого запроса собственный экземпляр состояния. Реактивность при этом сохраняется: внутри лежит обычный `$state`, и изменение theme.mode перерисовывает всех потребителей.
Ключ контекста удобно делать Symbol, а не строку: символ уникален и не столкнётся с чужим контекстом из библиотеки. Функции-обёртки createTheme и getTheme прячут ключ внутри модуля, и потребитель работает с понятным API, не зная деталей.
Нужно общее изменяемое состояние темы, своё для каждого SSR-запроса и доступное во всём дереве. Как его организовать?
Связь с другими темами
Этот урок соединяет реактивность Svelte с реальностью серверного рендеринга:
- Hooks и locals — Серверная сторона request-scoped состояния: locals живёт один запрос и не делится между пользователями
- Универсальная реактивность — Руны в .svelte.js - тот же инструмент; вопрос лишь в безопасном месте их размещения при SSR
- Load-функции — Пользовательские данные приходят из load и locals, а не из разделяемой модульной переменной
Итог
- Сервер обслуживает много запросов одним экземпляром модуля; изменяемая переменная на уровне модуля разделяется между всеми пользователями
- Хранить пользовательские данные в модульной переменной на сервере опасно: они утекают между параллельными запросами
- Объект page из $app/state даёт реактивный доступ к url, params, data и form текущей страницы без своих глобальных переменных
- $app/state пришёл на смену стор-ориентированному $app/stores: те же данные, но через руны и без подписки на стор
- Request-scoped состояние строят через контекст: setContext в корневом layout создаёт экземпляр на запрос, getContext читает его в потомках
Связанные уроки
- sv-22-hooks — event.locals - серверный аналог идеи request-scoped состояния: данные, живущие ровно один запрос, без утечки между пользователями
- sv-10-universal-reactivity — Реактивные руны в .svelte.js уже знакомы; здесь разбирается, где их размещение безопасно при SSR, а где нет
- sv-19-load-functions — Серверное состояние пользователя берут из load и locals, а не из общей модульной переменной