SSR
Пакет безопасен для серверного рендера целиком. Оговорки касаются не безопасности, а того, что именно приходит с сервера.
Nuxt, vite-ssr или собственный @vue/server-renderer — короткий ответ один:
весь пакет рендерится на сервере и гидрируется без расхождений. Дальше —
оговорки о том, что именно приезжает с сервера и где нужен плагин.
Телепорт включается после гидрации
Панели, всплывашки и оверлеи живут в body, но попадают туда не сразу. Контракт
один на все компоненты: телепорт выключен на сервере и на первом клиентском
рендере, а включается в onMounted. Отключённый телепорт означает «рендерить
на месте», поэтому серверный HTML и первый клиентский рендер совпадают.
Что из этого следует для приложения:
- панели приходят с сервера — внутри разметки своего компонента и скрытыми, поэтому вспышки раскрытого списка нет;
- вставлять
ssrContext.teleportsв разметку пакету не нужно: у его телепортов там одни якоря-комментарии, и гидрация проходит чисто и без них. Вставляйте их, если у вас есть свои телепорты, включённые на сервере; - обёртки client-only не нужны ни одному компоненту пакета.
Исключение — содержимое модальных слоёв. GrModal и всё, что на нём
построено (GrDialog, GrConfirmDialog, GrPromptDialog, GrCommandPalette),
а также GrDrawer, GrImageViewer и GrSidebar в режиме overlay не отдают
содержимое на сервер вообще, даже будучи открытыми: слой рисуется только после
включения телепорта. Практический вывод: в первый экран и в поисковую выдачу оно
не попадает, а расхождения гидрации внутри него в принципе невозможны.
Пишете свой компонент с телепортом — берите usePortalTarget() и его enabled,
а не typeof window !== 'undefined'. Проверка окружения выглядит достаточной,
но именно она и создаёт расхождение: на сервере и на первом клиентском рендере
она даёт разный результат.
Композаблы: что требует плагина
| API | На сервере |
|---|---|
useTheme() | Читать можно, писать — только с granularityThemePlugin: mode и isDark отдают режим запроса или режим по умолчанию, а setMode/toggleMode без плагина бросают. Течёт именно мутация: модульное состояние общее на все запросы |
initThemeEarly() | Ничего не делает: его задача — поставить пару до монтирования, и зовут его в браузерной точке входа |
useToast() | Требует granularityToastPlugin; без него бросает с объяснением |
useDialogService() | Сам композабл безопасен; методы, открывающие окно (confirm, alert, prompt, open), на сервере бросают: хост монтируется в document.body |
useAnnouncer() | Безопасен, объявление — no-op: живой регион это узел документа, а не переносимое состояние |
useGrConfig(), useGrFormFieldContext() | Чистые, DOM не трогают |
useOverlayLayer() | Безопасен: на сервере слой в стек не заводится вовсе |
vClickOutside, vHotkey, vLoading | Работают в хуках монтирования, на сервере не вызываются |
Общее правило у запретов одно: модульное состояние на сервере — это состояние, общее для всех запросов, и выбор одного пользователя уехал бы в ответ другому. Плагин даёт каждому приложению своё.
ClientOnly — кому он нужен
Компонентам пакета — ни одному, и оборачивать их вредно: пропадёт серверная
разметка, а с ней и смысл SSR. Обёртка нужна вашему коду, который читает
среду прямо в шаблоне: ширину окна, navigator, localStorage, время в
локальной зоне.
Императивные API она не закрывает вовсе — они не рендерятся, прятать надо вызов, а не разметку:
if (typeof window !== 'undefined')
await useDialogService().confirm('Delete the item?', { title: 'Delete?' })Режим без мигания
Режим живёт парой data-theme="slate-dark" на <html>, и поставить её надо до
первой отрисовки. Пары руками не пишутся: их знает сборка и отдаёт манифестом
virtual:granum/themes — тем же, по которому собран CSS.
Без сервера — скрипт в <head>. Его вставляет плагин granum первым в
<head> HTML-входа приложения:
granum(granumConfig, { themeScript: { storageKey: 'gr-theme' } })Скрипт не трогает пару, уже пришедшую с сервера; иначе берёт режим из cookie
(если её имя задано), из localStorage, по системной схеме и, наконец, режим по
умолчанию. Ключ gr-theme обязателен: свой ключ по умолчанию у granum —
granum-theme, а useTheme() хранит выбор под gr-theme, и при расхождении
выбор не переживёт перезагрузку. Без плагина ту же строку отдаёт
createThemeScript({ manifest, storageKey: 'gr-theme' }) из
@feugene/granum/runtime; в Astro скрипт ставит интеграция
@feugene/astro-granularity.
С сервером — пара в HTML. Если выбор хранится в cookie, сервер читает её из
запроса и сразу отдаёт <html data-theme="slate-dark">, а приложение рендерит в
той же теме и том же режиме:
import { GR_THEME_STORAGE_KEY } from '@feugene/granularity'
import { themeFromCookie, themeRootAttributes, themeSheetLink } from '@feugene/granum/runtime'
import manifest from 'virtual:granum/themes'
const selection = themeFromCookie(manifest, request.headers.cookie, GR_THEME_STORAGE_KEY)
const theme = selection && { theme: selection.theme.id, mode: selection.mode.name }
const htmlAttrs = theme ? themeRootAttributes(manifest, theme) : {} // { 'data-theme': 'slate-dark' }
// лист дополнительной темы — в <head>: в общем CSS его нет
const sheet = theme && themeSheetLink(manifest, theme.theme) // у основной темы — undefinedimport { GR_THEME_STORAGE_KEY, granularityThemePlugin } from '@feugene/granularity'
import { createCookieStorage } from '@feugene/granum/runtime'
import manifest from 'virtual:granum/themes'
app.use(granularityThemePlugin, {
manifest,
storage: createCookieStorage({ name: GR_THEME_STORAGE_KEY }),
initial: theme, // на клиенте не передаётся: пару с сервера контроллер узнаёт сам
})Неизвестный выбор и отсутствие cookie дают undefined: решать за пользователя по
системной схеме сервер не может, и пару тогда ставит скрипт. Тему в initial
передают вместе с режимом: по ней компоненты берут пропсы по умолчанию, и без неё
серверный HTML разошёлся бы с гидрацией. Пару режима по
умолчанию «на всякий случай» не пишите — скрипт оставит её как серверную, и
сохранённый выбор не применится. Серверу, импортирующему @feugene/granum/runtime,
granum нужен в dependencies, а не в devDependencies.
Оба пути сходятся на клиенте: useTheme() узнаёт пару, уже стоящую на корне, и
не перезаписывает её. color-scheme объявляет CSS режима, ставить его руками не
нужно.
CSS на серверный рендер не влияет: он статичен. Критично только одно — режим должен примениться до первой отрисовки, иначе пользователь с тёмным режимом увидит вспышку светлого.
Правила для своего компонента
- DOM — только в
onMounted/onBeforeUnmountи в обработчиках. Ни одного обращения в телеsetupи на уровне модуля. - Нужен
documentилиwindowвне хука — гардtypeof window === 'undefined', а неtry/catch. - Телепортируете — берите
enabledизusePortalTarget(), не проверку окружения. - Браузерные API, которых может не быть и в браузере (
ResizeObserver,matchMedia), проверяйте на существование. - DOM-идентификаторы — только
useId(). Ни счётчик экземпляра (на сервере он растёт между запросами, на клиенте стартует с нуля), ни случайное число: расхождение придёт молча — черезid,aria-controls,aria-labelledby. - Значение, зависящее от среды, в первом рендере не использовать:
refс серверным значением и уточнение вonMounted.
Поведение телепортов, модальных слоёв, useId(), тостов и режима на этой
странице проверено прогоном SSR-стенда библиотеки с гидрацией; поведение
остальных композаблов и директив — по их серверным гардам в коде. Nuxt и
потоковый рендер стенд не покрывает.