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

14. Slots и Snippets (Svelte 5)

Slots — это «дырки» в компоненте, куда родитель вставляет свой контент. Представь компонент как рамку для картины: рамка одна, а картина — разная 🖼️ В Svelte 5 slots заменяются snippets, но концепция та же.


Без slots:
<Button label="Нажми меня" icon="star" />
// Компонент должен знать о каждом варианте контента
С slots:
<Button>
⭐ Нажми меня
</Button>
// Родитель решает, что внутри!

Slots — это основа компонентной компоновки (composition pattern).


Card.svelte
<div class="card">
<slot /> <!-- Сюда вставится всё, что между тегами -->
</div>
<!-- Использование -->
<Card>
<h2>Заголовок</h2>
<p>Любой контент!</p>
<button>Действие</button>
</Card>

Если слот не передан — показывается содержимое по умолчанию:

Button.svelte
<button class="btn">
<slot>
<!-- Fallback: показывается если слот не передан -->
Нажать
</slot>
</button>
<!-- Кнопка с кастомным текстом -->
<Button>Сохранить ✅</Button>
<!-- Кнопка с fallback текстом "Нажать" -->
<Button />

Layout.svelte
<div class="layout">
<header>
<slot name="header">
<!-- Fallback заголовок -->
<h1>Мой сайт</h1>
</slot>
</header>
<main>
<slot /> <!-- Безымянный слот — основной контент -->
</main>
<aside>
<slot name="sidebar" />
</aside>
<footer>
<slot name="footer">
<p>© 2024</p>
</slot>
</footer>
</div>
<!-- Использование -->
<Layout>
<!-- Именованный слот через slot="..." -->
<svelte:fragment slot="header">
<h1>Моя страница</h1>
<nav>...</nav>
</svelte:fragment>
<!-- Безымянный слот — просто контент без атрибута -->
<article>
<p>Основной контент страницы</p>
</article>
<div slot="sidebar">
<p>Виджеты боковой панели</p>
</div>
<!-- footer не передан — используется fallback -->
</Layout>

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

<!-- Без svelte:fragment — лишний div -->
<Layout>
<div slot="header"> <!-- div попадает в DOM! -->
<h1>Заголовок</h1>
<p>Подзаголовок</p>
</div>
</Layout>
<!-- С svelte:fragment — без лишнего элемента -->
<Layout>
<svelte:fragment slot="header">
<h1>Заголовок</h1>
<p>Подзаголовок</p>
</svelte:fragment>
</Layout>

Slot props — это способ передать данные из компонента обратно в слот. Scoped slots в Vue, render props в React:

<!-- List.svelte — компонент списка с данными -->
<script>
export let items: string[]
</script>
<ul>
{#each items as item, index}
<!-- Передаём item и index как slot props -->
<slot {item} {index} isFirst={index === 0} isLast={index === items.length - 1} />
{/each}
</ul>
<!-- Использование — получаем slot props через let: -->
<List items={['Яблоко', 'Банан', 'Манго']} let:item let:index let:isFirst let:isLast>
<li class:first={isFirst} class:last={isLast}>
{index + 1}. {item}
{#if isFirst}🥇{/if}
{#if isLast}🏁{/if}
</li>
</List>
DataTable.svelte
<script lang="ts">
export let rows: Record<string, unknown>[]
export let columns: { key: string; label: string }[]
let sortKey = ''
let sortDir = 1
$: sorted = sortKey
? [...rows].sort((a, b) => {
const av = a[sortKey]
const bv = b[sortKey]
if (typeof av === 'string' && typeof bv === 'string') {
return av.localeCompare(bv) * sortDir
}
return ((av as number) - (bv as number)) * sortDir
})
: rows
function sort(key: string) {
if (sortKey === key) sortDir *= -1
else { sortKey = key; sortDir = 1 }
}
</script>
<table>
<thead>
<tr>
{#each columns as col}
<th on:click={() => sort(col.key)}>
{col.label}
{#if sortKey === col.key}{sortDir > 0 ? '↑' : '↓'}{/if}
</th>
{/each}
</tr>
</thead>
<tbody>
{#each sorted as row, i}
<!-- Передаём row, index и isEven в слот -->
<slot {row} rowIndex={i} isEven={i % 2 === 0} />
{/each}
</tbody>
</table>
<!-- Кастомный рендер строк! -->
<DataTable {rows} {columns} let:row let:rowIndex let:isEven>
<tr class:even={isEven}>
<td>{rowIndex + 1}</td>
<td style="font-weight: bold">{row.name}</td>
<td>
<span class="badge">{row.role}</span>
</td>
<td>
<button on:click={() => editRow(row)}>✏️</button>
<button on:click={() => deleteRow(row)}>🗑️</button>
</td>
</tr>
</DataTable>

Card.svelte
<script>
// $$slots — объект с именами переданных слотов
// { default: true, header: true } ← пример
</script>
<div class="card">
{#if $$slots.header}
<div class="card-header">
<slot name="header" />
</div>
{/if}
<div class="card-body">
<slot />
</div>
{#if $$slots.footer}
<div class="card-footer">
<slot name="footer" />
</div>
{/if}
</div>
Полезно для:
✅ Условное отображение обёртки слота
✅ Разная вёрстка в зависимости от наличия слота
✅ Опциональные секции компонента

Svelte 5: {#snippet} — Переиспользуемые фрагменты 🆕

Заголовок раздела «Svelte 5: {#snippet} — Переиспользуемые фрагменты 🆕»

Snippets — замена slots в Svelte 5. Они мощнее и гибче:

<!-- Svelte 5 -->
<script lang="ts">
let items = ['Яша', 'Петя', 'Маша']
</script>
<!-- Объявление snippet — как inline компонент -->
{#snippet userItem(name: string, index: number)}
<div class="user">
<span class="badge">{index + 1}</span>
<span class="name">{name}</span>
<span class="avatar">{name[0]}</span>
</div>
{/snippet}
<!-- Использование с @render -->
<div class="user-list">
{#each items as item, i}
{@render userItem(item, i)}
{/each}
</div>

<!-- Card.svelte — Svelte 5 -->
<script lang="ts">
import type { Snippet } from 'svelte'
let {
title,
children, // Обязательный children snippet
actions, // Опциональный snippet для кнопок
variant = 'default',
} = $props<{
title: string
children: Snippet
actions?: Snippet
variant?: 'default' | 'danger' | 'success'
}>()
</script>
<div class="card card--{variant}">
<h3 class="card-title">{title}</h3>
<div class="card-body">
{@render children()}
</div>
{#if actions}
<div class="card-actions">
{@render actions()}
</div>
{/if}
</div>
<!-- Использование Card.svelte — Svelte 5 -->
<Card title="Мой профиль" variant="success">
<p>Имя: Яша</p>
<p>Возраст: 10</p>
{#snippet actions()}
<button class="btn-secondary">Отмена</button>
<button class="btn-primary">Сохранить</button>
{/snippet}
</Card>

Table.svelte
<script lang="ts">
import type { Snippet } from 'svelte'
interface Row {
id: number
[key: string]: unknown
}
let {
rows,
columns,
rowRenderer,
emptyState,
} = $props<{
rows: Row[]
columns: string[]
rowRenderer: Snippet<[Row, number]> // Тип: Snippet<[аргументы]>
emptyState?: Snippet
}>()
</script>
<table>
<thead>
<tr>{#each columns as col}<th>{col}</th>{/each}</tr>
</thead>
<tbody>
{#if rows.length === 0}
{#if emptyState}
<tr><td colspan={columns.length}>{@render emptyState()}</td></tr>
{/if}
{:else}
{#each rows as row, i}
{@render rowRenderer(row, i)}
{/each}
{/if}
</tbody>
</table>
<!-- Использование -->
<Table {rows} columns={['ID', 'Имя', 'Email']}>
{#snippet rowRenderer(row, i)}
<tr class:even={i % 2 === 0}>
<td>{row.id}</td>
<td style="font-weight: bold">{String(row.name)}</td>
<td style="color: #64748b">{String(row.email)}</td>
</tr>
{/snippet}
{#snippet emptyState()}
<div style="text-align: center; padding: 2rem; color: #64748b">
📭 Нет данных
</div>
{/snippet}
</Table>

┌────────────────────────────┬────────────────────────────────┐
│ Svelte 4 (Slots) │ Svelte 5 (Snippets) │
├────────────────────────────┼────────────────────────────────┤
│ <slot /> │ {@render children()} │
│ <slot name="header" /> │ {@render header()} │
│ export let header: ... │ let { header } = $props() │
│ slot:item={value} │ Snippet<[paramType]> │
│ let:item (в родителе) │ {#snippet name(param)} ... {/} │
│ $$slots.header │ if (header) {@render header()} │
│ <svelte:fragment slot="x"> │ {#snippet x()} ... {/snippet} │
└────────────────────────────┴────────────────────────────────┘

Accordion.svelte
<script lang="ts">
import type { Snippet } from 'svelte'
import { setContext } from 'svelte'
let { children } = $props<{ children: Snippet }>()
let openItem = $state<string | null>(null)
setContext('accordion', {
get open() { return openItem },
toggle: (id: string) => {
openItem = openItem === id ? null : id
}
})
</script>
<div class="accordion">{@render children()}</div>
AccordionItem.svelte
<script lang="ts">
import type { Snippet } from 'svelte'
import { getContext } from 'svelte'
let { id, title, children } = $props<{
id: string
title: string
children: Snippet
}>()
const accordion = getContext<{ open: string | null; toggle: (id: string) => void }>('accordion')
const isOpen = $derived(accordion.open === id)
</script>
<div class="accordion-item">
<button class="accordion-trigger" on:click={() => accordion.toggle(id)}>
{title}
<span>{isOpen ? '▲' : '▼'}</span>
</button>
{#if isOpen}
<div class="accordion-content" transition:slide>
{@render children()}
</div>
{/if}
</div>