19. Svelte 5: Runes
🪄 Svelte 5 Runes: Революция реактивности
Заголовок раздела «🪄 Svelte 5 Runes: Революция реактивности»Svelte 5 — это не просто обновление. Это переосмысление того, как работает реактивность. Runes (руны) — новый синтаксис, который делает реактивность явной, предсказуемой и работающей везде: в компонентах, в .svelte.ts файлах, в тестах 🔮
Почему Runes? Проблемы Svelte 4
Заголовок раздела «Почему Runes? Проблемы Svelte 4»Проблема 1: Магические переменные
Заголовок раздела «Проблема 1: Магические переменные»<!-- Svelte 4 — что реактивно, а что нет? --><script> let count = 0 // Реактивно? ДА (компилятор делает magic) const obj = {} // Реактивно? ДА если в компоненте let arr = [] // Реактивно? ДА
// Но! В обычном .ts файле — НИЧЕГО из этого не работает! // Реактивность только внутри .svelte файлов</script>Проблема 2: $: — источник путаницы
Заголовок раздела «Проблема 2: $: — источник путаницы»<!-- Svelte 4 — $: делает несколько разных вещей --><script> $: doubled = count * 2 // Вычисляемое значение (как derived) $: console.log(count) // Побочный эффект (как onMount) $: { // Блок кода при изменении console.log('count changed') updateSomething() } $: if (count > 10) { // Условный эффект alert('Большое число!') }</script>Проблема 3: Деструктуризация ломает реактивность
Заголовок раздела «Проблема 3: Деструктуризация ломает реактивность»<!-- Svelte 4 — проблема с destructuring --><script> export let user = { name: 'Яша', age: 10 }
// ❌ Ломает реактивность при обновлении user! let { name, age } = user
// При изменении user, name и age НЕ обновляются</script>Runes — Явная реактивность
Заголовок раздела «Runes — Явная реактивность»Runes — это специальные функции-сигналы: $state, $derived, $effect, $props, $bindable, $inspect.
Свел 4 (неявная реактивность):let x = 0 // Магически реактивно в компоненте$: doubled = x * 2 // Перегруженный синтаксис
Svelte 5 (явная реактивность — Runes):let x = $state(0) // ЯВНО: "это реактивное состояние"let doubled = $derived(x * 2) // ЯВНО: "это вычисляемое значение"
Главное преимущество:✅ Работает ВЕЗДЕ: в .svelte, .svelte.ts, в тестах✅ Предсказуемо: нет "магии" компилятора✅ TypeScript-friendly из коробки$state() — Замена реактивным переменным
Заголовок раздела «$state() — Замена реактивным переменным»<!-- Svelte 4 --><script> let count = 0 let name = 'Яша' let items = ['а', 'б', 'в']</script>
<!-- Svelte 5 с Runes --><script> let count = $state(0) let name = $state('Яша') let items = $state(['а', 'б', 'в'])
// Реактивные объекты — глубокое отслеживание! let user = $state({ name: 'Яша', profile: { age: 10, city: 'Москва', }, })
// Мутации работают! (в Svelte 4 нужно было user = { ...user, name: 'Петя' }) function birthday() { user.profile.age++ // Svelte 5 отслеживает глубокие изменения }</script>$state() в классах — Реактивные классы!
Заголовок раздела «$state() в классах — Реактивные классы!»// counter.svelte.ts — РАБОТАЕТ вне компонента!class Counter { count = $state(0) min: number max: number
constructor(initial = 0, min = -Infinity, max = Infinity) { this.count = $state(initial) this.min = min this.max = max }
increment() { this.count = Math.min(this.count + 1, this.max) }
decrement() { this.count = Math.max(this.count - 1, this.min) }
reset() { this.count = 0 }}
// В компоненте — реактивно!const counter = new Counter(0, 0, 10)// counter.count реактивно изменяется$derived() — Замена $:
Заголовок раздела «$derived() — Замена $:»<!-- Svelte 4 --><script> let count = 0 $: doubled = count * 2 $: isEven = count % 2 === 0 $: label = count > 10 ? 'Большое' : 'Маленькое'</script>
<!-- Svelte 5 --><script> let count = $state(0) let doubled = $derived(count * 2) let isEven = $derived(count % 2 === 0) let label = $derived(count > 10 ? 'Большое' : 'Маленькое')</script>$derived.by() — Для сложных вычислений
Заголовок раздела «$derived.by() — Для сложных вычислений»<script> let items = $state([ { id: 1, price: 100, qty: 2 }, { id: 2, price: 250, qty: 1 }, { id: 3, price: 50, qty: 5 }, ])
// Простой $derived() для выражений let count = $derived(items.length)
// $derived.by() для многострочных вычислений let total = $derived.by(() => { return items.reduce((sum, item) => { return sum + item.price * item.qty }, 0) })
let averagePrice = $derived.by(() => { if (items.length === 0) return 0 return total / items.reduce((sum, i) => sum + i.qty, 0) })
let sortedByPrice = $derived.by(() => [...items].sort((a, b) => b.price - a.price) )</script>$effect() — Замена onMount + beforeUpdate + afterUpdate
Заголовок раздела «$effect() — Замена onMount + beforeUpdate + afterUpdate»<!-- Svelte 4 --><script> import { onMount, beforeUpdate, afterUpdate, onDestroy } from 'svelte'
onMount(() => { console.log('Компонент создан') return () => console.log('Компонент уничтожен') })
beforeUpdate(() => console.log('Перед обновлением')) afterUpdate(() => console.log('После обновления'))</script>
<!-- Svelte 5 --><script> $effect(() => { // Запускается ПОСЛЕ монтирования и при каждом обновлении // зависимостей, которые используются внутри console.log('count изменился:', count)
// Возвращаем функцию очистки (как в useEffect React) return () => { console.log('Очистка перед следующим запуском') } })
// $effect запускается при изменении ИСПОЛЬЗУЕМЫХ значений $effect(() => { document.title = 'Счётчик: ' + count // Следит за count })
$effect(() => { const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') closeModal() } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) })</script>$effect.pre() — Запускается ДО обновления DOM
Заголовок раздела «$effect.pre() — Запускается ДО обновления DOM»<script> import { tick } from 'svelte'
let div: HTMLElement
$effect.pre(() => { // Запускается ПЕРЕД обновлением DOM // Как beforeUpdate в Svelte 4 if (div) { console.log('DOM перед обновлением:', div.scrollTop) } })</script>$props() — Замена export let
Заголовок раздела «$props() — Замена export let»<!-- Svelte 4 --><script> export let name: string export let age = 0 export let optional: string | undefined = undefined
// Остальные пропсы — нет встроенного способа получить</script>
<!-- Svelte 5 --><script lang="ts"> interface Props { name: string age?: number optional?: string // Обработчики событий — просто пропсы! onclick?: (e: MouseEvent) => void onchange?: (value: string) => void }
// Деструктуризация с defaults — работает и реактивна! let { name, age = 0, optional, onclick, onchange, ...rest // Остальные атрибуты! }: Props = $props()
// Больше нет проблем с деструктуризацией! // name, age автоматически обновляются при изменении пропса</script>
<div onclick={onclick} {...rest}> {name}, {age}</div>$bindable() — Двусторонняя привязка
Заголовок раздела «$bindable() — Двусторонняя привязка»<!-- Svelte 5 — явная двусторонняя привязка --><script lang="ts"> let { value = $bindable(''), // ← Явно помечаем как bindable count = $bindable(0), open = $bindable(false), } = $props()</script>
<input bind:value /><!-- Родитель --><script> let searchValue = $state('') let modalOpen = $state(false)</script>
<!-- bind: работает только с $bindable() пропсами --><SearchInput bind:value={searchValue} /><Modal bind:open={modalOpen} />$inspect() — Отладка реактивности
Заголовок раздела «$inspect() — Отладка реактивности»<script> let count = $state(0) let user = $state({ name: 'Яша', age: 10 })
// Логирует в консоль при каждом изменении count $inspect(count)
// Логирует несколько значений $inspect(count, user)
// Кастомный логгер $inspect(count).with((type, value) => { // type: 'init' | 'update' if (type === 'update') { console.log('count изменился с ??? на', value) // Можно отправлять в DevTools, Sentry и т.д. } })</script>
<!-- В DevTools видишь каждое изменение состояния! -->Таблица сравнения: Svelte 4 vs Svelte 5
Заголовок раздела «Таблица сравнения: Svelte 4 vs Svelte 5»┌────────────────────┬──────────────────────────┬─────────────────────────────┐│ Концепция │ Svelte 4 │ Svelte 5 (Runes) │├────────────────────┼──────────────────────────┼─────────────────────────────┤│ Состояние │ let count = 0 │ let count = $state(0) ││ Вычисл. значение │ $: doubled = count * 2 │ let d = $derived(count * 2) ││ Побочный эффект │ $: console.log(count) │ $effect(() => {...}) ││ Монтирование │ onMount(() => {...}) │ $effect(() => {...}) ││ Размонтирование │ onDestroy(() => {...}) │ $effect(() => return cleanup││ Пропсы │ export let name │ let {name} = $props() ││ Двуст. привязка │ export let value │ let {value=$bindable()}=... ││ Отладка │ console.log($count) │ $inspect(count) ││ Работает вне .svelte│ ❌ Нет │ ✅ Да (.svelte.ts) │└────────────────────┴──────────────────────────┴─────────────────────────────┘Runes вне компонентов — .svelte.ts файлы
Заголовок раздела «Runes вне компонентов — .svelte.ts файлы»Главная революция: реактивность теперь работает в обычных TypeScript файлах!
// stores/todoStore.svelte.ts ← заметь расширение!
export function createTodoStore() { let items = $state<Todo[]>([])
// $derived.by тоже работает! let completed = $derived(items.filter(i => i.done).length) let pending = $derived(items.filter(i => !i.done).length) let progress = $derived( items.length > 0 ? Math.round((completed / items.length) * 100) : 0 )
// $effect работает при использовании в компоненте $effect(() => { // Сохраняем в localStorage при каждом изменении localStorage.setItem('todos', JSON.stringify(items)) })
return { get items() { return items }, get completed() { return completed }, get pending() { return pending }, get progress() { return progress },
add(text: string) { items = [...items, { id: crypto.randomUUID(), text, done: false }] },
toggle(id: string) { items = items.map(i => i.id === id ? { ...i, done: !i.done } : i) },
remove(id: string) { items = items.filter(i => i.id !== id) }, }}
// Глобальный синглтон или создавай по местуexport const todoStore = createTodoStore()Миграция с Svelte 4 на Svelte 5
Заголовок раздела «Миграция с Svelte 4 на Svelte 5»<!-- Svelte 4 — оригинал --><script lang="ts"> import { onMount, onDestroy, createEventDispatcher } from 'svelte'
export let title: string export let count = 0
const dispatch = createEventDispatcher<{ change: number }>()
let doubled: number $: doubled = count * 2
let timer: ReturnType<typeof setInterval>
onMount(() => { timer = setInterval(() => count++, 1000) })
onDestroy(() => { clearInterval(timer) })
function increment() { count++ dispatch('change', count) }</script><!-- Svelte 5 — с Runes --><script lang="ts"> interface Props { title: string count?: number onchange?: (value: number) => void }
let { title, count = $bindable(0), onchange }: Props = $props()
let doubled = $derived(count * 2)
$effect(() => { const timer = setInterval(() => count++, 1000) return () => clearInterval(timer) })
function increment() { count++ onchange?.(count) }</script>
<h1>{title}</h1><p>Count: {count}, Doubled: {doubled}</p><button onclick={increment}>+</button>Режим совместимости: Runes Mode vs Legacy Mode
Заголовок раздела «Режим совместимости: Runes Mode vs Legacy Mode»<!-- svelte:options runes={true} — включить runes в файле --><svelte:options runes={true} />
<script> let count = $state(0) // ← Явный rune // Свел 4 синтаксис здесь НЕ работает: $: x = count * 2 — ошибка!</script>// svelte.config.js — включить runes для всего проектаexport default { compilerOptions: { runes: true, // Все .svelte файлы используют runes }}Стратегия миграции:1. Начни с нового проекта — сразу runes2. В существующем: включай поофайлово через <svelte:options runes>3. Svelte 4 синтаксис работает В TОМ ЖЕ проекте (обратная совместимость)4. Постепенно мигрируй компонент за компонентом5. Когда всё перемигрировано — включи runes: true глобально