← Курс/Scoped Slots: слоты с данными#230 из 257+30 XP

Scoped Slots: слоты с данными

Проблема обычных слотов

Обычный слот позволяет родителю передать контент в компонент, но родитель не знает о внутреннем состоянии дочернего компонента. Scoped slots решают эту проблему — дочерний компонент передаёт данные обратно в слот.

Синтаксис в дочернем компоненте

Данные передаются слоту через атрибуты на <slot>:

<!-- DataList.vue — дочерний компонент -->
<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      <!-- Передаём item и index в слот -->
      <slot :item="item" :index="index" />
    </li>
  </ul>
</template>

Синтаксис в родительском компоненте

<!-- v-slot="slotProps" — получаем все переданные данные -->
<DataList :items="products" v-slot="{ item, index }">
  <span>{{ index + 1 }}. {{ item.name }} — {{ item.price }}₽</span>
</DataList>

Деструктуризация в v-slot — стандартная практика для удобства.

Именованные scoped slots

<!-- Table.vue -->
<template>
  <table>
    <thead>
      <tr>
        <slot name="header" :columns="columns" />
      </tr>
    </thead>
    <tbody>
      <tr v-for="row in data">
        <slot name="row" :row="row" :columns="columns" />
      </tr>
    </tbody>
    <tfoot>
      <slot name="footer" :total="total" />
    </tfoot>
  </table>
</template>
<!-- Использование -->
<Table :data="data" :columns="cols">
  <template #header="{ columns }">
    <th v-for="col in columns">{{ col.label }}</th>
  </template>

  <template #row="{ row, columns }">
    <td v-for="col in columns">{{ row[col.key] }}</td>
  </template>

  <template #footer="{ total }">
    <td colspan="3">Итого: {{ total }}</td>
  </template>
</Table>

Renderless Component — паттерн "безрендерный компонент"

Самый мощный паттерн со scoped slots: компонент управляет **логикой**, а родитель полностью контролирует **внешний вид**:

<!-- MouseTracker.vue — только логика, нет своей разметки -->
<template>
  <slot :x="position.x" :y="position.y" :isMoving="isMoving" />
</template>

<script setup>
const position = reactive({ x: 0, y: 0 })
const isMoving = ref(false)

onMounted(() => {
  window.addEventListener('mousemove', (e) => {
    position.x = e.clientX
    position.y = e.clientY
    isMoving.value = true
  })
})
</script>
<!-- Использование — внешний вид на усмотрение пользователя -->
<MouseTracker v-slot="{ x, y, isMoving }">
  <div :class="{ active: isMoving }">
    Курсор: {{ x }}, {{ y }}
  </div>
</MouseTracker>

Этот паттерн аналогичен **render props** в React.

Использование $slots в скрипте

// Проверить, передан ли слот
const hasFooter = computed(() => !!slots.footer)

Когда использовать scoped slots

  • Список/таблица с настраиваемым рендером каждой строки
  • Pagination, когда нужен кастомный рендер элементов
  • Renderless composable-компоненты (логика без UI)
  • Autocomplete — компонент управляет данными, UI — снаружи
  • Примеры

    Паттерн renderless-компонента и scoped slots на чистом JS — данные управляются внутри, отображение снаружи

    // Эмулируем паттерн Renderless Component + Scoped Slots.
    // Компонент предоставляет данные через "слот-функцию",
    // а "родитель" решает как их отображать.
    
    // --- Renderless DataFetcher ---
    // Аналог компонента, который управляет загрузкой данных
    function createDataFetcher(fetchFn) {
      // Внутреннее состояние компонента
      let state = {
        data: null,
        loading: false,
        error: null,
      }
    
      let slotFn = null  // "шаблон" — функция рендера из родителя
    
      const api = {
        // Аналог <slot :data="data" :loading="loading" :error="error" :refetch="refetch">
        // slotFn — это то, что родитель передаёт через v-slot
        setSlot(fn) {
          slotFn = fn
          return api
        },
    
        render() {
          if (slotFn) {
            // Передаём "scope" — внутренние данные компонента
            return slotFn({
              data: state.data,
              loading: state.loading,
              error: state.error,
              refetch: api.fetch,
            })
          }
        },
    
        async fetch() {
          state = { ...state, loading: true, error: null }
          api.render()
          try {
            state.data = await fetchFn()
            state.loading = false
            api.render()
          } catch(err) {
            state.loading = false
            state.error = err.message
            api.render()
          }
        }
      }
    
      return api
    }
    
    // --- Renderless List ---
    // Компонент управляет фильтрацией/сортировкой, UI — снаружи
    function createFilterableList(items) {
      let filter = ''
      let sortBy = null
      let slotFn = null
    
      const api = {
        setSlot(fn) { slotFn = fn; return api },
    
        setFilter(text) {
          filter = text
          api.render()
        },
    
        setSortBy(key) {
          sortBy = key
          api.render()
        },
    
        render() {
          if (!slotFn) return
    
          let result = items.filter(item =>
            !filter || Object.values(item).some(v =>
              String(v).toLowerCase().includes(filter.toLowerCase())
            )
          )
    
          if (sortBy) {
            result = [...result].sort((a, b) =>
              a[sortBy] < b[sortBy] ? -1 : a[sortBy] > b[sortBy] ? 1 : 0
            )
          }
    
          // Вызываем "слот" с данными
          return slotFn({
            items: result,
            total: items.length,
            filtered: result.length,
            setFilter: api.setFilter,
            setSortBy: api.setSortBy,
          })
        }
      }
    
      return api
    }
    
    // === Демо DataFetcher ===
    console.log('=== DataFetcher (Renderless) ===')
    
    const fetcher = createDataFetcher(async () => {
      await new Promise(r => setTimeout(r, 50))
      return [{ id: 1, name: 'Vue' }, { id: 2, name: 'React' }]
    })
    
    // Родитель определяет UI через "слот"
    fetcher.setSlot(({ data, loading, error }) => {
      if (loading) console.log('  [UI] Загрузка...')
      if (error)   console.log('  [UI] Ошибка:', error)
      if (data)    console.log('  [UI] Данные:', data.map(d => d.name).join(', '))
    })
    
    fetcher.fetch().then(() => {
      // === Демо FilterableList ===
      console.log('\n=== FilterableList (Renderless) ===')
    
      const products = [
        { id: 1, name: 'MacBook Pro', price: 200000, category: 'laptop' },
        { id: 2, name: 'iPhone 15',   price: 90000,  category: 'phone' },
        { id: 3, name: 'iPad Air',    price: 80000,  category: 'tablet' },
        { id: 4, name: 'AirPods',     price: 20000,  category: 'audio' },
        { id: 5, name: 'Apple Watch', price: 50000,  category: 'watch' },
      ]
    
      const list = createFilterableList(products)
    
      // UI полностью контролируется "родителем"
      list.setSlot(({ items, total, filtered }) => {
        console.log(`  Показано ${filtered}/${total}:`)
        items.forEach(p => console.log(`  - ${p.name}: ${p.price}₽`))
      })
    
      console.log('Все продукты:')
      list.render()
    
      console.log('\nФильтр "air":')
      list.setFilter('air')
    
      console.log('\nСортировка по цене:')
      list.setFilter('')
      list.setSortBy('price')
    })