Що таке слоти у Vue?
Slots (слоти) дозволяють батьківському компоненту вставляти довільний контент у заздалегідь визначені місця в шаблоні дочірнього компонента.
Теорія
TL;DR
- Слоти - це як USB-порти: дочірній компонент визначає порти, батьківський підключає будь-який контент
- Три типи: default (безіменний), named (з іменем), scoped (дочірній передає дані батьківському)
- Слоти передають контент (HTML, компоненти); props передають дані (рядки, об'єкти)
- Правило вибору: батько контролює структуру UI? Слот. Тільки дані? Props.
- Синтаксис Vue 3:
#nameабоv-slot:name(стара формаslot="name"є застарілою)
Швидкий приклад
<!-- Alert.vue -->
<template>
<div class="alert">
<slot name="icon">⚠️</slot> <!-- named slot з fallback -->
<slot>Повідомлення за замовчуванням</slot> <!-- default slot -->
</div>
</template><!-- Батько -->
<Alert>
<template #icon>🚨</template>
<p>Термінове повідомлення!</p>
</Alert>
<!-- Результат: 🚨 Термінове повідомлення! -->Якщо батько не надає #icon, показується fallback ⚠️ з дочірнього компонента. Контент всередині <slot> - це і є той fallback.
Слоти проти props
Props переносять дані: рядки, числа, об'єкти. Слоти переносять структуру: HTML, інші компоненти, все що можна написати в шаблоні. Передати <CustomButton> або стилізований абзац через prop неможливо без того, щоб це перетворилось на екранований рядок. Слоти вирішують саме цю проблему.
Контент слота живе у скоупі батька. Батько його пише, тому має доступ до своїх змінних і методів. Scoped slots додають зворотний зв'язок: дочірній компонент ділиться власними даними з батьківським.
Default, named і scoped слоти
Default slot - безіменний слот. Будь-який контент, розміщений безпосередньо між тегами компонента, потрапляє в нього.
Named slots дозволяють заповнювати кілька місць окремо. Компонент макету може мати слоти header, default і footer, кожен із яких батько контролює незалежно.
<!-- Layout.vue -->
<template>
<header><slot name="header" /></header>
<main><slot /></main>
<footer><slot name="footer" /></footer>
</template><!-- Використання -->
<Layout>
<template #header><h1>Заголовок сторінки</h1></template>
<p>Основний контент</p>
<template #footer><p>© 2025</p></template>
</Layout>Scoped slots - найгнучкіший тип. Дочірній компонент передає дані через слот, а батьківський вирішує, як їх відобразити. Класичний приклад: список, який сам зберігає дані, але делегує рендеринг батьку.
<!-- UserList.vue -->
<template>
<ul>
<li v-for="user in users" :key="user.id">
<slot :item="user" />
</li>
</ul>
</template>
<script setup>
import { ref } from 'vue'
const users = ref([{ id: 1, name: 'Аліса' }, { id: 2, name: 'Боб' }])
</script><!-- Dashboard.vue -->
<UserList>
<template #default="{ item }">
<strong>{{ item.name }}</strong>
<button @click="editUser(item)">Редагувати</button>
</template>
</UserList>Список відповідає за ітерацію і дані. Батько контролює відображення. У більшості дашбордів, з якими доводилось працювати, цей патерн зустрічається в кожній таблиці даних - саме завдяки йому бібліотеки Vuetify та Element Plus такі гнучкі без необхідності їх форкати.
Коли використовувати
- Загальний контейнер (картка, модалка, панель) → default slot
- Кілька різних областей (header, body, footer) → named slots
- Дочірній компонент зберігає дані, батько контролює рендеринг (таблиці, списки, дропдауни) → scoped slots
- Передаються тільки рядки або примітиви → достатньо props
- Глибоке передавання даних без prop drilling → provide/inject
Як Vue обробляє слоти всередині
Компілятор шаблонів Vue сканує теги <slot> і створює точки вставки в render-функції дочірнього компонента. Під час рендерингу контент батьківського компонента компілюється в окрему render-функцію, яка підставляється в ці точки. Scoped slots працюють так само, але дочірній компонент передає об'єкт з даними при виклику функції слота. У Vue 3 реактивні дані слота передаються через Proxy, тому зміни в дочірньому компоненті автоматично оновлюють контент слота без ре-рендеру всього дерева.
Перевірити наявність слота можна через $slots.name у шаблоні або useSlots() у <script setup>:
<script setup>
import { useSlots } from 'vue'
const slots = useSlots()
</script>
<template>
<slot name="tab2" />
<p v-if="!slots.tab2">Контент не надано</p>
</template>Типові помилки
Передача HTML через prop:
<!-- Неправильно -->
<Child :content="'<p>Привіт</p>'" />
<!-- Рендериться як екранований рядок, не HTML --><!-- Правильно -->
<Child><p>Привіт</p></Child>Props серіалізують значення. HTML переданий як рядок відрендериться як <p>Привіт</p>.
Використання застарілого синтаксису Vue 2 у Vue 3:
<!-- Неправильно для Vue 3 -->
<Child slot="header">Контент</Child><!-- Правильно -->
<Child v-slot:header>Контент</Child>
<!-- або скорочено -->
<Child #header>Контент</Child>Атрибут slot є застарілим. Компілятор Vue 3 очікує v-slot:name або #name.
Спроба змінити дані scoped slot:
<!-- Неправильно: мутація не збережеться -->
<List v-slot="{ item }">
{{ item.name = 'Змінено' }}
</List>Slot props доступні тільки для читання в шаблоні батька. Вони передаються через Proxy, який ігнорує мутації. Щоб оновити дані, потрібно emit-нути подію і обробити її в батьку:
<List @update="handleUpdate" v-slot="{ item }">
<button @click="$emit('update', item)">Оновити</button>
</List>Безіменні вкладені слоти:
Коли компоненти вкладені і жоден слот не названий, кілька <slot /> конкурують за один default. Завжди називай слоти, якщо компонент має більше однієї контентної зони.
Де використовується
- Vuetify:
<v-card><template #text>Custom content</template></v-card> - Element Plus:
<el-table><template #default="{ row }">...</template></el-table> - Nuxt UI: компоненти макетів використовують
<slot name="hero" />для секцій сторінки - PrimeVue:
<DataTable><template #body="slotProps">...</template></DataTable>
Scoped slots - це основа компонентів відображення даних у кожній великій UI-бібліотеці для Vue.
Follow-up питання
Q: Яка різниця між default і named слотом?
A: Default заповнює безіменний <slot />. Named слоти відповідають конкретному <slot name="x" /> через #x або v-slot:x. В одному компоненті можуть бути обидва типи одночасно.
Q: Чи може контент слота звертатись до даних дочірнього компонента?
A: Не напряму. Контент слота живе у скоупі батька і бачить тільки батьківські змінні. Scoped slots вирішують це: <slot :item="user" /> робить user доступним у батьку через v-slot="{ item }".
Q: Як перевірити, чи батько заповнив слот?
A: У шаблоні - через $slots.name. У <script setup> - через useSlots() з Composition API, який повертає реактивний об'єкт із наявними слотами.
Q: Що змінилось у синтаксисі слотів між Vue 2 і Vue 3?
A: Vue 2 використовував slot="name" на елементах і slot-scope="data" для scoped слотів. Vue 3 замінив обидва на v-slot:name="data" (скорочено #name="data"). Старий синтаксис ще працює, але є застарілим і може бути видалений.
Q: Як Vue оптимізує scoped slots при використанні з Suspense або async-компонентами?
A: У Vue 3 контент слота може призупинятись незалежно від батька. Дочірній компонент може огорнути слот у <Suspense> для показу fallback поки async-контент завантажується. Реактивні дані слота проходять через Proxy, тому при зміні даних в дочірньому компоненті оновлюється тільки відповідний слот, а не все дерево компонентів.
Приклади
Базовий: Компонент картки з named slots і fallback
<!-- Card.vue -->
<template>
<div class="card">
<div class="card-header">
<slot name="header">Заголовок за замовчуванням</slot>
</div>
<div class="card-body">
<slot />
</div>
<!-- footer div існує в DOM тільки якщо батько надав контент -->
<div class="card-footer" v-if="$slots.footer">
<slot name="footer" />
</div>
</div>
</template><!-- ProductPage.vue -->
<Card>
<template #header>
<h2>MacBook Pro</h2>
</template>
<p>Найкращий ноутбук для розробників.</p>
<p>Ціна: $2,499</p>
<template #footer>
<button @click="addToCart">Додати в кошик</button>
</template>
</Card>v-if="$slots.footer" прибирає весь div з DOM, якщо батько не надав контент для footer. Не порожній блок, а повна відсутність елемента. Це важливо, коли CSS grid або flex правила адресують прямих дітей.
Середній: Таблиця даних зі scoped slots
Поширений патерн для дашбордів: таблиця відповідає за структуру та ітерацію, рендеринг кожного стовпця делегується батьку.
<!-- DataTable.vue -->
<template>
<table>
<thead>
<tr>
<th v-for="col in columns" :key="col.key">{{ col.label }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.id">
<td v-for="col in columns" :key="col.key">
<!-- scoped slot передає і весь рядок, і значення клітинки -->
<slot :name="col.key" :row="row" :value="row[col.key]">
{{ row[col.key] }} <!-- за замовчуванням: просто текст -->
</slot>
</td>
</tr>
</tbody>
</table>
</template>
<script setup>
defineProps({ columns: Array, rows: Array })
</script><!-- UsersDashboard.vue -->
<DataTable :columns="cols" :rows="users">
<!-- кастомний рендер для стовпця статусу -->
<template #status="{ value }">
<span :class="value === 'active' ? 'badge-green' : 'badge-red'">
{{ value }}
</span>
</template>
<!-- кастомний рендер для стовпця дій -->
<template #actions="{ row }">
<button @click="editUser(row)">Редагувати</button>
<button @click="deleteUser(row.id)">Видалити</button>
</template>
</DataTable>Стовпці без кастомного слота автоматично показують звичайний текст. Саме так влаштований <el-table> в Element Plus.
Senior: Реактивне визначення слотів через useSlots
<!-- DashboardWidget.vue -->
<template>
<div class="widget">
<div class="widget-header">
<slot name="title">
<span>{{ defaultTitle }}</span>
</slot>
<!-- панель дій монтується тільки якщо батько її заповнив -->
<div v-if="slots.actions" class="widget-actions">
<slot name="actions" />
</div>
</div>
<div class="widget-body">
<slot />
</div>
</div>
</template>
<script setup>
import { useSlots } from 'vue'
defineProps({ defaultTitle: { type: String, default: 'Віджет' } })
const slots = useSlots()
</script><!-- AdminPanel.vue -->
<DashboardWidget default-title="Трафік">
<template #actions>
<button @click="refresh">Оновити</button>
<button @click="exportData">Експорт</button>
</template>
<TrafficChart :data="chartData" />
</DashboardWidget>useSlots() повертає реактивний об'єкт. Якщо батько динамічно додає або прибирає #actions, віджет реагує без додаткової логіки. Цей підхід прибирає порожні wrapper div з HTML, що важливо для CSS grid макетів, які адресують прямих дітей.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.