← Курс/Слоты: гибкий контент компонентов#222 из 257+30 XP

Слоты: гибкий контент компонентов

Зачем нужны слоты

Props позволяют передавать данные. Но что если нужно передать **разметку или компоненты**? Например, у тебя есть компонент Card — и ты хочешь помещать в него разный контент без изменения самого Card.

Слоты решают эту задачу: они позволяют родителю «вставить» произвольный контент в определённые места дочернего компонента.

Default slot

Самый простой вид — один слот по умолчанию:

<!-- Card.vue -->
<template>
  <div class="card">
    <slot />  <!-- сюда попадёт контент от родителя -->
  </div>
</template>

<!-- Родитель -->
<Card>
  <p>Это контент внутри карточки</p>
  <button>Кнопка</button>
</Card>

Fallback content

Если слот не заполнен — отображается содержимое внутри <slot>:

<slot>
  <p>Нет контента — показываем заглушку</p>
</slot>

Named slots (именованные слоты)

Для нескольких зон вставки используют именованные слоты:

<!-- Layout.vue -->
<template>
  <div class="layout">
    <header>
      <slot name="header" />
    </header>
    <main>
      <slot />  <!-- default slot -->
    </main>
    <footer>
      <slot name="footer">
        © 2024 Default Footer  <!-- fallback -->
      </slot>
    </footer>
  </div>
</template>

<!-- Родитель -->
<Layout>
  <template #header>
    <h1>Заголовок страницы</h1>
  </template>

  <p>Основной контент</p>  <!-- попадёт в default slot -->

  <!-- footer не передан — используется fallback -->
</Layout>

Scoped slots (слоты с данными)

Scoped slots позволяют дочернему компоненту **передавать данные обратно** в слот родителя — это мощный паттерн инверсии контроля:

<!-- DataList.vue — знает данные, но не знает как рендерить -->
<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      <slot :item="item" :index="index" />
    </li>
  </ul>
</template>

<!-- Родитель — знает как рендерить, получает данные из слота -->
<DataList :items="users">
  <template #default="{ item, index }">
    <strong>{{ index + 1 }}. {{ item.name }}</strong>
    <span>{{ item.email }}</span>
  </template>
</DataList>

Практические паттерны

<!-- Headless компонент — только логика, рендеринг через scoped slot -->
<MouseTracker>
  <template #default="{ x, y }">
    Мышь: {{ x }}, {{ y }}
  </template>
</MouseTracker>

<!-- Renderless компонент (аналог) -->
<DataFetcher url="/api/users">
  <template #default="{ data, loading, error }">
    <Spinner v-if="loading" />
    <ErrorMessage v-else-if="error" :message="error" />
    <UserList v-else :users="data" />
  </template>
</DataFetcher>

Примеры

Паттерн render-функций и children как функции — аналог scoped slots в чистом JS

// Аналог именованных слотов через объект с функциями-рендерерами
function createLayout({ header, default: defaultSlot, footer } = {}) {
  const FALLBACK_FOOTER = '<footer>© 2024 Default Footer</footer>'
  const FALLBACK_CONTENT = '<p>Нет контента</p>'

  const headerHTML = header ? `<header>${header}</header>` : ''
  const contentHTML = `<main>${defaultSlot || FALLBACK_CONTENT}</main>`
  const footerHTML = `<footer>${footer || FALLBACK_FOOTER}</footer>`

  return `<div class="layout">${headerHTML}${contentHTML}${footerHTML}</div>`
}

console.log(createLayout({
  header: '<h1>Мой сайт</h1>',
  default: '<p>Добро пожаловать!</p>',
  footer: '<p>Контакты: hello@site.ru</p>',
}))

console.log(createLayout({
  header: '<h1>Страница</h1>',
  // footer не передан — будет fallback
}))

// Аналог scoped slots — children как функция
// Компонент получает данные, но ДЕЛЕГИРУЕТ рендеринг наружу
function createDataList(items, renderItem) {
  const itemsHTML = items
    .map((item, index) => `<li>${renderItem(item, index)}</li>`)
    .join('')
  return `<ul>${itemsHTML}</ul>`
}

const users = [
  { id: 1, name: 'Alice', role: 'admin' },
  { id: 2, name: 'Bob', role: 'user' },
  { id: 3, name: 'Carol', role: 'moderator' },
]

// renderItem — это аналог scoped slot
const html = createDataList(users, (user, index) =>
  `<strong>${index + 1}. ${user.name}</strong> [${user.role}]`
)
console.log(html)

// Headless компонент — только логика, рендеринг снаружи
function createCounter(initialValue, render) {
  let count = initialValue

  function update() {
    // "Перерендер" — вызываем render с текущими данными и методами
    return render({
      count,
      increment() { count++; return update() },
      decrement() { count--; return update() },
      reset() { count = initialValue; return update() },
    })
  }

  return update()
}

// Используем headless counter — сами решаем как рендерить
const result = createCounter(0, ({ count, increment, decrement }) => {
  console.log(`Счётчик: ${count}`)
  return { count, increment, decrement }
})

result.increment()  // Счётчик: 1
result.increment()  // Счётчик: 2
result.decrement()  // Счётчик: 1