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

19. Svelte 5: Runes

Svelte 5 — это не просто обновление. Это переосмысление того, как работает реактивность. Runes (руны) — новый синтаксис, который делает реактивность явной, предсказуемой и работающей везде: в компонентах, в .svelte.ts файлах, в тестах 🔮


<!-- Svelte 4 — что реактивно, а что нет? -->
<script>
let count = 0 // Реактивно? ДА (компилятор делает magic)
const obj = {} // Реактивно? ДА если в компоненте
let arr = [] // Реактивно? ДА
// Но! В обычном .ts файле — НИЧЕГО из этого не работает!
// Реактивность только внутри .svelte файлов
</script>
<!-- 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 — это специальные функции-сигналы: $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 из коробки

<!-- 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>
// 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 реактивно изменяется

<!-- 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>
<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>

<!-- 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>
<script>
import { tick } from 'svelte'
let div: HTMLElement
$effect.pre(() => {
// Запускается ПЕРЕД обновлением DOM
// Как beforeUpdate в Svelte 4
if (div) {
console.log('DOM перед обновлением:', div.scrollTop)
}
})
</script>

<!-- 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>

<!-- 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} />

<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 │ 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) │
└────────────────────┴──────────────────────────┴─────────────────────────────┘

Главная революция: реактивность теперь работает в обычных 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 — оригинал -->
<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>

<!-- 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. Начни с нового проекта — сразу runes
2. В существующем: включай поофайлово через <svelte:options runes>
3. Svelte 4 синтаксис работает В TОМ ЖЕ проекте (обратная совместимость)
4. Постепенно мигрируй компонент за компонентом
5. Когда всё перемигрировано — включи runes: true глобально