Слоты вглубь: scoped и динамические
Урок поднимает слоты на следующий уровень: ребёнок отдаёт данные наружу, имена слотов вычисляются на лету, а целый компонент превращается в чистый поставщик логики без своей разметки.
Scoped-слот — слот, через который дочерний компонент передаёт данные обратно родителю, чтобы тот сам решил, как их отрисовать.
Обычный слот — это дырка в шаблоне ребёнка, куда родитель вставляет свою разметку. Но направление данных при этом одностороннее: родитель кладёт готовый HTML, ребёнок просто показывает его на месте. Часто этого мало. Представьте компонент-список: данными о каждом элементе владеет ребёнок (он их загрузил, отфильтровал, пронумеровал), а вот как выглядит строка — решать родителю. Нужен канал в обратную сторону: ребёнок должен отдать данные наверх, прямо в слот. Это и есть scoped-слоты.
В этом уроке три большие темы: scoped-слоты (слот с данными), динамические имена слотов (когда имя слота вычисляется выражением) и renderless-компоненты — крайний случай, когда компонент не рисует вообще ничего, а только снабжает родителя данными через слот. Все три приёма — про разделение ответственности: логика отдельно, представление отдельно.
Зачем это на практике
Любой переиспользуемый компонент с данными рано или поздно упирается в вопрос вёрстки. Таблица, выпадающий список, карусель, бесконечная прокрутка — везде логика общая, а внешний вид у каждого экрана свой. Если зашить разметку внутрь компонента, его придётся форкать под каждый случай. Scoped-слоты решают это: компонент владеет данными и поведением, а отрисовку делегирует наружу. Так устроены слоты в большинстве UI-библиотек: <Table> даёт вам строку, а вы рисуете ячейки как хотите.
Scoped-слот: передаём данные в слот
Чтобы отдать данные в слот, ребёнок привязывает их к тегу <slot> как атрибуты (их называют slot props). Родитель принимает их через v-slot.
Дочерний компонент UserList.vue владеет массивом и в цикле отдаёт каждый элемент в слот:
<script setup>
const users = [
{ id: 1, name: 'Аня', online: true },
{ id: 2, name: 'Борис', online: false }
]
</script>
<template>
<ul>
<li v-for="user in users" :key="user.id">
<!-- отдаём user и его индекс в слот -->
<slot :user="user" :online="user.online">
{{ user.name }}
</slot>
</li>
</ul>
</template>
Родитель получает эти slot props в объект и сам строит разметку строки:
<template>
<UserList v-slot="{ user, online }">
<strong>{{ user.name }}</strong>
<span>{{ online ? '🟢 в сети' : '⚪ оффлайн' }}</span>
</UserList>
</template>
Обратите внимание: v-slot="{ user, online }" — это деструктуризация объекта slot props. Текст между тегами <slot> внутри ребёнка ({{ user.name }}) — это содержимое по умолчанию: оно покажется, только если родитель не передал свой шаблон слота.
Именованные scoped-слоты
Слоты можно совмещать: дать имя и при этом передавать данные. На именованном слоте v-slot пишут с двоеточием — v-slot:имя или сокращённо #имя. Пусть таблица отдаёт отдельно слот для заголовка и отдельно — для строки:
<!-- DataTable.vue -->
<template>
<table>
<thead><slot name="head" /></thead>
<tbody>
<tr v-for="row in rows" :key="row.id">
<slot name="row" :row="row" />
</tr>
</tbody>
</table>
</template>
<DataTable :rows="rows">
<template #head>
<th>Имя</th><th>Город</th>
</template>
<template #row="{ row }">
<td>{{ row.name }}</td><td>{{ row.city }}</td>
</template>
</DataTable>
Когда у компонента несколько слотов, каждый шаблон родителя оборачивают в <template #имя>. Безымянный слот доступен под именем default.
Динамические имена слотов
Имя слота можно вычислять — это полезно, когда набор слотов заранее не известен (например, по списку колонок). Имя в квадратных скобках: #[выражение] или v-slot:[выражение].
<script setup>
const columns = ['name', 'email', 'role']
</script>
<template>
<DataGrid>
<!-- генерируем слот под каждую колонку -->
<template v-for="col in columns" #[col]="{ value }" :key="col">
<em>{{ value }}</em>
</template>
</DataGrid>
</template>
Здесь #[col] на каждой итерации подставит имя из columns: получатся слоты name, email, role. Без динамических имён пришлось бы вручную выписывать каждый <template #name>, <template #email> и так далее.
Renderless-компонент
Доведём идею до предела. Что, если компонент не рисует ничего своего, а только держит состояние и отдаёт его в слот по умолчанию? Такой компонент называют renderless («без разметки»): вся вёрстка — на стороне родителя, вся логика — внутри. Классический пример — переключатель (toggle):
<!-- Toggle.vue — никакой собственной разметки -->
<script setup>
import { ref } from 'vue'
const on = ref(false)
function toggle() { on.value = !on.value }
</script>
<template>
<!-- отдаём наружу состояние и метод -->
<slot :on="on" :toggle="toggle" />
</template>
<Toggle v-slot="{ on, toggle }">
<button @click="toggle">
{{ on ? 'Выключить' : 'Включить' }}
</button>
</Toggle>
Компонент Toggle вообще не знает про кнопку — он мог бы отдавать состояние чекбоксу, переключателю темы, аккордеону. Логика написана один раз, представлений сколько угодно. В современном Vue 3 ту же задачу часто решают composable-функцией (useToggle()), и это обычно проще. Но renderless-паттерн остаётся уместным, когда логика тесно завязана на разметку слота или когда нужно раздавать данные из шаблона в шаблон.
Как это работает под капотом
Слоты в Vue — это не куски HTML, а функции. Когда вы пишете содержимое слота, компилятор превращает его в функцию, которая принимает объект slot props и возвращает виртуальные узлы (VNodes). Внутри ребёнка <slot :user="user" /> компилируется в вызов этой функции с аргументом { user }. Поэтому scoped-слот «реактивен»: при изменении user функция вызывается заново и перерисовывает только содержимое слота.
Все слоты, переданные в компонент, доступны изнутри как объект $slots (а в <script setup> — через useSlots()): ключи — имена слотов, значения — функции-рендереры. Динамическое имя #[col] просто кладёт функцию слота под вычисленным ключом в этот объект. Понимание «слот = функция, возвращающая VNode» снимает почти всю магию: становится ясно, почему данные текут от ребёнка к слоту и почему контент слота имеет доступ к области видимости родителя, а не ребёнка.
Частые ошибки
Путать область видимости. Содержимое слота пишется в шаблоне родителя и видит данные родителя. Получить данные ребёнка можно только через slot props (то, что ребёнок повесил на <slot>). Попытка обратиться напрямую к переменной ребёнка изнутри слота — частая ошибка новичка.
Смешивать v-slot на компоненте и на <template>. Сокращённую запись v-slot прямо на теге компонента можно использовать, только когда слот один (default). Как только слотов несколько — все, включая default, оборачивайте в отдельные <template #имя>.
Забыть содержимое слота по умолчанию. Если слот может остаться непереданным, дайте ему запасной контент между тегами <slot>...</slot> — иначе на этом месте будет пусто.
Ставить :key не туда при динамических слотах. В v-for по слотам :key вешается на тот же <template>, что и #[col], иначе Vue ругнётся на отсутствие ключа.
Итоги
- Scoped-слот — канал данных от ребёнка к родителю: ребёнок вешает slot props на
<slot>, родитель принимает их вv-slot="{ ... }". - Именованные слоты задаются
nameу<slot>и принимаются через#имя; безымянный слот зовётсяdefault. - Динамическое имя слота
#[выражение]позволяет генерировать слоты по данным, например по списку колонок. - Renderless-компонент не рисует своей разметки — только отдаёт состояние и методы в слот; вёрстка целиком на родителе.
- Под капотом слот — функция, принимающая slot props и возвращающая VNode; поэтому содержимое слота видит область родителя, а данные ребёнка приходят только через slot props.