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

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

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

12 питань

Обидві директиви дозволяють пропустити роботу з оновлення частини шаблону.

v-once - відрендерити елемент чи компонент один раз і більше ніколи не оновлювати:

<footer v-once>
  <p>© {{ year }} {{ companyName }}</p>
  <LegalLinks />
</footer>

При наступних рендерах Vue пропускає всю цю гілку разом з нащадками. Підходить для статичного за змістом вмісту, що будується з даних лише раз.

v-memo - оновлювати лише тоді, коли змінилося одне зі значень у масиві залежностей:

<div v-for="item in list" :key="item.id" v-memo="[item.id === selectedId]">
  <p>ID: {{ item.id }} - вибрано: {{ item.id === selectedId }}</p>
  <ExpensiveRow :item="item" />
</div>

Коли змінюється selectedId, перерендерюються лише два рядки - той, що втратив вибір, і той, що його отримав. Решта тисячі рядків пропускається.

Коли це має сенс:

  • v-memo - великі списки (сотні й тисячі елементів), де зміна стосується небагатьох рядків, і вимірювання показали, що рендер списку гальмує;
  • v-once - великі статичні блоки, побудовані з даних, які не змінюються протягом життя компонента.

Пастки:

  • забута залежність: якщо в шаблоні під v-memo використано значення, якого немає в масиві, зміни цього значення не з'являться на екрані. Найчастіша помилка - баг виглядає як «реактивність зламалася»;
  • v-memo="[]" працює як v-once;
  • v-memo всередині v-for не працює на дочірніх елементах - тільки на тому ж елементі, що й v-for;
  • передчасна оптимізація: у більшості компонентів оновлення й так дешеві. Ці директиви - інструмент для виміряних проблем, а не для кожного шаблону;
  • v-once на компоненті з даними, які все ж змінюються (наприклад, переклад після зміни мови), - застарілий інтерфейс.

Докладніше в документації: Вбудовані директиви: v-memo

Звичайний імпорт компонента потрапляє в головний бандл: навіть якщо важкий редактор чи графік відкриють раз на тиждень, його код завантажується з кожною сторінкою.

defineAsyncComponent завантажує компонент при першому рендері:

import { defineAsyncComponent } from 'vue'

const ChartPanel = defineAsyncComponent(() => import('./ChartPanel.vue'))
<button @click="showChart = true">Показати графік</button>
<ChartPanel v-if="showChart" :data="sales" />

Динамічний import() змушує збирач винести компонент і його залежності (бібліотеку графіків) в окремий файл. Він завантажиться, лише коли showChart стане true.

Стани завантаження й помилки:

const ChartPanel = defineAsyncComponent({
  loader: () => import('./ChartPanel.vue'),
  loadingComponent: Spinner,
  delay: 200,           // показати спінер лише якщо завантаження довше 200 мс
  errorComponent: LoadError,
  timeout: 10000,       // вважати помилкою після 10 с
})

delay за замовчуванням 200 мс - щоб на швидкому з'єднанні спінер не «блимав».

Де застосовувати:

  • модальні вікна, редактори тексту, карти, графіки, PDF-переглядачі;
  • вкладки, які відкривають рідко;
  • компоненти для певних ролей (адмінські панелі всередині звичайної сторінки).

Сторінки маршрутизатора розділяють так само, але простіше - Vue Router приймає функцію з import() прямо в маршруті.

Що варто знати:

  • асинхронний компонент приймає ті самі props, слоти й події, що й звичайний - для батька різниці немає;
  • не дробити надто дрібно: десятки маленьких частин - десятки запитів. Виносять важке й рідко потрібне;
  • помилки після деплою: у давно відкритій вкладці стара частина може зникнути з сервера - errorComponent з пропозицією оновити сторінку краще за порожнє місце;
  • у SSR асинхронні компоненти можна ще й гідрувати ліниво (Vue 3.5+).

Докладніше в документації: Асинхронні компоненти

Коли компонент зникає з екрана (перемкнули вкладку, v-if став false, змінився маршрут), Vue знищує його екземпляр: стан, введені дані, позиція прокрутки губляться. При поверненні компонент створюється заново.

<KeepAlive> кешує екземпляри компонентів замість знищення:

<KeepAlive :include="['OrdersTab', 'ReportsTab']" :max="5">
  <component :is="currentTab" />
</KeepAlive>

Перемкнулися з вкладки «Замовлення» на «Звіти» й назад - фільтри, введений пошук і завантажені дані на місці.

Параметри:

  • include / exclude - які компоненти кешувати (за назвою компонента: у <script setup> - назва файлу чи defineOptions({ name }));
  • max - скільки екземплярів тримати; при переповненні витісняється найдавніше використаний.

З маршрутизатором:

<RouterView v-slot="{ Component }">
  <KeepAlive>
    <component :is="Component" />
  </KeepAlive>
</RouterView>

Хуки кешованого компонента: onActivated і onDeactivated - замість onMounted/onUnmounted, які при показі з кешу не викликаються.

Підводні камені:

  • застарілі дані: компонент з кешу показує дані з моменту, коли його сховали. Оновлювати їх треба в onActivated, інакше користувач бачить замовлення годинної давнини;
  • пам'ять: кожен кешований екземпляр тримає свій стан, DOM і дані. Без max і include кеш росте з кожною новою сторінкою - особливо з маршрутами виду /orders/:id, де кожен id - окремий запис кешу;
  • побічні ефекти продовжують працювати: таймери, підписки на WebSocket, обробники на window у прихованому компоненті живуть далі. Їх треба призупиняти в onDeactivated;
  • ключі маршрутів: кешування сторінки з параметром може показати дані попереднього id, якщо компонент перевикористовується - потрібен :key чи реакція на зміну параметра.

Коли не потрібен: якщо стан простіше зберегти в Pinia чи URL (фільтри, номер сторінки) - це надійніше й не тримає цілий компонент у пам'яті.

Докладніше в документації: KeepAlive

Без розділення весь застосунок - усі сторінки, адмінка, рідко потрібні розділи - потрапляє в один JavaScript-файл. Користувач, що відкрив головну, завантажує і код сторінки налаштувань, і графіки звітів.

Ліниві маршрути - компонент сторінки задається функцією з динамічним import():

const routes = [
  { path: '/', component: () => import('./pages/Home.vue') },
  { path: '/orders', component: () => import('./pages/Orders.vue') },
  { path: '/reports', component: () => import('./pages/Reports.vue') },
]

Збирач (Vite) виносить кожну сторінку разом з її унікальними залежностями в окремий файл. Він завантажується при першому переході на маршрут.

Не використовувати defineAsyncComponent для маршрутів - Vue Router сам уміє працювати з функцією, що повертає Promise; обгортка лише заважає.

Групування в одну частину - кілька пов'язаних сторінок разом (наприклад, розділ адмінки), щоб не робити три запити поспіль. У Vite це налаштовується через опції збирання (manualChunks / групування частин).

Що варто знати:

  • затримка першого переходу: при кліку на маршрут код ще треба завантажити. Допомагає попереднє завантаження (<link rel="modulepreload">, завантаження при наведенні на посилання) і показ індикатора завантаження;
  • помилки завантаження після деплою: якщо користувач тримав вкладку відкритою, а файли старої збірки видалили, перехід падає. router.onError - місце, щоб обробити це (наприклад, перезавантажити сторінку на новий URL);
  • головна сторінка - не робіть лінивою найчастішу точку входу, якщо це не дає реального виграшу: це лише додатковий запит;
  • спільні залежності (Vue, Pinia, бібліотека компонентів) збирач виносить в окремий спільний файл автоматично.

В Inertia-застосунках (Laravel + Vue) роль маршрутів виконують сторінки Inertia, і розділення налаштовується в resolve через import.meta.glob('./Pages/**/*.vue') - без { eager: true } кожна сторінка стає окремою частиною.

Як перевірити результат: rollup-plugin-visualizer показує, що потрапило в кожну частину і скільки важить.

Докладніше в документації: Vue Router: ліниве завантаження маршрутів

Дочірній компонент оновлюється, коли змінюється хоча б один з його props. Якщо prop залежить від стану, що змінюється часто, оновлюються всі діти, навіть ті, для яких результат той самий.

Класичний приклад:

<ListItem
  v-for="item in list"
  :key="item.id"
  :id="item.id"
  :active-id="activeId"
/>

Усередині ListItem порівнює id === activeId. Змінився activeId - змінився prop у кожного елемента, і перерендерюються всі сто рядків.

Стабільніший варіант - обчислити результат у батька:

<ListItem
  v-for="item in list"
  :key="item.id"
  :id="item.id"
  :active="item.id === activeId"
/>

Тепер при зміні вибору active змінюється лише у двох рядків - решта отримують той самий false, і Vue їх пропускає.

Інші джерела нестабільних props:

  • новий об'єкт чи масив у шаблоні: :options="{ size: 'sm' }" чи :items="list.filter(...)" створюють нове посилання на кожен рендер батька - дочірній компонент оновлюється завжди. Виносьте такі значення в computed чи константи;
  • інлайн-функції - для подій у Vue це не проблема (обробники кешуються компілятором), а для props-функцій - так само нове посилання щоразу;
  • передача цілого об'єкта, коли потрібне одне поле, - дитина оновлюється при зміні будь-якого поля об'єкта.

Як це побачити: Vue DevTools (вкладка продуктивності й хронологія) показують, скільки компонентів оновилося після дії і скільки часу це зайняло. Хук onUpdated з лічильником у підозрілому компоненті - швидка перевірка в розробці.

Коли це варто оптимізувати: списки на сотні елементів, важкі дочірні компоненти, часті зміни (введення тексту, анімації, перетягування). Для десятка простих рядків різниця невідчутна.

Допоміжні інструменти: v-memo для крайніх випадків, а для великих списків - віртуалізація, щоб рендерити лише видимі рядки.

Докладніше в документації: Продуктивність: стабільність props

З Vue 3.4 обчислювана властивість запускає залежні ефекти (рендер, watch) лише якщо її значення справді змінилося, а не щоразу, коли змінилися її залежності.

const count = ref(1)
const isEven = computed(() => count.value % 2 === 0)

watchEffect(() => console.log(isEven.value))

count.value = 3   // isEven лишився false - ефект не запускається
count.value = 5   // теж
count.value = 6   // true - запускається

До 3.4 кожна зміна count змушувала перезапустити все, що читало isEven, навіть з тим самим результатом.

Порівняння - за Object.is. Для примітивів це працює чудово. А от computed, що щоразу повертає новий об'єкт чи масив, завжди вважається зміненим:

const activeItems = computed(() => items.value.filter((i) => i.active))

Будь-яка зміна items (навіть поля, яке не впливає на фільтр) дає новий масив - залежні компоненти оновлюються.

Ручна стабілізація - через попереднє значення, яке Vue передає першим аргументом:

const summary = computed((oldValue) => {
  const next = { done: doneCount.value, total: total.value }
  if (oldValue && oldValue.done === next.done && oldValue.total === next.total) {
    return oldValue     // те саме посилання - залежні не оновлюються
  }
  return next
})

Обов'язково обчислювати нове значення повністю до порівняння, щоб Vue зібрав усі залежності на кожному запуску.

Практичні висновки:

  • computed, що повертає примітив (boolean, number, рядок), - найкращий кандидат для передачі в дочірні компоненти й шаблони: стабільний автоматично;
  • для об'єктів, що рідко змінюються по суті, - стабілізація через oldValue, коли вимірювання показують проблему;
  • побічних ефектів у computed бути не повинно: завдяки ліньому й кешованому обчисленню невідомо, скільки разів і коли він виконається.

Пов'язане: watch з deep: true на великих об'єктах теж дорогий - кожна зміна обходить усю структуру. З Vue 3.5 deep приймає число - глибину обходу.

Докладніше в документації: Продуктивність: стабільність computed

Список на 10 000 рядків - це 10 000+ DOM-вузлів і стільки ж екземплярів компонентів. Навіть ідеально оптимізована реактивність не врятує: браузер повільно розраховує розкладку, прокрутка смикається, пам'ять росте, а перший рендер займає секунди.

Віртуалізація (windowing) рендерить лише рядки, видимі на екрані, плюс невеликий запас зверху й знизу. При прокрутці ті самі DOM-вузли перевикористовуються для нових даних, а висоту прокручуваної області імітують відступами.

<script setup lang="ts">
import { useVirtualList } from '@vueuse/core'

const { list, containerProps, wrapperProps } = useVirtualList(rows, { itemHeight: 48 })
</script>

<template>
  <div v-bind="containerProps" style="height: 600px">
    <div v-bind="wrapperProps">
      <OrderRow v-for="{ data, index } in list" :key="data.id" :order="data" />
    </div>
  </div>
</template>

Готові рішення: vue-virtual-scroller, useVirtualList з VueUse, TanStack Virtual для Vue.

Результат: у DOM 20-30 рядків замість 10 000 - рендер миттєвий, прокрутка плавна.

Складнощі:

  • різна висота рядків - потрібне вимірювання або оцінка висоти; бібліотеки підтримують динамічну висоту, але це складніше й повільніше;
  • пошук браузера (Ctrl+F) не знайде текст рядків, яких немає в DOM;
  • доступність: зчитувачі екрана бачать лише відрендерені рядки - потрібні атрибути aria-rowcount, aria-rowindex;
  • стан рядків: розгорнутий рядок чи введене значення, що зберігалося в компоненті рядка, губиться при прокрутці - стан має жити в даних, а не в компоненті;
  • таблиці з фіксованими колонками й заголовками вимагають узгодженої ширини колонок.

Альтернативи, які інколи кращі:

  • пагінація - простіша, зрозуміла користувачу, і дані завантажуються частинами з сервера;
  • «Завантажити ще» - доки рядків сотні, а не тисячі;
  • серверні фільтри й пошук, щоб користувач узагалі не гортав тисячі рядків.

Ознака, що потрібна віртуалізація: вимірювання показують, що повільні саме рендер і розкладка, а список справді має бути довгим і безперервним (журнали, таблиці даних, чати).

Докладніше в документації: Продуктивність: віртуалізація списків

Розмір JavaScript безпосередньо впливає на час, коли сторінка стає інтерактивною: файл треба завантажити, розібрати й виконати, а на слабких телефонах останні два кроки займають більше часу, ніж мережа.

Що Vue робить сам:

  • компіляція шаблонів заздалегідь: з Vite шаблони .vue-файлів компілюються під час збирання, тому в бандл іде runtime-збірка Vue без компілятора шаблонів (приблизно на третину менша). Компілятор потрібен лише якщо шаблони задаються рядками під час виконання - цього варто уникати;
  • tree shaking API: якщо не використовується <Transition> чи <KeepAlive>, їхнього коду не буде в бандлі.

Що робити вам:

1. Подивитися, що всередині. rollup-plugin-visualizer показує розмір кожного модуля. Майже завжди знаходяться сюрпризи: уся бібліотека іконок через один імпорт, повна локалізація бібліотеки дат, дві версії однієї залежності.

2. Імпортувати точково:

import { debounce } from 'lodash-es'      // замість import _ from 'lodash'
import CalendarIcon from '~icons/heroicons/calendar'   // одна іконка, а не набір

3. Обирати залежності з думкою про розмір - перевірити розмір пакета до встановлення (bundlephobia, pkg-size). Інколи 20 рядків власного коду кращі за бібліотеку на 50 КБ.

4. Розділяти код - ліниві маршрути й асинхронні компоненти для рідко потрібного.

5. Бібліотеки компонентів - підключати компоненти за потреби (автоімпорт unplugin-vue-components), а не реєструвати всю бібліотеку глобально.

6. Пакети у форматі ES-модулів - для них працює tree shaking; пакети CommonJS потрапляють цілими.

Метрики й контроль:

  • розмір після стиснення (gzip/brotli) - те, що реально йде мережею;
  • бюджет розміру в CI (size-limit чи перевірка звіту збирання) - щоб регресії ловилися в пул-реквесті, а не через пів року;
  • Lighthouse і реальні метрики (INP, LCP) - кінцевий критерій, а не кілобайти самі по собі.

Для Laravel + Inertia: сторінки, підключені через import.meta.glob з { eager: true }, потрапляють у головний бандл усі разом - для великих застосунків краще ліниве завантаження сторінок.

Докладніше в документації: Продуктивність: розмір бандла

У звичайному 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