Svelte 5: шпаргалка — руны, $state, $derived, $effect, $props, сниппеты, анимации, SvelteKit
Svelte 5 — компилируемый фреймворк с рунами (runes) вместо виртуального DOM. Руны делают реактивность явной и переносимой в .svelte.ts модули. Шпаргалка построена на реальных паттернах из боевых проектов.
Руны: таблица за 15 секунд
| Руна | Назначение | Заменяет в Svelte 4 | Аналог в React |
|---|---|---|---|
$state(x) |
Реактивная переменная | let x = 0 |
useState(0) |
$state.raw(x) |
Неглубокий $state (без Proxy) | — | useRef (но reactive) |
$derived(expr) |
Производное значение | $: x = expr |
useMemo |
$derived.by(() => {...}) |
Сложное производное | $: { ... } |
useMemo(() => {...}) |
$effect(() => {...}) |
Побочный эффект | $: { ... } |
useEffect |
$effect.pre(() => {...}) |
Эффект до обновления DOM | beforeUpdate |
useLayoutEffect |
$props() |
Пропсы компонента | export let x |
props.x / destruct |
$bindable() |
Двусторонний проп | bind:value |
value + onChange |
$inspect(x) |
Отладка реактивности | console.log |
— |
$host() |
Контекст custom element | — | — |
$state — реактивная переменная
<script>
let count = $state(0) // примитив
let user = $state({ name: 'Анна' }) // объект — глубокий Proxy
let items = $state([1, 2, 3]) // массив — глубокий Proxy
count++ // прямое изменение — реактивно
user.name = 'Иван' // глубокое изменение — реактивно
items.push(4) // push, splice, pop — реактивны
// Паттерн: замена ссылки вместо мутации (частый в проектах)
cells = { ...cells, [key]: value } // новый объект → триггер реактивности
// Паттерн: сложные контейнеры
let expanded = $state<Set<string>>(new Set(["root"])) // Set
let progress = $state<Map<string, CardProgress>>(new Map()) // Map
// Паттерн: localStorage-состояние
let saved = $state(loadFromStorage<State>('key', defaultState))
function persist() { saveToStorage('key', saved) }
// $state.raw — без глубокого Proxy (быстрее для больших объектов)
let bigData = $state.raw({ /* 10000 записей */ })
bigData.items[0].name = 'new' // ❌ НЕ триггерит!
bigData = { ...bigData, items: [...] } // ✅ замена ссылки триггерит
</script>
Где работает: .svelte, .svelte.js, .svelte.ts. В обычных .js/.ts — ошибка компиляции.
$state в классах (нужны аксессоры, классы не проксируются):
// counter.svelte.ts
class Counter {
count = $state(0)
increment() { this.count++ }
}
$derived и $derived.by — производные значения
<script>
let width = $state(100)
let height = $state(200)
// Простое выражение
let area = $derived(width * height)
// Цепочка производных (реальный паттерн из проектов)
let rgb = $derived(hexToRgb(color))
let hsl = $derived(rgbToHsl(rgb.r, rgb.g, rgb.b))
let contrast = $derived(contrastRatio(color, '#ffffff'))
// Паттерн: $derived возвращает функцию (идиоматический приём!)
let isSelected = $derived((qId: number, optIdx: number) =>
saved.answers[String(qId)] === String(optIdx)
)
</script>
$derived.by — многострочная логика
<script>
// Ранний возврат + защита от NaN/infinity
let paybackMonths = $derived.by(() => {
if (monthlyIncome <= 0) return null
const m = investment / monthlyIncome
if (!isFinite(m)) return null
return m
})
// Поиск с фильтрацией
let filtered = $derived.by(() => {
if (!query) return items
return items.filter(i => i.name.toLowerCase().includes(query.toLowerCase()))
})
// Цикл с поиском уровня (реальный паттерн из игр)
let levelInfo = $derived.by(() => {
const levels = theme.levels
for (let i = levels.length - 1; i >= 0; i--) {
if (unlockedCount >= levels[i].minUpgrades) return levels[i]
}
return levels[0]
})
// Сортировка массива
let sorted = $derived.by(() =>
[...data].sort((a, b) => {
const cmp = a[sortField].localeCompare(b[sortField])
return sortDir === 'asc' ? cmp : -cmp
})
)
</script>
Правила:
- Ленивое вычисление — только при изменении зависимостей
- Не может быть асинхронным (не
awaitвнутри) - Не мутировать состояние!
$derived.by— только чтение и возврат значения - Ранние
returnв$derived.byразрешены и поощряются
$effect — побочные эффекты: полное руководство
Когда срабатывает $effect
Эффекты выполняются в микротаске после обновления DOM, батчатся, и отслеживают зависимости автоматически. Порядок жизненного цикла:
Монтирование: setup → DOM-рендер → $effect (первый запуск)
Обновление: state change → DOM-рендер → teardown старого эффекта → $effect
Размонтирование: teardown → удаление DOM
Паттерн 1: DOM-класс и тема
<script>
let isDark = $state(false)
$effect(() => {
document.documentElement.classList.toggle('dark', isDark)
})
</script>
Паттерн 2: Слушатели событий с очисткой
<script>
$effect(() => {
function handler(e: Event) { e.preventDefault(); installable = true }
window.addEventListener('beforeinstallprompt', handler)
return () => window.removeEventListener('beforeinstallprompt', handler)
})
</script>
Паттерн 3: Интервалы и таймеры (игровые циклы)
<script>
$effect(() => {
init() // запускает tickTimer (100ms), saveTimer (10s), elapsedTimer (1s)
return () => {
clearInterval(tickTimer)
clearInterval(saveTimer)
clearInterval(elapsedTimer)
}
})
</script>
Паттерн 4: Условный эффект с early return
<script>
let dropdownOpen = $state(false)
let dropdownRef = $state<HTMLElement | null>(null)
$effect(() => {
if (!dropdownOpen) return // early return = без подписок
function handler(e: MouseEvent) {
if (dropdownRef && !dropdownRef.contains(e.target as Node))
dropdownOpen = false
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') dropdownOpen = false
}
document.addEventListener('click', handler, true)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('click', handler, true)
document.removeEventListener('keydown', onKey)
}
})
</script>
Паттерн 5: Race conditions — поиск с отменой
<script>
let query = $state('')
let results = $state<Item[]>([])
let loading = $state(false)
$effect(() => {
const controller = new AbortController()
const currentQuery = query
if (!currentQuery) {
results = []
return
}
loading = true
fetch(`/api/search?q=${currentQuery}`, { signal: controller.signal })
.then(r => r.json())
.then(data => {
results = data // пришёл ответ на актуальный запрос
loading = false
})
.catch(e => {
if (e.name !== 'AbortError') {
console.error(e)
loading = false
}
})
return () => controller.abort() // отмена предыдущего запроса
})
</script>
Паттерн 6: Media query — фабрика реактивных хуков
<script>
function useMediaQuery(query: string) {
let matches = $state(false)
$effect(() => {
const mq = window.matchMedia(query)
matches = mq.matches
function handler(e: MediaQueryListEvent) { matches = e.matches }
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
})
return () => matches // возвращает геттер
}
const getIsMobile = useMediaQuery('(max-width: 1200px)')
let isMobile = $state(false)
$effect(() => { isMobile = getIsMobile() })
{#if isMobile}
<MobileNav />
{/if}
</script>
Паттерн 7: Async init (fire-and-forget)
<script>
let status = $state<'loading' | 'menu' | 'playing'>('loading')
$effect(() => {
initDB()
})
async function initDB() {
try {
await seedBuiltinDecks()
status = 'menu'
} catch {
status = 'menu'
}
}
</script>
Паттерн 8: Синхронизация пропса с локальным состоянием
<script>
let { value } = $props()
let internalValue = $state(value) // svelte-ignore state_referenced_locally
$effect(() => { internalValue = value }) // зеркалируем изменения пропса
</script>
$effect.pre — до обновления DOM
<script>
let items = $state([])
let container = $state<HTMLElement | null>(null)
let prevScrollHeight = 0
$effect.pre(() => {
if (container) prevScrollHeight = container.scrollHeight
})
$effect(() => {
if (container) {
container.scrollTop = container.scrollHeight - prevScrollHeight
}
})
</script>
$effect.root — ручное управление скоупом
import { $effect } from 'svelte/reactivity'
const cleanup = $effect.root(() => {
$effect(() => { /* эффект 1 */ })
$effect(() => { /* эффект 2 */ })
// оба эффекта живут в общем скоупе
})
// Позже — вручную уничтожить все эффекты
cleanup()
$effect.tracking() — проверка контекста
import { $effect } from 'svelte/reactivity'
function maybeReactiveRead() {
if ($effect.tracking()) {
// Код читается внутри $effect или $derived — подписка активна
return expensiveQuery()
} else {
// Код вызван из обработчика клика или onMount — подписка не нужна
return cachedValue
}
}
untrack() — чтение без подписки
<script>
import { untrack } from 'svelte'
let count = $state(0)
let log = $state<string[]>([])
$effect(() => {
// count изменился → хотим записать в log, но БЕЗ подписки на log!
untrack(() => {
log = [...log, `count стал ${count}`]
})
})
</script>
$props — пропсы компонента
<!-- Стиль A: typed interface + destructure (основной в проектах) -->
<script lang="ts">
interface Props {
page: PageData
menu?: MenuItem[]
t?: (key: string, params?: Record<string, unknown>) => string
onClose?: () => void
children?: Snippet
footer?: Snippet
}
let { page, menu = [], t = (k: string) => k, onClose, children, footer }: Props = $props()
</script>
<!-- Стиль B: inline generic (для простых компонентов) -->
<script lang="ts">
let { count = $bindable(0), label = 'Count' } = $props<{
count?: number
label?: string
}>()
</script>
<!-- Стиль C: js (без TS) -->
<script>
let { initialCount = 0, label = 'Count', t = (k) => k } = $props()
</script>
Важно: деструктуризация пропсов в Svelte 5 не ломает реактивность (в отличие от Vue). Пропсы с коллбэками (onClose, onApply) — не $bindable, используются как обычные пропсы-функции.
Сниппеты: замена слотов
Сниппеты — это переиспользуемые блоки разметки внутри компонента, которые рендерятся через {@render}. Они заменили <slot> из Svelte 4: <slot> больше не существует в Svelte 5.
Базовое использование
{#snippet card(title, description)}
<article class="card">
<h2>{title}</h2>
<p>{description}</p>
</article>
{/snippet}
{@render card('Заголовок', 'Описание')}
Параметры: типы, дефолты, деструктуризация
Сниппеты — это функции разметки. Их параметры поддерживают:
- типы (TypeScript)
- значения по умолчанию
- деструктуризацию (включая вложенную)
<!-- ✅ Типы + дефолты + деструктуризация -->
{#snippet card(
{ title, description = 'нет описания' }: { title: string; description?: string },
variant: 'primary' | 'ghost' = 'primary'
)}
<article class="card card--{variant}">
<h2>{title}</h2>
<p>{description}</p>
</article>
{/snippet}
{@render card({ title: 'Hello' })}
{@render card({ title: 'Hi', description: 'опционально' }, 'ghost')}
Ограничения:
- ❌ rest-параметры (
...args) НЕ поддерживаются — сниппет не может принять произвольное число аргументов. - Параметры сниппета не реактивны сами по себе; если нужно производное значение — используйте
$derivedвнутри тела сниппета (см. ниже про@const).
Область видимости и рекурсия
Сниппеты видны в своей лексической области (siblings и их дети). Сниппет может ссылаться на себя и на другие сниппеты того же scope — это даёт рекурсию «бесплатно»:
{#snippet blastoff()}<span>🚀</span>{/snippet}
{#snippet countdown(n: number)}
{#if n > 0}
<span>{n}...</span>
{@render countdown(n - 1)}
{:else}
{@render blastoff()}
{/if}
{/snippet}
{@render countdown(3)}
<!-- 3... 2... 1... 🚀 -->
Продвинутый: рекурсивное дерево (реальный паттерн из проекта)
{#snippet treeNode(key: string, val: any, path: string, depth: number)}
{@const isContainer = typeof val === 'object' && val !== null}
{@const isExpanded = expanded.has(path)}
<div class="node" style="padding-left: {depth * 16}px">
<button onclick={() => toggle(path)}>
{isExpanded ? '▼' : '▶'} {key}
</button>
{#if isContainer && isExpanded}
{#each Object.entries(val) as [k, v]}
{@render treeNode(k, v, `${path}.${k}`, depth + 1)}
{/each}
{/if}
</div>
{/snippet}
{@render treeNode('root', data, '$', 0)}
Сниппеты как пропсы — явный контракт
Сниппеты — это значения первого класса, их можно передавать как пропсы. Это и есть «явный контракт»: типы проверяются на этапе компиляции, а ?.() на этапе рендера.
<!-- Child.svelte — типизированный контракт -->
<script lang="ts">
import type { Snippet } from 'svelte';
let { children, footer }: { children: Snippet; footer?: Snippet } = $props();
</script>
<div class="modal-body">{@render children()}</div>
{#if footer}
<div class="modal-footer">{@render footer()}</div>
{/if}
Snippet<Args> — generic, где Args — tuple типов параметров (сниппет может принимать несколько):
| Тип | Сигнатура | Когда использовать |
|---|---|---|
Snippet |
() => void |
Без параметров (children, header()) |
Snippet<[T]> |
(arg: T) => void |
Один параметр |
Snippet<[T, U]> |
(a: T, b: U) => void |
Два параметра |
Snippet<[T] | []> |
(arg?: T) => void |
Необязательный параметр |
Generics: один тип для data и row
Чтобы row принимал именно тот тип, что лежит в data, используйте <script lang="ts" generics="T">:
<!-- Table.svelte -->
<script lang="ts" generics="T">
import type { Snippet } from 'svelte';
let {
data,
row
}: {
data: T[];
row: Snippet<[T]>;
} = $props();
</script>
<table>
{#each data as item}
{@render row(item)} <!-- T подставлен автоматически -->
{/each}
</table>
Два способа передать сниппет в компонент
Explicit (через пропс): — сниппет объявлен на стороне родителя и явно передан.
{#snippet header()}<th>fruit</th>{/snippet}
<!-- [archlet] Failed to render <Table/> -->
Implicit (как children): — сниппет объявлен внутри тега компонента и автоматически становится его пропсом.
<!-- [archlet] Failed to render <Table>...</Table> -->
Implicit children
Любой контент вне {#snippet} внутри <Component>...</Component> автоматически попадает в children:
<div id="modal-ztn78vq" class="archlet-modal" role="dialog" aria-modal="true" aria-labelledby="modal-ztn78vq-title" data-open="false">
<div class="archlet-modal-backdrop"></div>
<div class="archlet-modal-content">
<div class="archlet-modal-header">
<button class="archlet-modal-close" aria-label="Close">✕</button>
</div>
<div class="archlet-modal-body">
<p>Это станет children автоматически</p>
</div>
</div>
</div><script>
(function(){
const modal=document.getElementById('modal-ztn78vq');
if(!modal)return;
function close(){
modal.setAttribute('data-open','false');
modal.dispatchEvent(new CustomEvent('modal-close'));
document.body.style.overflow='';
}
function show(){
modal.setAttribute('data-open','true');
document.body.style.overflow='hidden';
const focusable=modal.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
if(focusable)focusable.focus();
}
modal.querySelector('.archlet-modal-backdrop')?.addEventListener('click',function(e){
if(e.target===this)close();
});
modal.querySelector('.archlet-modal-close')?.addEventListener('click',close);
modal.addEventListener('keydown',function(e){
if(e.key==='Escape'){close();e.stopPropagation();}
if(e.key==='Tab'){
const focusable=modal.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
const first=focusable[0],last=focusable[focusable.length-1];
if(e.shiftKey&&document.activeElement===first){last.focus();e.preventDefault();}
if(!e.shiftKey&&document.activeElement===last){first.focus();e.preventDefault();}
}
});
window['__archlet_modal_modal-ztn78vq_show']=show;
window['__archlet_modal_modal-ztn78vq_close']=close;
})();
</script>
⚠️ Ловушка: если внутри компонента вы передаёте именованные сниппеты и обычный контент одновременно — обычный контент перезапишет children. Всегда либо оборачивайте в {#snippet children()}...{/snippet}, либо передавайте только через именованные сниппеты.
Опциональные сниппеты и fallback
Сниппет, объявленный как ?: в $props(), может быть undefined. Два способа обработать:
<!-- Способ 1: optional chaining — ничего не рендерим -->
{@render footer?.()}
<!-- Способ 2: fallback через {:else} (заменяет <slot>fallback</slot>) -->
{#if children}
{@render children()}
{:else}
<p>Контент не передан</p>
{/if}
Экспорт сниппетов между файлами
Сниппет можно объявить в <script module> и переиспользовать между файлами (Svelte ≥ 5.5):
<!-- snippets.svelte -->
<script module>
export { add };
</script>
{#snippet add(a: number, b: number)}
{a} + {b} = {a + b}
{/snippet}
<!-- App.svelte -->
<script>
import { add } from './snippets.svelte';
</script>
{@render add(1, 2)}
Отличия от слотов Svelte 4
| Svelte 4 | Svelte 5 | Зачем |
|---|---|---|
<slot /> |
{@render children?.()} |
<slot> больше не существует |
<slot name="header" /> |
{@render header?.()} |
Именованные → именованные сниппеты |
<slot>fallback</slot> |
{#if children}{@render children()}{:else}fallback{/if} |
Дефолт через {:else} |
<svelte:fragment slot="x">…</svelte:fragment> |
{#snippet x()}…{/snippet} |
Блочные сниппеты |
let:item={row} (slot props) |
Передавайте данные через пропсы и рендерите сниппет | Нет slot props |
$$slots.header |
header !== undefined |
$$slots удалён |
<slot> без параметров |
{#snippet name(a, b)} — может принимать произвольные параметры |
Сниппеты — функции |
| Неявный «магический» канал | Сниппеты — обычные пропсы с типами | Явный контракт на этапе компиляции |
Ключевые преимущества сниппетов:
- Принимают произвольные параметры с типами — как обычные функции
- Передаются как обычные пропсы → автокомплит, типы, рефакторинг
{@render}принимает выражения:{@render (cond ? a : b)()}- Рекурсия и self-reference из коробки
- Экспорт между файлами через
<script module>
Шаблонный синтаксис
<!-- Условия -->
{#if count > 10}
<p>Много: {count}</p>
{:else if count > 0}
<p>Мало: {count}</p>
{:else}
<p>Ноль</p>
{/if}
<!-- Циклы — key обязателен для производительности -->
{#each items as item, index (item.id)}
<li>{index}: {item.name}</li>
{:else}
<li>Список пуст</li>
{/each}
<!-- Await -->
{#await promise}
<p>Загрузка...</p>
{:then value}
<p>Результат: {value}</p>
{:catch error}
<p>Ошибка: {error.message}</p>
{/await}
<!-- {#key} — принудительный перерендер при смене выражения -->
{#key item.id}
<ExpensiveComponent {item} />
{/key}
<!-- @const — локальные константы -->
{@const total = items.reduce((s, i) => s + i.price, 0)}
<p>Итого: {total}</p>
<!-- ⚠️ @const — legacy. В новом коде используйте {const x = $derived(y)} -->
{@const area = box.width * box.height} <!-- ❌ legacy -->
{const area = $derived(box.width * box.height)} <!-- ✅ рекомендуется -->
<!-- @html — сырой HTML (осторожно с XSS!) -->
{@html rawHtml}
{@const} — ограничения и альтернатива:
{@const} допустим только как непосредственный потомок:
{#if ...},{#each ...},{#snippet ...}<Component /><svelte:boundary>
В других местах (между свободными тегами) — нельзя. В новом коде рекомендуется {const x = $derived(y)}:
| Задача | @const (legacy) |
Современный стиль |
|---|---|---|
| Локальная константа без зависимостей | {@const x = 10} |
{const x = 10} |
| Производное значение (реактивно) | {@const y = items.length} |
{const y = $derived(items.length)} |
| Сложная логика | {@const z = items.reduce(...)} |
{const z = $derived.by(() => items.reduce(...))} |
Директивы
<!-- bind:value — двустороннее связывание -->
<input bind:value={name} />
<textarea bind:value={text} />
<input type="checkbox" bind:checked={agree} />
<select bind:value={selected} />
<!-- bind:this — ссылка на DOM-элемент -->
<canvas bind:this={canvasRef}></canvas>
<!-- canvasRef: $state<HTMLCanvasElement | null> -->
<!-- События — HTML-атрибуты в Svelte 5 (не on:click!) -->
<button onclick={handler}>Клик</button>
<form onsubmit|preventDefault={handleSubmit}>
<div onkeydown|stopPropagation={handleKey}>
<!-- Модификаторы: |preventDefault, |stopPropagation, |once, |self, |trusted -->
<!-- class:active — условный класс -->
<div class:active={isActive} class:disabled={!enabled}></div>
<!-- use:action — действия над DOM -->
<div use:tooltip={{ text: 'Подсказка' }}></div>
Анимации и переходы
<script>
import { fade, fly, slide, scale, blur, crossfade } from 'svelte/transition'
import { cubicOut } from 'svelte/easing'
import { flip } from 'svelte/animate'
let visible = $state(true)
</script>
<!-- transition: двусторонний (in + out) -->
<div transition:fade={{ duration: 300 }}>
<div transition:fly={{ y: 200, duration: 500, easing: cubicOut }}>
<div transition:slide>
<div transition:scale={{ start: 0.5 }}>
<!-- in / out раздельно -->
<div in:fly={{ x: -200 }} out:slide>
<!-- Анимация перестановок в списке -->
{#each items as item (item.id)}
<div animate:flip={{ duration: 300 }}>
{item.name}
</div>
{/each}
<!-- Кастомный переход -->
<script>
function typewriter(node: HTMLElement, { speed = 50 }: { speed?: number }) {
const text = node.textContent || ''
node.textContent = ''
let i = 0
const interval = setInterval(() => {
if (i < text.length) node.textContent += text[i++]
else clearInterval(interval)
}, speed)
return {
duration: text.length * speed,
tick: (t: number) => { /* прогресс 0..1 */ }
}
}
</script>
<div transition:typewriter={{ speed: 30 }}>Текст печатается...</div>
Действия (actions)
<script>
function tooltip(node: HTMLElement, options: { text: string }) {
const tip = document.createElement('div')
tip.textContent = options.text
tip.className = 'tooltip'
function show() { node.parentNode?.appendChild(tip) }
function hide() { tip.remove() }
node.addEventListener('mouseenter', show)
node.addEventListener('mouseleave', hide)
return {
update(newOpts: { text: string }) { tip.textContent = newOpts.text },
destroy() {
node.removeEventListener('mouseenter', show)
node.removeEventListener('mouseleave', hide)
tip.remove()
}
}
}
</script>
<div use:tooltip={{ text: 'Подсказка при наведении' }}>Наведи на меня</div>
Специальные элементы
<!-- <svelte:boundary> — граница ошибок (как error boundary в React) -->
<svelte:boundary>
<ComponentThatMayThrow />
{#snippet failed(error, reset)}
<div class="error">
<p>Ошибка: {error.message}</p>
<button onclick={reset}>Попробовать снова</button>
</div>
{/snippet}
</svelte:boundary>
<!-- <svelte:window> — события окна -->
<svelte:window onkeydown={handleKey} onresize={handleResize} />
<!-- <svelte:head> — элементы в <head> -->
<svelte:head>
<title>{page.title}</title>
<meta name="description" content={page.description} />
</svelte:head>
<!-- <svelte:element> — динамический тег -->
<script>
let tag = $state<'h1' | 'h2' | 'h3'>('h1')
</script>
<svelte:element this={tag}>Динамический заголовок</svelte:element>
<!-- <svelte:options> — опции компиляции -->
<svelte:options immutable={true} accessors={true} />
Сторы (stores) и контекст
Writable store и мост в руны
import { writable } from 'svelte/store'
import { fromStore, toStore } from 'svelte/store'
// Svelte 4 store
export const count = writable(0)
// В Svelte 5: мост store → руны
let countRune = fromStore(count) // реактивный $state из store
let countStore = toStore(countRune) // обратно: $state → store
Модульный стор на $state (реальный паттерн)
// stores/counter.svelte.ts
export const counter = $state({
count: 0,
label: "Счётчик",
})
export function increment() { counter.count += 1 }
export function decrement() { counter.count -= 1 }
export function reset() { counter.count = 0 }
// Использование — прямой импорт, без контекста/пропсов
import { counter, increment } from './stores/counter.svelte.ts'
Фабрика с геттерами (устойчивость к HMR)
// stores/createCounter.svelte.ts
export function createCounter(initial = 0) {
let count = $state(initial)
let doubled = $derived(count * 2)
function increment() { count++ }
function reset() { count = initial }
return {
get count() { return count }, // геттеры — защита от HMR-потерь
get doubled() { return doubled },
increment,
reset
}
}
Контекст (setContext / getContext)
<!-- Предок -->
<script>
import { setContext } from 'svelte'
const theme = $state({ mode: 'dark' })
setContext('theme', theme)
</script>
<!-- Потомок -->
<script>
import { getContext } from 'svelte'
const theme = getContext<{ mode: string }>('theme')
</script>
<p>Тема: {theme.mode}</p>
createSubscriber — мост к внешним API
// media-query.svelte.ts
import { createSubscriber } from 'svelte/reactivity'
export class MediaQuery {
#mq: MediaQueryList
#subscribe: () => void
constructor(query: string) {
this.#mq = window.matchMedia(query)
this.#subscribe = createSubscriber((update) => {
this.#mq.addEventListener('change', update)
return () => this.#mq.removeEventListener('change', update)
})
}
get matches(): boolean {
this.#subscribe() // регистрирует зависимость при чтении
return this.#mq.matches
}
}
// WebSocket — другой пример
class ReactiveWS {
#ws: WebSocket
#subscribe: () => void
#data = $state<any>(null)
constructor(url: string) {
this.#ws = new WebSocket(url)
this.#subscribe = createSubscriber((update) => {
this.#ws.addEventListener('message', (e) => {
this.#data = JSON.parse(e.data)
update()
})
return () => this.#ws.close()
})
}
get data() { this.#subscribe(); return this.#data }
}
SvelteKit: ключевые концепции
// +page.ts — загрузка данных
export const load = async ({ params, url, fetch }) => {
const res = await fetch(`/api/user/${params.id}`)
return { user: await res.json() }
}
// +page.server.ts — серверная загрузка (секреты, БД)
export const load = async ({ locals }) => {
const db = await connectDB()
return { users: await db.query('SELECT * FROM users') }
}
// +page.svelte — страница
<script>
let { data } = $props() // data приходит из load-функции
</script>
<h1>{data.user.name}</h1>
// actions — серверные действия (формы)
export const actions = {
default: async ({ request }) => {
const formData = await request.formData()
const name = formData.get('name')
// сохранить в БД
return { success: true }
}
}
<!-- +page.svelte — форма с actions -->
<script>
let { form } = $props()
</script>
<form method="POST">
<input name="name" />
<button>Отправить</button>
</form>
{#if form?.success}
<p>Сохранено!</p>
{/if}
Переход со Svelte 4 на Svelte 5
| Svelte 4 | Svelte 5 | Примечание |
|---|---|---|
let count = 0 |
let count = $state(0) |
Явная реактивность |
$: doubled = count * 2 |
let doubled = $derived(count * 2) |
Простое выражение |
$: { сложная логика } |
$derived.by(() => { ... }) |
Многострочная логика |
$: { сайд-эффект } |
$effect(() => { ... }) |
С очисткой через return |
export let name |
let { name } = $props() |
Деструктуризация |
export let age = 18 |
let { age = 18 } = $props() |
Значения по умолчанию |
<slot /> |
{@render children?.()} |
Сниппеты |
<slot name="header" /> |
{@render header?.()} |
Именованные сниппеты |
{@const x = y} |
{const x = $derived(y)} |
@const помечен legacy |
on:click={handler} |
onclick={handler} |
Без двоеточия |
on:click|preventDefault={h} |
onclick|preventDefault={h} |
Синтаксис сохранён |
$store (автоподписка) |
fromStore(store) |
Явный мост |
beforeUpdate / afterUpdate |
$effect.pre / $effect |
Через руны |
Реальные паттерны из проектов
<!-- Паттерн: localStorage + $state (используется в 15+ инструментах) -->
<script>
let data = $state(loadFromStorage('my-tool', defaultState))
function setCell(key: string, value: string) {
if (value === '') {
const next = { ...data.cells }; delete next[key]
data = { ...data, cells: next } // новый объект → триггер
} else {
data = { ...data, cells: { ...data.cells, [key]: value } }
}
saveToStorage('my-tool', data) // персистентность
}
</script>
<!-- Паттерн: один иконный компонент с async-загрузкой -->
<script>
import { onMount } from 'svelte'
let { name, size = 24 } = $props()
let iconHtml = $state('')
let loaded = $state(false)
onMount(async () => {
try {
iconHtml = await fetchAndRenderIcon(name, { size })
} catch { /* fallback-иконка */ }
loaded = true
})
</script>
{#if loaded}
{@html iconHtml}
{/if}
<!-- Паттерн: i18n с fallback-переводчиком -->
<script>
let { t = (k: string) => k } = $props()
</script>
<span>{t('hello.world')}</span>
Частые проблемы и их решения
| Проблема | ❌ Ошибка | ✅ Решение |
|---|---|---|
| $state в .ts/.js файле | let x = $state(0) в utils.ts |
Переименовать в utils.svelte.ts |
| $derived не обновляется после мутации | Деструктуризация const [first] = items |
Обращаться через items[0], не деструктурировать |
| Бесконечный цикл в $effect | Запись в state, от которого зависит effect | untrack(() => { setState(value) }) |
| Race condition: ответ пришёл на старый запрос | Нет отмены fetch в $effect | AbortController + return cleanup |
| Двойное срабатывание $effect в dev | StrictMode — намеренно | В production однократно |
| Смешанный режим (runes + legacy) | let x = 0; let y = $state(0) |
Полная миграция файла на руны |
| HMR теряет состояние в .svelte.ts | Vite пересоздаёт модуль | Фабрики с геттерами (createX()) |
$effect не ждёт async |
$effect(async () => { await fn() }) |
.then() внутри синхронного $effect |
$props() rest мутабелен |
rest.class = 'x' — сбросится |
Не мутировать rest-объект |
| Деструктуризация $state объекта | const { name } = $state({name: 'A'}) |
const obj = $state(...); obj.name |
| $derived не реагирует на push в $state.raw | push мутирует, но Proxy нет | Заменять ссылку: data = [...data, item] |
Полезные ресурсы
- Svelte 5 документация — официальный сайт
- Svelte 5 Runes Guide — полный справочник рун
- Svelte 5 Migration Guide — переход с Svelte 4
- SvelteKit — фреймворк на базе Svelte
- Svelte Society — рецепты и компоненты
- Svelte хранилище (svelte/store) — оригинальные сторы
Что дальше
- Шпаргалка по React — сравнение Svelte и React
- Шпаргалка по Vue 3 — сравнение Svelte и Vue
- Шпаргалка по TypeScript — типизация Svelte-компонентов
- Шпаргалка по Git — управление версиями
Связанные шпаргалки: React, Vue 3, TypeScript, Git Advanced, Docker