Senior: питання на співбесіді з теми «Продуктивність і SSR»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
4 питання
У звичайному Vue-застосунку (SPA) сервер віддає майже порожній HTML, а сторінку будує JavaScript у браузері. SSR рендерить ті самі компоненти на сервері в готовий HTML, а в браузері Vue «оживляє» його - гідрує.
Як це влаштовано:
- на сервері (Node.js) створюється застосунок, завантажуються дані, компоненти рендеряться в рядок HTML (
renderToStringзvue/server-renderer); - браузер отримує готову сторінку - вміст видно одразу, ще до завантаження JavaScript;
- завантажується той самий код застосунку, Vue проходить по існуючому DOM і прив'язує до нього реактивність та обробники подій, не перестворюючи елементи;
- далі застосунок працює як звичайний SPA.
Що це дає:
- швидший перший показ вмісту (LCP), особливо на повільних пристроях;
- SEO й превью соцмереж: пошукові роботи й боти соцмереж бачать вміст без виконання JavaScript;
- кращий досвід на поганому з'єднанні.
Ціна:
- потрібен Node.js-сервер (або платформа з підтримкою SSR) і його навантаження - рендер на кожен запит;
- код має працювати в обох середовищах: на сервері немає
window,document,localStorage. Звертатися до них можна лише вonMountedчи з перевірками; - стан між запитами: на сервері один процес обслуговує всіх користувачів - глобальні змінні модулів (синглтон-стор) ділитимуться між запитами і можуть показати дані одного користувача іншому. Застосунок і стор треба створювати на кожен запит;
- помилки гідрації, коли серверний і клієнтський HTML не збігаються.
Як роблять на практиці:
- Nuxt - фреймворк над Vue, що бере на себе SSR, маршрутизацію, завантаження даних і розгортання;
- Inertia SSR у Laravel-проєктах - Laravel віддає дані, окремий Node-процес (
php artisan inertia:start-ssr) рендерить сторінку Vue; - статична генерація (SSG) - HTML генерується під час збирання, якщо вміст не змінюється на кожен запит (документація, блог).
Коли SSR не потрібен: внутрішні панелі й кабінети за авторизацією - SEO там не важливий, а SPA простіший в експлуатації.
Під час гідрації Vue очікує, що DOM, отриманий із сервера, точно відповідає тому, що відрендерив би клієнт з тими самими даними. Якщо ні - у консолі Hydration mismatch, а Vue виправляє розбіжність, перебудовуючи частину DOM. Це повільно, а в продакшені може лишити неправильний вміст.
Типові причини:
1. Дані, що відрізняються на сервері й клієнті:
<span>{{ new Date().toLocaleTimeString() }}</span> <!-- час сервера ≠ час браузера -->
<span>{{ Math.random() }}</span>
<p>{{ formatDate(order.createdAt) }}</p> <!-- різні часові пояси -->
2. Код, що залежить від браузера:
<div v-if="window.innerWidth > 768">...</div> <!-- на сервері window немає -->
<p>{{ localStorage.getItem('name') }}</p>
3. Невалідна вкладеність HTML. <div> всередині <p>, <tr> без <tbody> - браузер «виправляє» розмітку при розборі, і DOM уже не відповідає серверному HTML.
4. Сторонні скрипти й розширення браузера, що змінюють DOM до гідрації.
5. Генеровані id, які на сервері й клієнті виходять різними (лічильник, Math.random()).
Як виправляти:
- значення, що мають бути лише на клієнті, - встановлювати в
onMounted(він не виконується на сервері):
const now = ref<string | null>(null)
onMounted(() => { now.value = new Date().toLocaleTimeString() })
- детерміновані дані: форматування дат з явним часовим поясом (
Intl.DateTimeFormat('uk', { timeZone: 'Europe/Kyiv' })), а не поясом середовища; useId()(Vue 3.5) для id, стабільних між сервером і клієнтом;- клієнтські компоненти - рендерити лише в браузері (
<ClientOnly>у Nuxt чи власна обгортка з прапорцем, встановленим вonMounted); - валідна розмітка - перевіряється валідатором HTML.
Свідома розбіжність. Якщо значення має відрізнятися (час, локальна дата), Vue 3.5 дозволяє придушити попередження атрибутом:
<span data-allow-mismatch="text">{{ localTime }}</span>
Значення атрибута обмежує тип розбіжності: text, children, class, style, attribute. Це для точкових випадків, а не спосіб приховати справжні баги.
Діагностика: у режимі розробки попередження показує, який саме вузол не збігся. Для продакшен-збірки детальні повідомлення вмикаються прапорцем __VUE_PROD_HYDRATION_MISMATCH_DETAILS__.
У SSR сторінка приходить готовим HTML, але інтерактивною стає лише після гідрації - коли Vue пройде по всіх компонентах сторінки й прив'яже обробники. На важких сторінках це секунди роботи головного потоку, за які кліки не працюють і погіршується INP.
Лінива гідрація (Vue 3.5+) дозволяє гідрувати асинхронні компоненти не одразу, а за умовою. Поки компонент не гідровано, він показує серверний HTML - вміст видно, просто без інтерактивності.
import { defineAsyncComponent, hydrateOnVisible, hydrateOnIdle, hydrateOnInteraction } from 'vue'
const Comments = defineAsyncComponent({
loader: () => import('./Comments.vue'),
hydrate: hydrateOnVisible(), // коли з'явиться в області видимості
})
const Footer = defineAsyncComponent({
loader: () => import('./Footer.vue'),
hydrate: hydrateOnIdle(), // коли браузер вільний
})
const Rating = defineAsyncComponent({
loader: () => import('./Rating.vue'),
hydrate: hydrateOnInteraction(['click', 'focus']), // при першій взаємодії
})
Стратегії:
hydrateOnIdle(timeout?)- черезrequestIdleCallback;hydrateOnVisible(options?)- черезIntersectionObserver(можна задатиrootMargin);hydrateOnMediaQuery('(max-width: 500px)')- лише якщо виконується медіазапит;hydrateOnInteraction(events)- при першій події; подія, що спричинила гідрацію, відтворюється після неї, тож клік не губиться;- власна стратегія - функція, що отримує
hydrateі повертає функцію прибирання.
Що це дає:
- головний потік вільний раніше - сторінка відповідає на дії швидше;
- код компонента може завантажитися лише тоді, коли він знадобиться (з динамічним
import()); - нижня частина довгих сторінок не займає час на старті.
Обмеження й ризики:
- працює лише з SSR і асинхронними компонентами; у звичайному SPA гідрації немає;
- до гідрації компонент не реагує - якщо користувач клікне на кнопку з
hydrateOnVisibleраніше, ніж та гідрується, клік не спрацює (на відміну відhydrateOnInteraction); - стан, що залежить від інших компонентів, - компонент, гідрований пізніше, не бачив попередніх подій;
- Nuxt має власні обгортки над цими стратегіями (
hydrate-on-visibleтощо) для компонентів з префіксомLazy.
Підхід схожий на «острівці» (Astro): інтерактивність вмикається лише там і тоді, де вона потрібна.
Докладніше в документації: Асинхронні компоненти: лінива гідрація
Багатьом компонентам потрібні унікальні id для зв'язку елементів: <label for> і <input id>, aria-describedby для підказок і помилок, aria-controls для вкладок і розкривних блоків.
Наївні способи ламаються:
const id = `input-${Math.random().toString(36).slice(2)}` // різний на сервері й клієнті
let counter = 0
const id = `input-${++counter}` // залежить від порядку створення
- з SSR сервер і браузер згенерують різні значення - помилка гідрації, і
forвказуватиме на неіснуючий елемент; - глобальний лічильник на сервері продовжує рахувати між запитами різних користувачів, а в браузері починає з нуля;
- асинхронні компоненти й ліниве завантаження змінюють порядок створення компонентів.
useId() (Vue 3.5+) генерує id, стабільний між сервером і клієнтом та унікальний у межах застосунку:
<script setup lang="ts">
import { useId } from 'vue'
defineProps<{ label: string; error?: string }>()
const id = useId()
</script>
<template>
<label :for="id">{{ label }}</label>
<input :id="id" :aria-describedby="error ? `${id}-error` : undefined" />
<p v-if="error" :id="`${id}-error`">{{ error }}</p>
</template>
Один useId() на компонент - а похідні id (${id}-error, ${id}-hint) будуються суфіксами.
Правила:
- викликати в
setup(на верхньому рівні<script setup>), а не вcomputed, обробниках чи циклах - id прив'язаний до екземпляра компонента; - кілька застосунків на одній сторінці (наприклад, кілька окремих віджетів Vue на сторінці Blade) можуть згенерувати однакові id - для них задають префікс через
app.config.idPrefix; - id не варто використовувати як ключ даних чи зберігати - це лише зв'язок елементів DOM.
Навіть без SSR useId кращий за власні лічильники: передбачуваний, без глобального стану, без випадкових зіткнень.
Чому це важливо: зв'язані через id мітки й описи - основа доступності форм. Зчитувач екрана озвучує мітку поля й текст помилки, а клік на мітці фокусує поле. Зламаний id мовчки руйнує це для частини користувачів.
Докладніше в документації: Допоміжні функції Composition API: useId