27. batch, untrack и createRoot
🔧 batch, untrack и createRoot
Заголовок раздела «🔧 batch, untrack и createRoot»Привет! 👋 Это продвинутый урок о низкоуровневых примитивах Solid.js, которые дают тебе точный контроль над реактивностью: когда обновления объединять, когда не подписываться, и как работает модель владения (ownership model).
Думай об этих примитивах как о педалях в машине: большинство времени ты просто едешь (createSignal/createEffect), но иногда нужно нажать на сцепление (untrack), газ с тормозом одновременно (batch) или перезапустить двигатель (createRoot).
🔄 batch(): группировка обновлений
Заголовок раздела «🔄 batch(): группировка обновлений»По умолчанию каждое изменение сигнала немедленно уведомляет все зависимые компоненты. batch() откладывает все уведомления до конца блока:
import { createSignal, createMemo, createEffect, batch } from 'solid-js';
const [firstName, setFirstName] = createSignal('Яша');const [lastName, setLastName] = createSignal('Иванов');const fullName = createMemo(() => `${firstName()} ${lastName()}`);
createEffect(() => { console.log('fullName изменился:', fullName());});
// ❌ БЕЗ batch — два отдельных уведомленияsetFirstName('Максим'); // → эффект запускается: "Максим Иванов"setLastName('Петров'); // → эффект запускается: "Максим Петров"// Итог: 2 обновления DOM (промежуточное состояние "Максим Иванов" мелькнуло)
// ✅ С batch — одно уведомлениеbatch(() => { setFirstName('Максим'); // ← не уведомляет setLastName('Петров'); // ← не уведомляет});// После batch: эффект запускается ОДИН РАЗ: "Максим Петров"// Итог: 1 обновление DOMРеальные случаи использования batch
Заголовок раздела «Реальные случаи использования batch»// Пример 1: Обновление нескольких связанных полей формыfunction resetForm() { batch(() => { setName(''); setEmail(''); setPhone(''); setErrors({}); setTouched({}); setSubmitting(false); }); // Один рендер вместо 6!}
// Пример 2: Загрузка данных с сервераasync function fetchUserData(id: number) { const data = await api.getUser(id); batch(() => { setUser(data.user); setPermissions(data.permissions); setPreferences(data.preferences); setLoading(false); setError(null); });}
// Пример 3: Оптимистичное обновлениеfunction optimisticToggle(itemId: number) { batch(() => { setItems(items => items.map(i => i.id === itemId ? { ...i, done: !i.done } : i )); setLastUpdated(Date.now()); setPendingCount(n => n + 1); });}
// Важно: batch — синхронный! Await внутри не работаетbatch(async () => { setLoading(true); await fetch('/api/data'); // ← batch закончится ДО await! setData(result); // ← это обновление уже вне batch});🙈 untrack(): чтение без подписки
Заголовок раздела «🙈 untrack(): чтение без подписки»untrack() позволяет читать сигнал внутри реактивного контекста, не создавая зависимости:
import { createSignal, createEffect, untrack } from 'solid-js';
const [count, setCount] = createSignal(0);const [logEnabled, setLogEnabled] = createSignal(true);
// ❌ Оба сигнала создают зависимостьcreateEffect(() => { if (logEnabled()) { console.log('Счётчик:', count()); // Запускается при изменении ОБОИХ }});
// ✅ Читаем count() без подпискиcreateEffect(() => { if (logEnabled()) { // ← зависимость от logEnabled // count() читается, но эффект НЕ зависит от него console.log('Счётчик:', untrack(() => count())); }});// Теперь эффект запустится только при изменении logEnabled!
// Ещё пример: снимок значенияcreateEffect(() => { const current = count(); // ← зависимость: эффект запустится при изменении const snapshot = untrack(() => expensiveSignal()); // ← читаем без подписки console.log('Изменился count:', current, 'snapshot:', snapshot);});Когда untrack спасает от бесконечных циклов
Заголовок раздела «Когда untrack спасает от бесконечных циклов»const [a, setA] = createSignal(0);const [b, setB] = createSignal(0);
// ❌ Потенциальный бесконечный цикл!createEffect(() => { setB(a() + 1); // Читаем a — зависимость // Если b тоже влияет на a — будет цикл});
// ✅ Читаем предыдущее значение b без зависимостиcreateEffect(() => { const newB = a() + untrack(() => b()); // b не создаёт зависимость setB(newB);});
// Реальный случай: счётчик без подписки на себяconst [history, setHistory] = createSignal<number[]>([]);const [count, setCount] = createSignal(0);
createEffect(() => { const c = count(); // ← зависимость setHistory(untrack(() => [...history(), c])); // ← history без зависимости});🌳 Модель владения (Ownership Model)
Заголовок раздела «🌳 Модель владения (Ownership Model)»Solid использует дерево владения для автоматической очистки ресурсов:
Дерево владения: Родительский компонент (Owner) ├── createSignal → уничтожается вместе с родителем ├── createEffect → уничтожается вместе с родителем │ ├── createSignal (дочерний) → уничтожается когда эффект перезапускается │ └── onCleanup → вызывается при перезапуске или уничтожении └── Дочерний компонент (Child Owner) └── createEffect → уничтожается когда дочерний компонент удалёнimport { createRoot, getOwner, runWithOwner } from 'solid-js';
// getOwner — получить текущего владельцаfunction createManagedEffect() { const owner = getOwner(); // Получаем текущий owner
// Создаём эффект в другом контексте, но с нашим владельцем runWithOwner(owner, () => { createEffect(() => { // Этот эффект будет уничтожен вместе с owner console.log('Управляемый эффект'); }); });}🔓 createRoot(): вычисления без владельца
Заголовок раздела «🔓 createRoot(): вычисления без владельца»createRoot создаёт независимое реактивное дерево, которое не привязано ни к какому компоненту и не уничтожается автоматически:
import { createRoot, createSignal, createEffect } from 'solid-js';
// Случай 1: Синглтон-стор (живёт всё время приложения)const { count, increment } = createRoot(() => { const [count, setCount] = createSignal(0); return { count, increment: () => setCount(n => n + 1), };});// Этот реактивный контекст живёт вечно — никто им не владеет
// Случай 2: Управляемое время жизниconst dispose = createRoot((dispose) => { const [data, setData] = createSignal(null);
createEffect(() => { console.log('data:', data()); });
// Возвращаем функцию очистки return dispose;});
// Позже — явно уничтожаемsetTimeout(() => { dispose(); // Все сигналы и эффекты внутри уничтожены}, 5000);
// Случай 3: Тестирование сигналов вне компонентовfunction testSignal() { createRoot(() => { const [count, setCount] = createSignal(0); const doubled = createMemo(() => count() * 2);
setCount(5); console.assert(doubled() === 10, 'doubled должен быть 10'); });}🏃 runWithOwner() и getOwner()
Заголовок раздела «🏃 runWithOwner() и getOwner()»Позволяют создавать реактивные вычисления в произвольном контексте владения:
import { createSignal, createEffect, getOwner, runWithOwner } from 'solid-js';
// Паттерн: отложенное создание эффектаfunction ComponentWithDeferredEffect() { const [count, setCount] = createSignal(0); const owner = getOwner(); // Сохраняем владельца
// Создаём эффект после setTimeout (нет активного owner!) setTimeout(() => { runWithOwner(owner!, () => { // Этот эффект привязан к компоненту, а не к setTimeout createEffect(() => { console.log('count из defer:', count()); }); }); }, 100);
return <button onClick={() => setCount(n => n + 1)}>+</button>;}
// Паттерн: фабрика реактивных объектовfunction createReactiveResource<T>(fetchFn: () => Promise<T>) { const owner = getOwner()!; let dispose: () => void;
const { signal, cleanup } = createRoot((d) => { dispose = d; const [data, setData] = createSignal<T | null>(null); runWithOwner(owner, () => { createEffect(() => { fetchFn().then(setData); }); }); return { signal: data, cleanup: d }; });
return signal;}🚨 catchError() и onError(): обработка ошибок в эффектах
Заголовок раздела «🚨 catchError() и onError(): обработка ошибок в эффектах»import { createEffect } from 'solid-js';// catchError из 'solid-js'
// Обработка ошибок в синхронных реактивных вычисленияхimport { catchError } from 'solid-js';
function SafeComponent() { const [data] = createResource(fetchData);
const result = catchError( () => processData(data()), // Потенциально выбрасывает ошибку (error) => { console.error('Ошибка в реактивном вычислении:', error); return null; // Fallback значение } );
return <div>{result()}</div>;}
// onError: обработчик ошибок в дереве компонентовimport { onError } from 'solid-js';
function ParentComponent() { // Ловим ошибки от ВСЕХ дочерних компонентов onError((error) => { console.error('Ошибка в дочернем компоненте:', error); // Можно отправить в Sentry и т.д. });
return <ChildComponent />;}
// ErrorBoundary — декларативный вариантimport { ErrorBoundary } from 'solid-js';
function App() { return ( <ErrorBoundary fallback={(error, reset) => ( <div> <p>Ошибка: {error.message}</p> <button onClick={reset}>Попробовать снова</button> </div> )} > <RiskyComponent /> </ErrorBoundary> );}⚠️ Типичные ловушки
Заголовок раздела «⚠️ Типичные ловушки»// ❌ batch с async — не работает как ожидаетсяbatch(async () => { setA(1); await somePromise; // batch ЗАКАНЧИВАЕТСЯ здесь, уведомления отправлены setB(2); // Это уже отдельное обновление, вне batch});
// ✅ batch только для синхронного кодаasync function asyncUpdate() { const data = await somePromise; batch(() => { // ← batch после await setA(data.a); setB(data.b); });}
// ❌ untrack в неправильном местеconst doubled = createMemo(() => untrack(() => count()) * 2 // ← count не подписан! Memo никогда не обновится);
// ✅ untrack только для "вторичных" значенийcreateEffect(() => { const primary = primarySignal(); // ← зависимость const secondary = untrack(() => secondarySignal()); // ← без зависимости doSomething(primary, secondary);});
// ❌ createRoot без dispose — утечка памятиcreateRoot(() => { createEffect(() => { /* heavy computation */ }); // Нет dispose — эффект живёт вечно!});
// ✅ Сохраняй dispose для управления временем жизниlet disposeRoot: () => void;createRoot((dispose) => { disposeRoot = dispose; createEffect(() => { /* heavy computation */ });});// Позже:// disposeRoot();