9. Stores (хранилища)
Svelte Stores: глобальное состояние без Redux 🏪
Заголовок раздела «Svelte Stores: глобальное состояние без Redux 🏪»Пропсы отлично работают для передачи данных между родителем и дочерним компонентом. Но что если нужно поделиться состоянием между компонентами, которые вообще не связаны в дереве? Вот тут-то и приходят на помощь Svelte Stores — реактивные хранилища данных, живущие вне компонентов! 🚀
Что такое Store? 🤔
Заголовок раздела «Что такое Store? 🤔»Store — это объект с методом subscribe, который хранит значение и уведомляет всех подписчиков при его изменении. Это не компонент, не хук — просто реактивный контейнер данных.
┌─────────────────────────────────┐ │ Store (writable) │ │ │ │ value: 42 │ │ │ │ subscribe(fn) ─────────────► Компонент A │ set(newVal) ─────────────► Компонент B │ update(fn) ─────────────► Компонент C └─────────────────────────────────┘Любой объект, у которого есть метод subscribe(callback), является Store по контракту Svelte. Это открывает огромные возможности для кастомизации!
writable — основной Store 📝
Заголовок раздела «writable — основной Store 📝»writable(initialValue) создаёт Store с тремя методами:
import { writable } from 'svelte/store';
// Создаём store с начальным значением 0export const count = writable(0);| Метод | Описание | Пример |
|---|---|---|
set(value) | Заменяет значение | count.set(42) |
update(fn) | Обновляет на основе текущего | count.update(n => n + 1) |
subscribe(fn) | Подписывается на изменения | count.subscribe(v => console.log(v)) |
Ручная подписка — для понимания 🔬
Заголовок раздела «Ручная подписка — для понимания 🔬»Чтобы понять, как работает Store изнутри, посмотрим на ручную подписку:
<script> import { count } from './stores.js'; import { onDestroy } from 'svelte';
let localCount;
// Подписываемся: callback вызывается сразу с текущим значением, // и затем при каждом изменении const unsubscribe = count.subscribe(value => { localCount = value; });
// ВАЖНО: отписаться при уничтожении компонента! onDestroy(unsubscribe);</script>
<p>Текущее значение: {localCount}</p><button on:click={() => count.update(n => n + 1)}>+1</button>Это работает, но довольно многословно. К счастью, Svelte придумал кое-что получше…
Auto-subscribe с $ — магия Svelte ✨
Заголовок раздела «Auto-subscribe с $ — магия Svelte ✨»Svelte предоставляет специальный синтаксис: приставка $ перед именем Store. Компилятор автоматически:
- Подписывается при монтировании компонента
- Отписывается при уничтожении
- Обновляет шаблон при каждом изменении
<script> import { count } from './stores.js'; // Больше никаких subscribe/unsubscribe вручную!</script>
<!-- $count = автоматически подписанное значение --><p>Значение: {$count}</p><button on:click={() => $count++}>+1 (прямое присваивание!)</button><button on:click={() => count.set(0)}>Сброс</button>💡 Wow-момент:
$count++не просто читает значение — Svelte компилирует это в вызовcount.set($count + 1)автоматически!
Изменение Store — все способы
Заголовок раздела «Изменение Store — все способы»<script> import { count } from './stores.js';
// 1. Через set() function reset() { count.set(0); }
// 2. Через update() function increment() { count.update(n => n + 1); } function double() { count.update(n => n * 2); }
// 3. Через $ (прямое присваивание — самый элегантный способ!) function setTen() { $count = 10; } function incrementDirect() { $count++; } function decrementDirect() { $count--; }</script>
<p>Count: {$count}</p><button on:click={reset}>Reset</button><button on:click={increment}>+1 (update)</button><button on:click={double}>×2 (update)</button><button on:click={setTen}>= 10 (set)</button><button on:click={incrementDirect}>+1 ($)</button>readable — Store только для чтения 📖
Заголовок раздела «readable — Store только для чтения 📖»readable(initial, start) создаёт Store, в который внешний код не может записывать данные. Только внутренний producer изменяет значение. Идеально для: текущего времени, геолокации, размера окна.
import { readable } from 'svelte/store';
// Часы — классический пример readable storeexport const time = readable(new Date(), function start(set) { // start() вызывается, когда появляется первый подписчик const interval = setInterval(() => { set(new Date()); }, 1000);
// stop() вызывается, когда подписчиков не осталось return function stop() { clearInterval(interval); };});<script> import { time } from './stores.js';
const formatter = new Intl.DateTimeFormat('ru', { hour: '2-digit', minute: '2-digit', second: '2-digit', });</script>
<!-- time обновляется каждую секунду, cleanup автоматический --><p>Сейчас: {formatter.format($time)}</p>Геолокация как readable store:
Заголовок раздела «Геолокация как readable store:»import { readable } from 'svelte/store';
export const geolocation = readable(null, (set) => { if (!navigator.geolocation) { set({ error: 'Геолокация недоступна' }); return () => {}; }
const watchId = navigator.geolocation.watchPosition( (pos) => set({ lat: pos.coords.latitude, lng: pos.coords.longitude }), (err) => set({ error: err.message }) );
return () => navigator.geolocation.clearWatch(watchId);});Контракт Store — кастомные объекты
Заголовок раздела «Контракт Store — кастомные объекты»Любой объект с методом subscribe(fn) является валидным Store в Svelte! subscribe должен:
- Вызвать
fnнемедленно с текущим значением - Возвращать функцию
unsubscribe - Вызывать
fnпри каждом изменении
// Минимальный store вручную:function createMinimalStore(value) { const subscribers = new Set();
return { subscribe(fn) { fn(value); // 1. Сразу вызываем с текущим значением subscribers.add(fn); return () => subscribers.delete(fn); // 2. Возвращаем unsubscribe }, set(newValue) { value = newValue; subscribers.forEach(fn => fn(value)); // 3. Уведомляем всех }, };}
const myStore = createMinimalStore(42);Кастомные Store с методами 🏗️
Заголовок раздела «Кастомные Store с методами 🏗️»Самый мощный паттерн — обернуть writable и добавить свою бизнес-логику:
import { writable } from 'svelte/store';
function createCounter(initial = 0) { const { subscribe, set, update } = writable(initial);
return { subscribe, // Обязательно экспортируем! increment: () => update(n => n + 1), decrement: () => update(n => n - 1), reset: () => set(initial), setTo: (n) => set(n), };}
export const counter = createCounter(0);<script> import { counter } from './stores.js';</script>
<p>Счётчик: {$counter}</p><button on:click={counter.increment}>+1</button><button on:click={counter.decrement}>−1</button><button on:click={counter.reset}>Сброс</button><button on:click={() => counter.setTo(100)}>= 100</button>Корзина покупок как кастомный Store:
Заголовок раздела «Корзина покупок как кастомный Store:»import { writable } from 'svelte/store';
function createCart() { const { subscribe, update, set } = writable([]);
return { subscribe,
addItem(item) { update(items => { const existing = items.find(i => i.id === item.id); if (existing) { return items.map(i => i.id === item.id ? { ...i, qty: i.qty + 1 } : i ); } return [...items, { ...item, qty: 1 }]; }); },
removeItem(id) { update(items => items.filter(i => i.id !== id)); },
updateQty(id, qty) { if (qty <= 0) { this.removeItem(id); return; } update(items => items.map(i => i.id === id ? { ...i, qty } : i)); },
clear: () => set([]), };}
export const cart = createCart();Stores в TypeScript 💪
Заголовок раздела «Stores в TypeScript 💪»Svelte предоставляет типы из svelte/store:
import { writable, readable } from 'svelte/store';import type { Writable, Readable } from 'svelte/store';
// Типизированный writableexport const count: Writable<number> = writable(0);
// Типизированный readableexport const time: Readable<Date> = readable(new Date(), (set) => { const interval = setInterval(() => set(new Date()), 1000); return () => clearInterval(interval);});
// Кастомный интерфейсinterface User { id: number; name: string; email: string;}
export const currentUser: Writable<User | null> = writable(null);
// Тип для кастомного storeinterface CartStore extends Readable<CartItem[]> { addItem: (item: Product) => void; removeItem: (id: number) => void; clear: () => void;}Store как модуль — разделяем между компонентами 📁
Заголовок раздела «Store как модуль — разделяем между компонентами 📁»Правильный способ — вынести stores в отдельный файл:
src/├── stores/│ ├── auth.ts // authStore, currentUser│ ├── cart.ts // cartStore│ ├── ui.ts // themeStore, sidebarOpen│ └── index.ts // реэкспорт всего├── components/│ ├── Header.svelte // использует authStore, cartStore│ ├── Cart.svelte // использует cartStore│ └── UserMenu.svelte // использует authStoreimport { writable, derived } from 'svelte/store';
export const currentUser = writable<User | null>(null);
// derived store — вычисляется из другогоexport const isLoggedIn = derived(currentUser, $user => $user !== null);export const userName = derived(currentUser, $user => $user?.name ?? 'Гость');Derived Store — предварительный просмотр 🔮
Заголовок раздела «Derived Store — предварительный просмотр 🔮»derived(store, fn) создаёт Store, вычисляемый на основе другого. Подробно разберём в следующем уроке, но вот быстрый пример:
import { writable, derived } from 'svelte/store';
const cartItems = writable([ { name: 'Книга', price: 500, qty: 2 }, { name: 'Кружка', price: 300, qty: 1 },]);
// Автоматически пересчитывается при изменении cartItemsconst totalPrice = derived(cartItems, $items => $items.reduce((sum, item) => sum + item.price * item.qty, 0));
const itemCount = derived(cartItems, $items => $items.reduce((sum, item) => sum + item.qty, 0));<p>Товаров: {$itemCount}</p><p>Итого: {$totalPrice} ₽</p>Реактивные выражения со Store 🔗
Заголовок раздела «Реактивные выражения со Store 🔗»Store отлично сочетаются с реактивными блоками $::
<script> import { cartItems, promoCode } from './stores.js';
// Реактивно пересчитывается при изменении $cartItems или $promoCode $: subtotal = $cartItems.reduce((sum, i) => sum + i.price * i.qty, 0); $: discount = $promoCode === 'SVELTE30' ? 0.3 : 0; $: total = subtotal * (1 - discount);
$: if ($cartItems.length > 10) { console.log('Богатый покупатель!', $cartItems.length, 'товаров'); }</script>
<p>Подытог: {subtotal} ₽</p><p>Скидка: {discount * 100}%</p><p>Итого: {total} ₽</p>Когда использовать Store, а когда — props/context? 🤔
Заголовок раздела «Когда использовать Store, а когда — props/context? 🤔»| Подход | Когда использовать |
|---|---|
| Props | Данные от родителя к дочернему компоненту |
| Store | Общие данные между несвязанными компонентами |
| Context | Данные для поддерева компонентов (без глобальности) |
| URL/params | Данные, которые должны быть в адресной строке |
✅ Store — правильный выбор: - Авторизованный пользователь (нужен везде) - Корзина покупок (Header + Cart + OrderPage) - Тема оформления (весь UI) - Уведомления (глобальная очередь)
✅ Props — правильный выбор: - Список товаров передаётся в <ProductGrid> - Цвет кнопки передаётся в <Button> - Данные пагинации в <Table>
✅ Context — правильный выбор: - Конфигурация формы для всех полей внутри <Form> - Тема для виджета (не всего сайта)Сравнение с другими инструментами 🔄
Заголовок раздела «Сравнение с другими инструментами 🔄»| Svelte Store | React Context | Zustand | Vue Pinia | Redux | |
|---|---|---|---|---|---|
| Размер | ~0kb (встроен) | ~0kb (встроен) | ~1kb | ~1kb | ~40kb |
| Синтаксис | $store | useContext | useStore | store.count | useSelector |
| Шаблонный код | Минимум | Немного | Немного | Средне | Много |
| DevTools | ✅ | ⚠️ | ✅ | ✅ | ✅ |
| TypeScript | ✅ | ✅ | ✅ | ✅ | ✅ |
| Async | Ручной | Ручной | Встроен | Встроен | Redux Thunk |
Svelte Store выигрывает по простоте — $count++ vs dispatch(increment()) в Redux! 😄
Svelte 5: Store остаются, но есть $state 🆕
Заголовок раздела «Svelte 5: Store остаются, но есть $state 🆕»В Svelte 5 появились runes — более мощная система реактивности. Stores не исчезают, но для локального состояния рекомендуется $state:
<!-- Svelte 5 --><script> // Локальное состояние компонента let count = $state(0);
// Для глобального состояния — Stores по-прежнему актуальны! import { cart } from './stores.js';</script>// Svelte 5: universal reactivity (без компонентов)import { writable } from 'svelte/store';
// Stores работают везде — в .svelte, .js, .ts файлахexport const theme = writable('dark');Практические примеры 🛠️
Заголовок раздела «Практические примеры 🛠️»Auth Store с persistence:
Заголовок раздела «Auth Store с persistence:»import { writable } from 'svelte/store';
function createAuthStore() { // Читаем из localStorage при старте const saved = localStorage.getItem('user'); const initial = saved ? JSON.parse(saved) : null;
const { subscribe, set, update } = writable(initial);
return { subscribe,
login(user: User) { localStorage.setItem('user', JSON.stringify(user)); set(user); },
logout() { localStorage.removeItem('user'); set(null); },
updateProfile(changes: Partial<User>) { update(user => { const updated = { ...user!, ...changes }; localStorage.setItem('user', JSON.stringify(updated)); return updated; }); }, };}
export const auth = createAuthStore();Notification Store:
Заголовок раздела «Notification Store:»import { writable } from 'svelte/store';
export type NotificationType = 'success' | 'error' | 'info' | 'warning';
export interface Notification { id: number; type: NotificationType; message: string;}
let nextId = 0;
function createNotificationStore() { const { subscribe, update } = writable<Notification[]>([]);
return { subscribe,
add(type: NotificationType, message: string, duration = 4000) { const id = nextId++; update(list => [...list, { id, type, message }]);
// Автоудаление через duration мс setTimeout(() => this.remove(id), duration); },
remove(id: number) { update(list => list.filter(n => n.id !== id)); },
success: (msg: string) => this.add('success', msg), error: (msg: string) => this.add('error', msg), };}
export const notifications = createNotificationStore();Итоги урока 🎯
Заголовок раздела «Итоги урока 🎯»| Концепция | Суть |
|---|---|
writable(val) | Store с set/update/subscribe |
readable(val, start) | Store только для чтения с producer |
$store | Автоподписка и автоотписка |
$store = value | Компилируется в store.set(value) |
| Кастомный Store | Обёртка над writable с методами |
| Контракт Store | Любой объект с subscribe(fn) |
| Derived (preview) | Вычисляемый из другого Store |
Интерактивный Playground 🎮
Заголовок раздела «Интерактивный Playground 🎮»Несколько виртуальных “компонентов” разделяют один Store. Когда ты меняешь значение в любом из них — все остальные обновляются мгновенно. Плюс: живые часы на readable store и корзина на кастомном store!