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

20. Svelte 5: Новая реактивность

🔬 Svelte 5: Глубокое погружение в реактивность

Заголовок раздела «🔬 Svelte 5: Глубокое погружение в реактивность»

Привет! 👋 Svelte 5 переписал систему реактивности с нуля. Это уже не просто “компилятор-магия” — это настоящая система сигналов (signals), которая даёт тебе точный контроль над тем, что и когда перерисовывается. Если Svelte 4 был как автоматическая коробка передач, то Svelte 5 — это механическая с турбонаддувом 🏎️.

Думай о $state как о GPS-трекере: он следит за каждым изменением данных и точно знает, кого нужно уведомить. Никаких лишних перерисовок, никакого виртуального DOM — только хирургически точные обновления.


🧠 Как работает реактивность Svelte 5 изнутри

Заголовок раздела «🧠 Как работает реактивность Svelte 5 изнутри»

Svelte 5 использует концепцию fine-grained reactivity (тонко-зернистой реактивности). Это та же идея, что и в SolidJS, MobX и Angular Signals.

Svelte 4 (старый подход):
Изменилась переменная → перекомпилировать весь компонент
Svelte 5 (новый подход):
Изменился $state → уведомить только тех, кто подписан → обновить только нужный DOM-узел

Три кита реактивности Svelte 5:

  1. Sources (источники)$state() — хранят значения и уведомляют зависимостей
  2. Derivations (производные)$derived() — вычисляются при изменении источников
  3. Effects (эффекты)$effect() — запускаются при изменении зависимостей

📦 $state() с объектами и массивами: глубокие реактивные прокси

Заголовок раздела «📦 $state() с объектами и массивами: глубокие реактивные прокси»

Простые значения — это понятно. Но что происходит, когда ты передаёшь объект или массив в $state()?

<script lang="ts">
// $state() оборачивает объект в Proxy
let user = $state({
name: 'Яша',
age: 25,
address: {
city: 'Москва',
street: 'Арбат',
},
hobbies: ['программирование', 'кофе', 'кошки'],
});
// Svelte 5 создаёт ГЛУБОКИЙ прокси — все вложенные объекты тоже реактивны!
function updateCity() {
user.address.city = 'Питер'; // ✅ Реактивно! Даже вложенное свойство
}
function addHobby() {
user.hobbies.push('музыка'); // ✅ Реактивно! Мутация массива работает
}
function replaceHobbies() {
user.hobbies = ['только сон']; // ✅ Тоже реактивно
}
</script>
<p>Имя: {user.name}</p>
<p>Город: {user.address.city}</p>
<ul>
{#each user.hobbies as hobby}
<li>{hobby}</li>
{/each}
</ul>
<button onclick={updateCity}>Переехать в Питер</button>
<button onclick={addHobby}>Новое хобби</button>

Важно понять: $state() создаёт рекурсивный Proxy. Когда ты обращаешься к user.address, ты получаешь ещё один Proxy для объекта адреса. Это называется deep reactive proxy.

<script lang="ts">
let items = $state<string[]>(['яблоко', 'банан']);
// Все методы массива работают реактивно:
function demo() {
items.push('вишня'); // ✅
items.pop(); // ✅
items.splice(0, 1); // ✅
items.sort(); // ✅
items.reverse(); // ✅
items[0] = 'манго'; // ✅ Индексированное присвоение тоже!
items.length = 0; // ✅ Даже это работает!
}
</script>

Иногда тебе нужно хранить большой объект, который не должен быть реактивным. Например, данные из API, которые ты показываешь один раз. Для этого есть $state.raw():

<script lang="ts">
// $state.raw() НЕ создаёт прокси — это просто хранилище
// Обновления возможны только через переприсвоение всего значения
let bigData = $state.raw<Record<string, number>>({});
// ❌ Это НЕ вызовет обновление UI
// bigData.newKey = 123;
// ✅ Это вызовет обновление UI — полное переприсвоение
async function fetchData() {
const response = await fetch('/api/data');
bigData = await response.json(); // Весь объект заменяется
}
// Зачем это нужно?
// 1. Производительность — нет overhead от Proxy
// 2. Совместимость — некоторые библиотеки не любят Proxy
// 3. Явность — ты явно говоришь "этот объект изменяется целиком"
// Другой пример — канвас или WebGL данные:
let vertices = $state.raw<Float32Array>(new Float32Array(1000));
function updateGeometry() {
const newVertices = new Float32Array(1000);
// ... заполнить новые данные ...
vertices = newVertices; // Только так!
}
</script>

Сравнение производительности:

$state({...}) → Proxy overhead, но автоматические точечные обновления
$state.raw({...}) → Нет overhead, но нужно полное переприсвоение
Используй $state.raw() для:
- Больших неизменяемых данных
- Данных, которые обновляются целиком (fetch результаты)
- Объектов, которые несовместимы с Proxy (например, некоторые классы)

📸 $state.snapshot(): снимок состояния без реактивности

Заголовок раздела «📸 $state.snapshot(): снимок состояния без реактивности»
<script lang="ts">
let form = $state({
name: '',
email: '',
message: '',
});
async function handleSubmit() {
// $state.snapshot() создаёт обычный объект (не Proxy)
// Это полезно когда нужно сериализовать данные или отправить в API
const snapshot = $state.snapshot(form);
console.log(snapshot); // { name: '...', email: '...', message: '...' }
console.log(typeof snapshot); // 'object' (не Proxy!)
// Можно безопасно JSON.stringify:
const json = JSON.stringify(snapshot);
// Можно передать в функцию, которая не ожидает Proxy:
await sendToServer(snapshot);
}
// Зачем нужен snapshot а не просто spread?
// Потому что для вложенных объектов spread не убирает Proxy:
const shallowCopy = { ...form }; // address всё ещё Proxy!
const deepSnapshot = $state.snapshot(form); // Всё чистые объекты
</script>

$derived() — это автоматически пересчитываемое значение. Думай о нём как о формуле в Excel: изменился входные ячейки → автоматически пересчиталась формула.

<script lang="ts">
let items = $state([
{ name: 'Ноутбук', price: 80000, qty: 1 },
{ name: 'Мышь', price: 2000, qty: 2 },
{ name: 'Монитор', price: 30000, qty: 1 },
]);
// Простые производные значения:
let total = $derived(items.reduce((sum, item) => sum + item.price * item.qty, 0));
let itemCount = $derived(items.reduce((sum, item) => sum + item.qty, 0));
let avgPrice = $derived(total / itemCount);
// $derived.by() для сложной логики с блоком кода:
let analysis = $derived.by(() => {
if (items.length === 0) return { status: 'empty', message: 'Корзина пуста' };
const mostExpensive = items.reduce((max, item) =>
item.price > max.price ? item : max
);
const cheapest = items.reduce((min, item) =>
item.price < min.price ? item : min
);
const discount = total > 100000 ? 0.1 : total > 50000 ? 0.05 : 0;
return {
status: 'filled',
mostExpensive: mostExpensive.name,
cheapest: cheapest.name,
discount,
finalPrice: total * (1 - discount),
message: discount > 0 ? `Скидка ${discount * 100}%!` : 'Скидок нет',
};
});
// $derived.by() также полезен для асинхронных зависимостей
// и когда нужно ранний return:
let formError = $derived.by(() => {
const name = form.name.trim();
const email = form.email.trim();
if (!name) return 'Имя обязательно';
if (name.length < 2) return 'Имя слишком короткое';
if (!email) return 'Email обязателен';
if (!email.includes('@')) return 'Неверный email';
return null; // Нет ошибки
});
let form = $state({ name: '', email: '' });
</script>
<p>Сумма: {total} ₽</p>
<p>Статус: {analysis.message}</p>
{#if formError}
<p style="color: red">{formError}</p>
{/if}

Ключевое отличие $derived() от $derived.by():

<script lang="ts">
let x = $state(10);
// $derived() — только выражение (одна строка):
let doubled = $derived(x * 2);
// $derived.by() — полный блок кода:
let processed = $derived.by(() => {
// Можно использовать if/else, циклы, early return
if (x < 0) return 'отрицательное';
if (x === 0) return 'ноль';
let result = '';
for (let i = 0; i < x; i++) {
result += '★';
}
return result;
});
</script>

$effect() запускается после монтирования компонента и после каждого изменения реактивных зависимостей.

<script lang="ts">
let count = $state(0);
let name = $state('Яша');
// $effect автоматически отслеживает зависимости
$effect(() => {
// Читаем count и name — они становятся зависимостями
console.log(`count изменился: ${count}, name: ${name}`);
// Этот эффект запустится когда изменится count ИЛИ name
});
// Эффект с очисткой:
$effect(() => {
const id = setInterval(() => {
count++;
}, 1000);
// Возвращаем функцию очистки — она вызовется перед следующим запуском
// или при размонтировании компонента
return () => {
clearInterval(id);
console.log('Таймер очищен!');
};
});
// Практический пример — синхронизация с localStorage:
$effect(() => {
localStorage.setItem('user-name', name);
});
// Подписка на WebSocket:
$effect(() => {
const ws = new WebSocket('wss://example.com/live');
ws.onmessage = (event) => {
// обработка сообщения
};
return () => ws.close(); // Закрываем при очистке
});
</script>

Обычный $effect() запускается после обновления DOM. Но иногда нужно что-то сделать до — например, запомнить позицию скролла перед тем, как DOM изменится.

<script lang="ts">
let items = $state<string[]>([]);
let listElement: HTMLUListElement;
let scrollPosition = 0;
// $effect.pre() запускается СИНХРОННО перед обновлением DOM
$effect.pre(() => {
// Запоминаем позицию скролла перед изменением списка
if (items) { // Подписываемся на items
scrollPosition = listElement?.scrollTop ?? 0;
}
});
// Обычный $effect запустится ПОСЛЕ и восстановит скролл:
$effect(() => {
if (listElement && scrollPosition > 0) {
listElement.scrollTop = scrollPosition;
}
});
// Другой пример — анимации перед удалением:
$effect.pre(() => {
// Запускаем анимацию исчезновения ДО того, как элемент удалится из DOM
void items; // читаем для подписки
document.querySelectorAll('.leaving').forEach(el => {
el.classList.add('fade-out');
});
});
</script>

Последовательность выполнения:

1. Изменился $state
2. $effect.pre() запускается (DOM ещё не обновлён)
3. Svelte обновляет DOM
4. $effect() запускается (DOM уже обновлён)

🕵️ $effect.tracking(): проверяем, отслеживаем ли мы

Заголовок раздела «🕵️ $effect.tracking(): проверяем, отслеживаем ли мы»
<script lang="ts">
let count = $state(0);
// $effect.tracking() возвращает true если мы внутри reactive context
$effect(() => {
console.log('Внутри эффекта:', $effect.tracking()); // true
// Полезно для создания утилитных функций, которые ведут себя
// по-разному в зависимости от контекста:
logWithTracking('Что-то произошло');
});
// Вне reactive context:
console.log('Снаружи:', $effect.tracking()); // false
function logWithTracking(message: string) {
if ($effect.tracking()) {
// Мы внутри $effect или $derived — зависимости отслеживаются
console.log('[REACTIVE]', message);
} else {
// Обычный вызов
console.log('[NORMAL]', message);
}
}
</script>

Иногда нужно прочитать реактивное значение внутри эффекта, но не подписываться на его изменения:

<script lang="ts">
import { untrack } from 'svelte';
let a = $state(0);
let b = $state(0);
// Этот эффект запускается только когда изменяется `a`
// Изменение `b` не вызовет перезапуск
$effect(() => {
console.log('a изменился:', a);
// untrack() читает b без создания зависимости
const bValue = untrack(() => b);
console.log('Текущее b (без подписки):', bValue);
});
// Практичный пример — дебаунс с актуальным значением:
let searchQuery = $state('');
let searchResults = $state<string[]>([]);
$effect(() => {
// Подписываемся только на searchQuery
const query = searchQuery;
const timer = setTimeout(() => {
// Читаем дополнительные фильтры без подписки на них
const filters = untrack(() => currentFilters);
performSearch(query, filters);
}, 300);
return () => clearTimeout(timer);
});
let currentFilters = $state({ category: 'all', sort: 'name' });
async function performSearch(query: string, filters: typeof currentFilters) {
// поиск...
}
</script>

Svelte 5 позволяет создавать классы с реактивными полями — это мощный паттерн для создания переиспользуемой логики:

<script lang="ts">
// Класс с реактивными полями — каждый экземпляр независим!
class Counter {
count = $state(0);
step = $state(1);
get doubled() {
return $derived(this.count * 2); // Нет! Так нельзя внутри класса
}
// $derived в классах — через геттер с $derived.by() — нет...
// На самом деле нужно использовать геттеры напрямую:
get doubled() {
return this.count * 2; // Это работает через Proxy magic!
}
increment() {
this.count += this.step;
}
decrement() {
this.count = Math.max(0, this.count - this.step);
}
reset() {
this.count = 0;
}
}
// Создаём несколько независимых счётчиков:
let counter1 = new Counter();
let counter2 = new Counter();
counter2.step = 5; // Разный шаг для разных экземпляров
</script>
<div>
<p>Счётчик 1: {counter1.count} (удвоен: {counter1.doubled})</p>
<button onclick={() => counter1.increment()}>+1</button>
<button onclick={() => counter1.decrement()}>-1</button>
</div>
<div>
<p>Счётчик 2: {counter2.count} (шаг: {counter2.step})</p>
<button onclick={() => counter2.increment()}>+{counter2.step}</button>
</div>

Более сложный пример — реактивная форма:

<script lang="ts">
class FormField {
value = $state('');
touched = $state(false);
constructor(
private validator: (v: string) => string | null
) {}
get error() {
if (!this.touched) return null;
return this.validator(this.value);
}
get isValid() {
return this.error === null;
}
touch() {
this.touched = true;
}
}
class LoginForm {
email = new FormField((v) => {
if (!v) return 'Email обязателен';
if (!v.includes('@')) return 'Неверный формат';
return null;
});
password = new FormField((v) => {
if (!v) return 'Пароль обязателен';
if (v.length < 8) return 'Минимум 8 символов';
return null;
});
isSubmitting = $state(false);
get isValid() {
return this.email.isValid && this.password.isValid;
}
async submit() {
this.email.touch();
this.password.touch();
if (!this.isValid) return;
this.isSubmitting = true;
try {
await login(this.email.value, this.password.value);
} finally {
this.isSubmitting = false;
}
}
}
const form = new LoginForm();
async function login(email: string, password: string) {
// API call
}
</script>
<form onsubmit={(e) => { e.preventDefault(); form.submit(); }}>
<input
type="email"
bind:value={form.email.value}
onblur={() => form.email.touch()}
/>
{#if form.email.error}
<span class="error">{form.email.error}</span>
{/if}
<input
type="password"
bind:value={form.password.value}
onblur={() => form.password.touch()}
/>
{#if form.password.error}
<span class="error">{form.password.error}</span>
{/if}
<button type="submit" disabled={form.isSubmitting}>
{form.isSubmitting ? 'Входим...' : 'Войти'}
</button>
</form>

🔄 Паттерны реактивности: что использовать когда

Заголовок раздела «🔄 Паттерны реактивности: что использовать когда»
Ситуация → Инструмент
──────────────────────────────────────────────────
Простое значение (число, строка) → $state()
Объект/массив с мутациями → $state()
Большие данные (только замена) → $state.raw()
Нужен снимок для API → $state.snapshot()
Формула из других данных → $derived()
Сложная логика вычисления → $derived.by()
Синхронизация с внешним миром → $effect()
До обновления DOM → $effect.pre()
Проверить контекст → $effect.tracking()
Читать без подписки → untrack()
Переиспользуемая логика → Реактивный класс

<script lang="ts">
// ❌ Ошибка: деструктуризация теряет реактивность
let user = $state({ name: 'Яша', age: 25 });
let { name, age } = user; // name и age — обычные переменные, не реактивные!
// ✅ Правильно: читаем через объект
$effect(() => {
console.log(user.name, user.age); // Реактивно!
});
// ❌ Ошибка: $derived в условии
let condition = $state(true);
// if (condition) { let x = $derived(...) } // Нельзя! Рунасы должны быть на верхнем уровне
// ✅ Правильно: $derived на верхнем уровне
let x = $derived(condition ? 'да' : 'нет');
// ❌ Ошибка: бесконечный цикл в $effect
let counter = $state(0);
$effect(() => {
counter++; // Читаем counter (зависимость) и меняем его → бесконечный цикл!
});
// ✅ Правильно: используем untrack для изменения зависимости
$effect(() => {
void someOtherDependency; // Подписываемся на что-то другое
untrack(() => { counter++; }); // Меняем без создания цикла
});
</script>

<!-- Svelte 4 -->
<script lang="ts">
let count = 0; // Реактивная переменная (магия компилятора)
$: doubled = count * 2; // Реактивное объявление
$: console.log(count); // Реактивный оператор
$: {
// Реактивный блок
if (count > 10) alert('Много!');
}
</script>
<!-- Svelte 5 -->
<script lang="ts">
let count = $state(0); // Явный сигнал
let doubled = $derived(count * 2); // Явное производное
$effect(() => console.log(count)); // Явный эффект
$effect(() => {
if (count > 10) alert('Много!');
});
</script>

Преимущества Svelte 5:

  • Явность — видно что реактивно, а что нет
  • Переиспользование — логика в классах и функциях (не только в компонентах)
  • Предсказуемость — никаких сюрпризов с реактивностью
  • TypeScript — лучший вывод типов