Перейти к содержимому

10. Слоты (Slots)

Слоты — это как дырки в компоненте, куда родитель может вставить свой HTML. Представь, что ты делаешь компонент-карточку, но хочешь, чтобы содержимое карточки мог задавать тот, кто её использует. Вот тут и приходят слоты! 🚀


Без слотов компоненты — это чёрные ящики. Хочешь кнопку внутри карточки — приходится передавать label пропом. Хочешь иконку — ещё один проп. А слоты позволяют передавать целые куски шаблона, сохраняя гибкость.

<!-- Без слотов — негибко 😢 -->
<Card title="Привет" content="Текст" icon="🎉" />
<!-- Со слотами — свобода! 🎉 -->
<Card>
<template #header>
<h2>Привет <span class="emoji">🎉</span></h2>
</template>
<p>Любой контент здесь!</p>
</Card>

Самый простой вариант. В компоненте ставишь <slot />, а родитель вставляет туда что хочет:

BaseCard.vue
<template>
<div class="card">
<slot />
<!-- Всё, что передаст родитель, появится здесь -->
</div>
</template>
<style scoped>
.card {
border: 1px solid #42b883;
border-radius: 8px;
padding: 1rem;
}
</style>
<!-- Использование -->
<template>
<BaseCard>
<h2>Заголовок</h2>
<p>Описание карточки</p>
<button>Кликни!</button>
</BaseCard>
</template>

Весь контент между тегами <BaseCard>...</BaseCard> попадёт в <slot />. Просто и элегантно! ✨


Что если родитель не передал никакого содержимого? Слот может иметь запасной контент — он отобразится, когда слот пуст:

Button.vue
<template>
<button class="btn">
<slot>
<!-- Это отобразится, если контент не передан -->
Нажми меня
</slot>
</button>
</template>
<!-- С контентом — отобразит "Отправить" -->
<Button>Отправить</Button>
<!-- Без контента — отобразит "Нажми меня" -->
<Button />

Удобно для кнопок, плейсхолдеров, состояний загрузки 👍


Когда нужно несколько точек вставки — используй именованные слоты. Классический пример — лейаут страницы:

AppLayout.vue
<template>
<div class="layout">
<header class="header">
<slot name="header">
<!-- Запасной контент для шапки -->
<h1>Мой сайт</h1>
</slot>
</header>
<main class="main">
<slot />
<!-- Дефолтный слот — для основного контента -->
</main>
<aside class="sidebar">
<slot name="sidebar" />
</aside>
<footer class="footer">
<slot name="footer">
<p>© 2024 Яша учит код</p>
</slot>
</footer>
</div>
</template>
<!-- Использование AppLayout.vue -->
<template>
<AppLayout>
<!-- Именованный слот через #имя -->
<template #header>
<nav>
<RouterLink to="/">Главная</RouterLink>
<RouterLink to="/about">О нас</RouterLink>
</nav>
</template>
<!-- Дефолтный слот — без template -->
<article>
<h2>Статья</h2>
<p>Контент страницы...</p>
</article>
<!-- Можно и через v-slot: -->
<template v-slot:sidebar>
<ul>
<li>Ссылка 1</li>
<li>Ссылка 2</li>
</ul>
</template>
<!-- footer не передаём — отобразится fallback -->
</AppLayout>
</template>

💡 #header — это сокращение от v-slot:header. Так же как @click это сокращение от v-on:click.


v-slot — официальная директива для работы со слотами. Полный синтаксис:

<template v-slot:header>...</template>
<!-- Сокращение -->
<template #header>...</template>
<!-- Дефолтный слот — эти записи эквивалентны -->
<template v-slot:default>...</template>
<template #default>...</template>
<!-- TypeScript с проверкой типов -->
<script setup lang="ts">
// Компонент явно объявляет, какие слоты он принимает
defineSlots<{
default(props: {}): any
header(props: { title: string }): any
footer(props: {}): any
}>()
</script>

Самая мощная фича! Компонент может передавать данные обратно в слот. Родитель получает данные дочернего компонента прямо в шаблоне.

Представь список: компонент управляет данными, но стиль каждого элемента задаёт родитель:

DataList.vue
<script setup lang="ts">
interface Item {
id: number
name: string
status: 'active' | 'inactive'
}
const props = defineProps<{
items: Item[]
}>()
</script>
<template>
<ul class="list">
<li v-for="item in items" :key="item.id">
<!-- Передаём item обратно в слот! -->
<slot :item="item" :index="item.id" />
</li>
</ul>
</template>
<!-- Родитель получает item через v-slot -->
<template>
<DataList :items="users">
<template #default="{ item, index }">
<!-- item — это данные из DataList! -->
<span :class="['badge', item.status]">
{{ index }}. {{ item.name }} — {{ item.status }}
</span>
</template>
</DataList>
</template>

Если слот только один (дефолтный), можно сократить:

<DataList :items="users" v-slot="{ item }">
<span>{{ item.name }}</span>
</DataList>

Паттерн renderless (без рендера) — компонент, который только предоставляет логику, а разметку полностью контролирует родитель. Идеальный пример — компонент для отслеживания мыши:

<!-- MouseTracker.vue — renderless component -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const x = ref(0)
const y = ref(0)
function handleMouseMove(e: MouseEvent) {
x.value = e.clientX
y.value = e.clientY
}
onMounted(() => {
window.addEventListener('mousemove', handleMouseMove)
})
onUnmounted(() => {
window.removeEventListener('mousemove', handleMouseMove)
})
</script>
<template>
<!-- Передаём координаты в слот -->
<slot :x="x" :y="y" />
</template>
<!-- Полный контроль над разметкой! -->
<template>
<MouseTracker v-slot="{ x, y }">
<div class="tracker">
Мышь: {{ x }}, {{ y }}
</div>
</MouseTracker>
<!-- Используем те же данные, но другую разметку -->
<MouseTracker v-slot="{ x, y }">
<canvas :data-x="x" :data-y="y" />
</MouseTracker>
</template>

🎯 Renderless components — предшественник composables. В Vue 3 чаще используют composables (useMouse()), но renderless всё ещё отлично подходит для случаев, где нужна гибкость шаблона.


Иногда нужно знать, передал ли родитель контент в слот. Используем $slots:

ConditionalSlot.vue
<script setup lang="ts">
import { useSlots } from 'vue'
const slots = useSlots()
</script>
<template>
<div class="card">
<!-- Показываем шапку только если есть контент -->
<header v-if="slots.header" class="card-header">
<slot name="header" />
</header>
<div class="card-body">
<slot />
</div>
<!-- Показываем footer только если он передан -->
<footer v-if="slots.footer" class="card-footer">
<slot name="footer" />
</footer>
</div>
</template>
<!-- В Options API через $slots -->
<template>
<div>
<div v-if="$slots.icon" class="icon-wrapper">
<slot name="icon" />
</div>
<slot />
</div>
</template>

Имя слота может быть динамическим — как и в случае с динамическими директивами:

<script setup lang="ts">
import { ref } from 'vue'
const currentSlot = ref<'header' | 'footer'>('header')
</script>
<template>
<Layout>
<!-- Динамическое имя слота -->
<template #[currentSlot]>
<p>Контент для {{ currentSlot }}</p>
</template>
</Layout>
<button @click="currentSlot = currentSlot === 'header' ? 'footer' : 'header'">
Переключить слот
</button>
</template>

В Vue 3.3+ появился defineSlots для строгой типизации:

<script setup lang="ts">
interface TableColumn<T> {
key: keyof T
label: string
}
const props = defineProps<{
columns: TableColumn<any>[]
rows: any[]
}>()
// Явная типизация слотов
const slots = defineSlots<{
// Слот для ячейки с данными
cell(props: { row: any; column: TableColumn<any>; value: any }): any
// Слот для шапки
header(props: { column: TableColumn<any> }): any
// Слот "нет данных"
empty(props: {}): any
}>()
</script>
<template>
<table>
<thead>
<tr>
<th v-for="col in columns" :key="String(col.key)">
<slot name="header" :column="col">
{{ col.label }}
</slot>
</th>
</tr>
</thead>
<tbody>
<tr v-if="rows.length === 0">
<td :colspan="columns.length">
<slot name="empty">
<p>Нет данных</p>
</slot>
</td>
</tr>
<tr v-for="(row, i) in rows" :key="i">
<td v-for="col in columns" :key="String(col.key)">
<slot name="cell" :row="row" :column="col" :value="row[col.key]">
{{ row[col.key] }}
</slot>
</td>
</tr>
</tbody>
</table>
</template>

<!-- Tabs.vue — родительский компонент -->
<script setup lang="ts">
import { ref, provide } from 'vue'
const activeTab = ref(0)
provide('activeTab', activeTab)
provide('setActiveTab', (i: number) => activeTab.value = i)
</script>
<template>
<div class="tabs">
<slot name="tabs" />
<slot name="panels" />
</div>
</template>

Когда компонент-обёртка должен прокинуть слоты внутрь:

Wrapper.vue
<template>
<BaseComponent>
<!-- Прокидываем все слоты с их scoped данными -->
<template v-for="(_, name) in $slots" #[name]="slotProps">
<slot :name="name" v-bind="slotProps || {}" />
</template>
</BaseComponent>
</template>

СинтаксисЧто делает
<slot />Дефолтный слот
<slot name="header" />Именованный слот
<slot :item="item" />Scoped слот (передаёт данные)
<slot>fallback</slot>Слот с запасным контентом
#headerСокращение для v-slot:header
v-slot="{ item }"Деструктуризация scoped данных
$slots.headerПроверка наличия слота
defineSlots<{...}>()Типизация слотов (Vue 3.3+)