Слоты вглубь: 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.
Проверьте себя
1. Как дочерний компонент передаёт данные в слот, чтобы родитель сам решил, как их отрисовать?
AЧерез emit события с данными
BПривязывает данные как атрибуты к тегу <slot>, а родитель принимает их через v-slot
CЧерез глобальную переменную
DНикак — слоты передают данные только сверху вниз
2. Что делает синтаксис #[col] в шаблоне родителя?
AСоздаёт слот, имя которого вычисляется из выражения col
BОбъявляет слот с буквальным именем «col»
CПередаёт массив col как slot prop
DЭто синтаксическая ошибка
3. Чем характерен renderless-компонент?
AОн рендерится быстрее обычных за счёт кеша
BОн не имеет собственной разметки и отдаёт только состояние и методы через слот
CОн работает без виртуального DOM
DОн не может иметь реактивного состояния