10. Derived и кастомные Stores
🔄 Derived и Custom Stores
Заголовок раздела «🔄 Derived и Custom Stores»Если writable — это «ящик с данными», то derived — это вычислительная машина, которая автоматически пересчитывает значение, когда исходные данные меняются. Думай о derived как о формуле в Excel: ты вводишь числа в ячейки, а формула пересчитывает результат сама 🧮
Зачем нужны derived stores?
Заголовок раздела «Зачем нужны derived stores?»Представь: у тебя есть список задач и ты хочешь показать количество выполненных. Можно хранить отдельный счётчик и обновлять его вручную… или просто вычислять из основного списка:
Без derived: С derived:let todos = writable([...]) const todos = writable([...])let completed = writable(0) const completed = derived( todos,// Не забудь обновить! t => t.filter(t => t.done).lengthcompleted.set(newCount) ) // Автоматически!derived даёт синхронизацию бесплатно — никаких ручных обновлений!
Базовый derived store
Заголовок раздела «Базовый derived store»import { writable, derived } from 'svelte/store'
// Исходный storeconst count = writable(0)
// Производный store — удваивает значениеconst doubled = derived(count, $count => $count * 2)
// Сколько угодно производных от одного источника!const tripled = derived(count, $count => $count * 3)const isEven = derived(count, $count => $count % 2 === 0)const absolute = derived(count, $count => Math.abs($count))
// В компоненте — подписываемся как обычно// $doubled, $tripled, $isEven обновляются автоматически!<script> import { writable, derived } from 'svelte/store'
const temperature = writable(0) // Цельсий
const fahrenheit = derived( temperature, $temp => ($temp * 9) / 5 + 32 )
const kelvin = derived( temperature, $temp => $temp + 273.15 )</script>
<input type="number" bind:value={$temperature} /><p>Цельсий: {$temperature}°C</p><p>Фаренгейт: {$fahrenheit.toFixed(1)}°F</p><p>Кельвин: {$kelvin.toFixed(2)}K</p>Derived с начальным значением
Заголовок раздела «Derived с начальным значением»Если вычисление синхронное — первое значение появляется сразу. Но можно явно задать начальное:
const count = writable(5)
// Без начального значения — derived начинает с undefined пока не вычислитсяconst doubled = derived(count, $count => $count * 2)
// С явным начальным значениемconst doubledWithInit = derived( count, ($count) => $count * 2, 0 // начальное значение — третий аргумент)Derived из нескольких stores 🔗
Заголовок раздела «Derived из нескольких stores 🔗»Вот где начинается магия! derived может принимать массив stores:
import { writable, derived } from 'svelte/store'
const firstName = writable('Яша')const lastName = writable('Иванов')const age = writable(10)
// Берём из нескольких источников!const fullName = derived( [firstName, lastName], ([$firstName, $lastName]) => `${$firstName} ${$lastName}`)
const greeting = derived( [firstName, age], ([$firstName, $age]) => { if ($age < 18) return `Привет, ${$firstName}! 👋` return `Добро пожаловать, ${$firstName}!` })
// Можно комбинировать любые storesconst userProfile = derived( [firstName, lastName, age], ([$firstName, $lastName, $age]) => ({ fullName: `${$firstName} ${$lastName}`, isAdult: $age >= 18, initials: `${$firstName[0]}${$lastName[0]}`, }))<script> // Корзина и скидки из разных частей приложения const cartItems = writable<CartItem[]>([]) const discountPercent = writable(0) const promoCode = writable<string | null>(null)
const cartSummary = derived( [cartItems, discountPercent, promoCode], ([$items, $discount, $promo]) => { const subtotal = $items.reduce((sum, item) => sum + item.price * item.qty, 0) const discountAmount = subtotal * ($discount / 100) const promoDiscount = $promo === 'YASHA10' ? subtotal * 0.1 : 0 const total = subtotal - discountAmount - promoDiscount
return { subtotal, discountAmount, promoDiscount, total, itemCount: $items.reduce((sum, item) => sum + item.qty, 0), } } )</script>
<p>Товаров: {$cartSummary.itemCount}</p><p>Итого: {$cartSummary.total.toFixed(2)} ₽</p>Async Derived — производный с set 🕐
Заголовок раздела «Async Derived — производный с set 🕐»Иногда вычисление производного значения требует асинхронного запроса. Для этого в derived предусмотрен callback с set:
import { writable, derived } from 'svelte/store'
const userId = writable<number>(1)
// Async derived: вторая функция получает (stores, set) вместо просто storesconst userProfile = derived( userId, ($userId, set) => { // Немедленно устанавливаем состояние загрузки set({ loading: true, data: null, error: null })
const controller = new AbortController()
fetch(`https://jsonplaceholder.typicode.com/users/${$userId}`, { signal: controller.signal, }) .then(res => res.json()) .then(data => set({ loading: false, data, error: null })) .catch(err => { if (err.name !== 'AbortError') { set({ loading: false, data: null, error: err.message }) } })
// Функция очистки — вызывается при смене userId! return () => controller.abort() }, { loading: true, data: null, error: null } // начальное значение)<script> import { writable, derived } from 'svelte/store'
const searchQuery = writable('')
const searchResults = derived( searchQuery, ($query, set) => { if (!$query.trim()) { set([]) return }
const timer = setTimeout(() => { fetch(`/api/search?q=${encodeURIComponent($query)}`) .then(r => r.json()) .then(results => set(results)) .catch(() => set([])) }, 300) // debounce встроенный!
// Очистка: отменяем таймер если query изменился return () => clearTimeout(timer) }, [] as SearchResult[] )</script>
<input bind:value={$searchQuery} placeholder="Поиск..." />{#each $searchResults as result} <li>{result.title}</li>{/each}Custom Stores — создаём свои хранилища 🏗️
Заголовок раздела «Custom Stores — создаём свои хранилища 🏗️»Custom store — это объект с методом subscribe (обязательно!) плюс любые методы, которые ты захочешь. Svelte автоматически определяет store по наличию subscribe:
import { writable } from 'svelte/store'import type { Writable } from 'svelte/store'
// Простой custom store — обёртка над writable с ограниченным APIfunction createCounter(initialValue = 0) { const { subscribe, set, update } = writable(initialValue)
return { subscribe, // ОБЯЗАТЕЛЬНО! Иначе не store increment: () => update(n => n + 1), decrement: () => update(n => n - 1), reset: () => set(0), setValue: (n: number) => set(n), }}
// Использованиеconst counter = createCounter(10)// $counter — реактивно!// counter.increment() — встроенный метод// НЕТ прямого counter.set() — API ограничен!<script> const counter = createCounter(0)</script>
<button on:click={counter.decrement}>−</button><span>{$counter}</span><button on:click={counter.increment}>+</button><button on:click={counter.reset}>Reset</button>Custom Store с TypeScript
Заголовок раздела «Custom Store с TypeScript»import { writable } from 'svelte/store'
interface Todo { id: number text: string done: boolean}
function createTodoStore() { const { subscribe, update, set } = writable<Todo[]>([]) let nextId = 1
return { subscribe,
add(text: string) { update(todos => [...todos, { id: nextId++, text, done: false }]) },
toggle(id: number) { update(todos => todos.map(todo => todo.id === id ? { ...todo, done: !todo.done } : todo ) ) },
remove(id: number) { update(todos => todos.filter(todo => todo.id !== id)) },
clear() { set([]) },
clearCompleted() { update(todos => todos.filter(todo => !todo.done)) }, }}
export const todos = createTodoStore()Паттерн Undo/Redo Store 🕰️
Заголовок раздела «Паттерн Undo/Redo Store 🕰️»import { writable, derived } from 'svelte/store'
function createUndoable<T>(initialValue: T) { const history = writable<T[]>([initialValue]) const cursor = writable(0) // текущая позиция в истории
// Текущее значение — производное от истории и курсора const current = derived( [history, cursor], ([$history, $cursor]) => $history[$cursor] )
const canUndo = derived(cursor, $cursor => $cursor > 0) const canRedo = derived( [history, cursor], ([$history, $cursor]) => $cursor < $history.length - 1 )
return { subscribe: current.subscribe, canUndo: canUndo, canRedo: canRedo,
set(value: T) { history.update($history => { let $cursor = 0 cursor.update(c => { $cursor = c; return c; })
// Обрезаем будущее (как в любом редакторе) const newHistory = [...$history.slice(0, $cursor + 1), value] cursor.set(newHistory.length - 1) return newHistory }) },
undo() { cursor.update(c => Math.max(0, c - 1)) },
redo() { history.update($history => { cursor.update(c => Math.min($history.length - 1, c + 1)) return $history }) }, }}
// Использованиеconst text = createUndoable('')// $text — текущий текст// text.set('новый') — изменить с записью в историю// text.undo() — отменить// text.redo() — повторить// $text.canUndo — можно ли отменить?Паттерн Pagination Store 📄
Заголовок раздела «Паттерн Pagination Store 📄»function createPaginationStore(totalItems: number, itemsPerPage = 10) { const { subscribe, update, set } = writable({ currentPage: 1, itemsPerPage, totalItems, })
const pagination = { subscribe,
// Производные вычисления get totalPages() { let state = { currentPage: 1, itemsPerPage, totalItems } subscribe(s => { state = s })() return Math.ceil(state.totalItems / state.itemsPerPage) },
setPage(page: number) { update(s => ({ ...s, currentPage: Math.max(1, Math.min(page, Math.ceil(s.totalItems / s.itemsPerPage))), })) },
nextPage() { this.setPage(this.page + 1) }, prevPage() { this.setPage(this.page - 1) },
setTotalItems(total: number) { update(s => ({ ...s, totalItems: total, currentPage: 1 })) },
getRange() { let state = { currentPage: 1, itemsPerPage, totalItems } subscribe(s => { state = s })() const start = (state.currentPage - 1) * state.itemsPerPage const end = Math.min(start + state.itemsPerPage, state.totalItems) return { start, end } }, }
return pagination}Паттерн Form Store 📝
Заголовок раздела «Паттерн Form Store 📝»import { writable, derived } from 'svelte/store'
interface FormField { value: string error: string | null touched: boolean}
type FormSchema<T extends Record<string, unknown>> = { [K in keyof T]: { initial: string validate: (value: string) => string | null }}
function createFormStore<T extends Record<string, string>>( schema: FormSchema<T>) { const keys = Object.keys(schema) as (keyof T)[]
const fields = writable<Record<keyof T, FormField>>( Object.fromEntries( keys.map(key => [ key, { value: schema[key].initial, error: null, touched: false }, ]) ) as Record<keyof T, FormField> )
const isValid = derived(fields, $fields => keys.every(key => { const field = $fields[key] const error = schema[key].validate(field.value) return error === null }) )
const isDirty = derived(fields, $fields => keys.some(key => $fields[key].value !== schema[key].initial) )
return { fields, isValid, isDirty,
setValue(key: keyof T, value: string) { fields.update($fields => ({ ...$fields, [key]: { ...$fields[key], value, touched: true, error: schema[key].validate(value), }, })) },
touch(key: keyof T) { fields.update($fields => ({ ...$fields, [key]: { ...$fields[key], touched: true, error: schema[key].validate($fields[key].value), }, })) },
reset() { fields.set( Object.fromEntries( keys.map(key => [ key, { value: schema[key].initial, error: null, touched: false }, ]) ) as Record<keyof T, FormField> ) },
getValues() { let result = {} as T fields.subscribe($fields => { result = Object.fromEntries( keys.map(key => [key, $fields[key].value]) ) as T })() return result }, }}
// Использование!const loginForm = createFormStore({ email: { initial: '', validate: (v) => v.includes('@') ? null : 'Некорректный email', }, password: { initial: '', validate: (v) => v.length >= 6 ? null : 'Минимум 6 символов', },})Readable Store — только для чтения
Заголовок раздела «Readable Store — только для чтения»Когда данные должны меняться только внутри (внешний код не может set):
import { readable } from 'svelte/store'
// Текущее время — меняется само, нельзя изменить снаружиconst time = readable(new Date(), function start(set) { const interval = setInterval(() => { set(new Date()) }, 1000)
// Функция очистки return function stop() { clearInterval(interval) }})
// Состояние геолокацииconst geolocation = readable<GeolocationPosition | null>(null, set => { const id = navigator.geolocation.watchPosition( position => set(position), () => set(null) ) return () => navigator.geolocation.clearWatch(id)})Дерево решений: какой store выбрать? 🌳
Заголовок раздела «Дерево решений: какой store выбрать? 🌳»Тебе нужно хранить данные?│├─ Данные меняются СНАРУЖИ компонента?│ ├─ ДА → writable()│ └─ НЕТ → readable()│├─ Данные ВЫЧИСЛЯЮТСЯ из других stores?│ └─ derived()│├─ Нужны КАСТОМНЫЕ МЕТОДЫ (add, remove, toggle)?│ └─ createCustomStore() — фабричная функция│└─ Данные нужны только ВНУТРИ компонента? └─ Просто let в <script> — это реактивно в Svelte!
Подсказка: Начни с writable. Если: - Нужно вычислять → derived - Нужны методы → custom store - Нельзя изменить снаружи → readable - Только в компоненте → let (Svelte реактивность)Паттерн: Store как синглтон vs фабрика
Заголовок раздела «Паттерн: Store как синглтон vs фабрика»// ❌ Синглтон — одно состояние на всё приложениеexport const counter = writable(0) // Один экземпляр на всех!
// ✅ Фабрика — каждый компонент получает свой экземпляр// stores/counter.tsexport function createCounter(initial = 0) { return writable(initial)}
// В компоненте:// const myCounter = createCounter(5) // Свой счётчик!Подписка вне компонента
Заголовок раздела «Подписка вне компонента»import { count } from './stores'
// Подписка с немедленным вызовомconst unsubscribe = count.subscribe(value => { console.log('count изменился:', value)})
// Не забудь отписаться! (Svelte делает это автоматически в компоненте)unsubscribe()
// Получить текущее значение БЕЗ подпискиlet currentValue: numberconst unsub = count.subscribe(v => currentValue = v)unsub() // Отписываемся сразу// currentValue теперь содержит текущее значениеStores в TypeScript: типизация
Заголовок раздела «Stores в TypeScript: типизация»import { writable, derived, readable } from 'svelte/store'import type { Readable, Writable } from 'svelte/store'
// Явная типизацияconst count: Writable<number> = writable(0)const name: Writable<string> = writable('')const user: Writable<User | null> = writable(null)
// Typed custom storeinterface CounterStore extends Readable<number> { increment: () => void decrement: () => void reset: () => void}
function createTypedCounter(initial = 0): CounterStore { const { subscribe, update, set } = writable(initial) return { subscribe, increment: () => update(n => n + 1), decrement: () => update(n => n - 1), reset: () => set(0), }}