SSR
Пакет безопасен для серверного рендера целиком. Оговорки касаются не безопасности, а того, что именно приходит с сервера.
Nuxt, vite-ssr или собственный @vue/server-renderer — короткий ответ один:
весь пакет рендерится на сервере и гидрируется без расхождений. Дальше —
оговорки о том, что именно приезжает с сервера и где нужен плагин.
Телепорт включается после гидрации
Панели, всплывашки и оверлеи живут в body, но попадают туда не сразу. Контракт
один на все компоненты: телепорт выключен на сервере и на первом клиентском
рендере, а включается в onMounted. Отключённый телепорт означает «рендерить
на месте», поэтому серверный HTML и первый клиентский рендер совпадают.
Что из этого следует для приложения:
- панели приходят с сервера — внутри разметки своего компонента и скрытыми, поэтому вспышки раскрытого списка нет;
- якоря телепортов всё равно надо вставлять в разметку, иначе Vue не найдёт точку привязки;
- обёртки client-only не нужны ни одному компоненту пакета.
Исключение — содержимое модалок. GrModal и всё, что на нём построено —
GrDialog, GrConfirmDialog, GrPromptDialog, GrCommandPalette,
GrImageViewer, GrDrawer, — не отдаёт содержимое на сервер вообще, даже
будучи открытым. Практический вывод: в первый экран и в поисковую выдачу оно не
попадает, а расхождения гидрации внутри него в принципе невозможны.
Пишете свой компонент с телепортом — берите usePortalTarget() и его enabled,
а не typeof window !== 'undefined'. Проверка окружения выглядит достаточной,
но именно она и создаёт расхождение: на сервере и на первом клиентском рендере
она даёт разный результат.
Композаблы: что требует плагина
| API | На сервере |
|---|---|
useTheme() | Читать можно, писать — только с granularityThemePlugin. Течёт именно мутация: модульное состояние общее на все запросы |
useToast() | Требует granularityToastPlugin; без него бросает с объяснением |
useDialogService() | Сам композабл безопасен, любой его метод на сервере бросает: он монтирует хост в document.body |
useAnnouncer() | Безопасен, объявление — no-op: живой регион это узел документа, а не переносимое состояние |
useGrConfig(), useGrFormFieldContext() | Чистые, DOM не трогают |
useOverlayLayer() | Безопасен: на сервере слой в стек не заводится вовсе |
vClickOutside, vHotkey, vLoading | Работают в хуках монтирования, на сервере не вызываются |
Общее правило у запретов одно: модульное состояние на сервере — это состояние, общее для всех запросов, и выбор одного пользователя уехал бы в ответ другому. Плагин даёт каждому приложению своё.
ClientOnly — кому он нужен
Компонентам пакета — ни одному, и оборачивать их вредно: пропадёт серверная
разметка, а с ней и смысл SSR. Обёртка нужна вашему коду, который читает
среду прямо в шаблоне: ширину окна, navigator, localStorage, время в
локальной зоне.
Императивные API она не закрывает вовсе — они не рендерятся, прятать надо вызов, а не разметку:
if (typeof window !== 'undefined')
await useDialogService().confirm({ title: 'Delete?' })Тема без мигания
Сервер не знает, какую тему выбрал пользователь. Решение — инлайновый скрипт в
<head> до первого рендера:
<script>
try {
var t = localStorage.getItem('gr-theme')
|| (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
document.documentElement.dataset.theme = t
}
catch {}
</script>Ключ gr-theme и атрибут data-theme — тот же контракт, что использует
useTheme(). Если тему выбирает сервер — из куки или профиля, — ставьте
granularityThemePlugin: простому SPA он не нужен, там модульного синглтона
достаточно.
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.
Все утверждения этой страницы — результат прогона SSR-стенда библиотеки с гидрацией, а не чтения исходников.