Динамічні компоненти у Vue.js
Динамічні компоненти (dynamic components) дають змогу замінювати один Vue-компонент іншим в одному місці DOM - достатньо змінити те, на що вказує атрибут :is у <component>.
Теорія
TL;DR
- Уяви пульт від телевізора:
<component>- це екран,:is- кнопка перемикання каналів. - Коли
:isзмінюється, Vue демонтує старий компонент і монтує новий на тому ж місці. - Передавай об'єкт компонента в
:is, а не рядок. Рядок нічого не рендерить і не видає помилок. - Обгорни в
<KeepAlive>, якщо хочеш зберегти стан при поверненні до компонента. - Зберігай активний компонент через
shallowRef, а неref.
Швидкий приклад
<script setup>
import { shallowRef } from 'vue'
import Home from './Home.vue'
import Settings from './Settings.vue'
// shallowRef стежить тільки за посиланням, а не за вмістом об'єкта компонента
const activeTab = shallowRef(Home)
const tabs = { home: Home, settings: Settings }
</script>
<template>
<button @click="activeTab = tabs.home">Головна</button>
<button @click="activeTab = tabs.settings">Налаштування</button>
<!-- Vue демонтує Home і монтує Settings коли activeTab змінюється -->
<component :is="activeTab" />
</template>Натискаєш «Налаштування» - Vue знищує екземпляр Home і створює свіжий Settings на тому ж місці.
Головна різниця
Динамічні компоненти вирішують іншу задачу, ніж v-if. З v-if ти пишеш окрему гілку для кожного компонента, і шаблон росте з кожним новим варіантом. З <component :is> ти просто додаєш запис в об'єкт, а шаблон не змінюється. Починаючи з трьох компонентів ланцюжки v-if стають незручними. Саме тоді <component :is> показує свою цінність.
Коли використовувати
- Таби: перемикання між видами без перезавантаження сторінки
- Багатокрокові форми: кожен крок - окремий компонент
- Системи плагінів: завантаження компонентів на основі конфігурації або даних
- Адмін-панелі: різні типи блоків (графіки, таблиці, форми) за типом даних
- CMS-блоки: hero, gallery, testimonial з конфігураційного масиву
Для одного-двох варіантів простіше залишити v-if. Він зрозуміліший і явніший у таких випадках.
Як це працює всередині
Коли :is змінюється, система реактивності Vue фіксує нове значення і запускає оновлення компонента. Старий компонент отримує хуки beforeUnmount і unmounted, його DOM-вузли видаляються, потім монтується новий з beforeMount і mounted. Якщо обгорнути <component> в <KeepAlive>, Vue кешує старий екземпляр замість того, щоб знищувати його. Повернення до нього миттєве, і весь стан (введені дані, результати запитів) зберігається.
Типові помилки
Передача рядка замість об'єкта компонента
<!-- НЕПРАВИЛЬНО: Vue шукає 'HomeView' в глобальному реєстрі. Нічого не рендериться. Жодної помилки. -->
<script setup>
import { ref } from 'vue'
const currentView = ref('HomeView')
</script>
<template>
<component :is="currentView" />
</template><!-- ПРАВИЛЬНО: передай сам об'єкт компонента -->
<script setup>
import { shallowRef } from 'vue'
import HomeView from './HomeView.vue'
import SettingsView from './SettingsView.vue'
const views = { home: HomeView, settings: SettingsView }
const currentView = shallowRef(HomeView)
</script>
<template>
<component :is="currentView" />
</template>Це тихий баг - нема попередження, нема рендеру. Можна витратити 20 хвилин, перш ніж зрозумієш що відбувається.
Втрата стану без KeepAlive
<!-- НЕПРАВИЛЬНО: користувач заповнив форму, переключив таб, повернувся - форма порожня -->
<component :is="currentTab" />
<!-- ПРАВИЛЬНО: дані форми виживають при перемиканні табів -->
<KeepAlive>
<component :is="currentTab" />
</KeepAlive>При кожній зміні :is Vue знищує старий екземпляр. Дані в полях, позиція скролу, результати API-запитів - все зникає. Користувачі одразу це помічають і сприймають як баг.
Таймери продовжують працювати в кешованих компонентах
<!-- НЕПРАВИЛЬНО: таймер працює навіть коли таб прихований -->
<script setup>
import { onMounted } from 'vue'
onMounted(() => {
setInterval(() => fetch('/api/updates'), 5000)
})
</script><!-- ПРАВИЛЬНО: зупиняй при приховуванні, відновлюй при показі -->
<script setup>
import { onMounted, onActivated, onDeactivated } from 'vue'
let timerId
onMounted(() => {
timerId = setInterval(() => fetch('/api/updates'), 5000)
})
onDeactivated(() => clearInterval(timerId))
onActivated(() => {
timerId = setInterval(() => fetch('/api/updates'), 5000)
})
</script>З <KeepAlive> компонент залишається в пам'яті. Його таймери і підписки продовжують виконуватися. П'ять кешованих табів - це п'ять паралельних polling-циклів у фоні.
ref замість shallowRef
<script setup>
// НЕПРАВИЛЬНО: ref глибоко спостерігає за об'єктом компонента - зайві витрати
const current = ref(MyComponent)
// ПРАВИЛЬНО: shallowRef стежить тільки за посиланням, не за вмістом
const current = shallowRef(MyComponent)
</script>Об'єкти компонентів можуть бути великими. Глибока реактивність на них додає роботу, яка Vue не потрібна.
Де зустрічається
- Vue Router використовує цей патерн всередині
<RouterView>для рендеру компонентів маршрутів - Nuxt поєднує динамічні компоненти з
<ClientOnly>для пропуску SSR на певних видах - Конструктори форм рендерять типи полів (text, select, checkbox) з конфігураційного масиву
- В e-commerce сторінки товарів перемикаються між виглядом для цифрових і фізичних продуктів
Питання на співбесіді
Q: Яка різниця між <component :is> і v-if для перемикання видів?
A: v-if простіший для одного-двох варіантів. <component :is> краще масштабується при трьох і більше опціях, бо шаблон не росте. Обидва демонтують старий компонент при зміні умови.
Q: Коли використовувати <KeepAlive>, а коли дозволити компоненту демонтуватись?
A: <KeepAlive> потрібен, коли компонент зберігає стан, що варто зберегти: дані форми, результати запитів, позицію скролу. Без нього - коли хочеш чистий екземпляр щоразу або компонент не має стану.
Q: Чи можна передавати пропси в динамічний компонент?
A: Так, так само як в будь-який статичний: <component :is="current" :user-id="123" @save="handleSave" />. Пропси і події працюють звичайно.
Q: (Senior) Як побудувати систему динамічних компонентів, де кожен компонент керує своїм lifecycle, не залежачи від батьківського?
A: Використовуй <KeepAlive> з onActivated і onDeactivated всередині кожного дочірнього компонента. Батько просто перемикає :is. Кожен компонент сам відповідає за налаштування і очищення. Для спільного стану або спільних колбеків - provide/inject.
Приклади
Базовий інтерфейс з табами
<script setup>
import { shallowRef } from 'vue'
import UserProfile from './UserProfile.vue'
import UserSettings from './UserSettings.vue'
import UserNotifications from './UserNotifications.vue'
const tabs = {
profile: UserProfile,
settings: UserSettings,
notifications: UserNotifications,
}
const currentTab = shallowRef(UserProfile)
</script>
<template>
<div class="tabs">
<button
v-for="(comp, name) in tabs"
:key="name"
:class="{ active: currentTab === comp }"
@click="currentTab = comp"
>
{{ name }}
</button>
<!-- Без KeepAlive: перемикання табів знищує старий екземпляр компонента -->
<component :is="currentTab" />
</div>
</template>Переключись на «settings» і назад на «profile» - компонент профілю монтується з нуля. Будь-які введені дані зникають. Це поведінка за замовчуванням, і інколи саме це і потрібно.
Конструктор форм з динамічними типами полів
<script setup>
import TextInput from './TextInput.vue'
import SelectInput from './SelectInput.vue'
import CheckboxInput from './CheckboxInput.vue'
const fieldComponents = {
text: TextInput,
select: SelectInput,
checkbox: CheckboxInput,
}
const formFields = [
{ type: 'text', name: 'username', label: 'Імʼя користувача' },
{ type: 'select', name: 'role', label: 'Роль', options: ['Адмін', 'Користувач'] },
{ type: 'checkbox', name: 'active', label: 'Активний акаунт' },
]
</script>
<template>
<form>
<div v-for="field in formFields" :key="field.name">
<component
:is="fieldComponents[field.type]"
v-bind="field"
/>
</div>
</form>
</template>Кожне поле рендерить потрібний компонент на основі свого type. Додати новий тип поля - один запис в fieldComponents і один об'єкт в formFields. Шаблон не змінюється.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.