Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Senior: питання на співбесіді з теми «Продуктивність і SSR»

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

4 питання

У звичайному Vue-застосунку (SPA) сервер віддає майже порожній HTML, а сторінку будує JavaScript у браузері. SSR рендерить ті самі компоненти на сервері в готовий HTML, а в браузері Vue «оживляє» його - гідрує.

Як це влаштовано:

  1. на сервері (Node.js) створюється застосунок, завантажуються дані, компоненти рендеряться в рядок HTML (renderToString з vue/server-renderer);
  2. браузер отримує готову сторінку - вміст видно одразу, ще до завантаження JavaScript;
  3. завантажується той самий код застосунку, Vue проходить по існуючому DOM і прив'язує до нього реактивність та обробники подій, не перестворюючи елементи;
  4. далі застосунок працює як звичайний 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: невідповідність гідрації

У 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