28. Кастомные примитивы
Одна из самых мощных концепций Solid.js — создание собственных реактивных примитивов. Примитив — это функция, которая инкапсулирует реактивную логику и может переиспользоваться в любом компоненте так же легко, как встроенные createSignal или createEffect.
Что такое кастомный примитив?
Заголовок раздела «Что такое кастомный примитив?»Кастомный примитив — обычная функция (обычно с префиксом create или make), которая:
- Использует встроенные примитивы внутри себя
- Возвращает реактивные данные или методы управления
- Работает только внутри реактивного контекста (компонент или другой примитив)
// Простейший кастомный примитивfunction createCounter(initial = 0) { const [count, setCount] = createSignal(initial);
return { count, increment: () => setCount(c => c + 1), decrement: () => setCount(c => c - 1), reset: () => setCount(initial), };}
// Использование в компоненте — как будто встроенный примитивfunction Counter() { const { count, increment, decrement } = createCounter(10); return <button onClick={increment}>{count()}</button>;}make* vs create* — соглашения именования
Заголовок раздела «make* vs create* — соглашения именования»Библиотека @solid-primitives ввела важное соглашение:
create*— создаёт реактивные данные, должен вызываться в реактивном контексте (компонент/эффект). Жизненный цикл управляется владельцем (Owner).make*— создаёт что-то с ручным управлением жизненным циклом, можно вызвать вне контекста.
// create — привязан к жизненному циклу компонента, очищается автоматическиfunction createEventListener( target: EventTarget, event: string, handler: EventListener) { createEffect(() => { target.addEventListener(event, handler); onCleanup(() => target.removeEventListener(event, handler)); });}
// make — ручное управление, возвращает dispose-функциюfunction makeEventListener( target: EventTarget, event: string, handler: EventListener) { target.addEventListener(event, handler); return () => target.removeEventListener(event, handler); // вызывай сам!}createLocalStorage — персистентный сигнал
Заголовок раздела «createLocalStorage — персистентный сигнал»Один из самых полезных примитивов — сигнал, синхронизированный с localStorage:
function createLocalStorage<T>(key: string, initialValue: T) { const stored = localStorage.getItem(key); const initial = stored !== null ? JSON.parse(stored) : initialValue;
const [value, setValue] = createSignal<T>(initial);
// Каждый раз при изменении — сохраняем в localStorage createEffect(() => { localStorage.setItem(key, JSON.stringify(value())); });
return [value, setValue] as const;}
// Использование — как обычный createSignal, но персистентный!function Settings() { const [theme, setTheme] = createLocalStorage('theme', 'dark');
return ( <select value={theme()} onChange={e => setTheme(e.target.value)}> <option value="dark">Тёмная</option> <option value="light">Светлая</option> </select> );}createDebounce — дебаунс-примитив
Заголовок раздела «createDebounce — дебаунс-примитив»Часто нужно реагировать на изменения сигнала не сразу, а с задержкой:
function createDebounce<T>(source: Accessor<T>, delay: number): Accessor<T> { const [debounced, setDebounced] = createSignal<T>(source()); let timer: ReturnType<typeof setTimeout>;
createEffect(() => { const value = source(); // создаём подписку clearTimeout(timer); timer = setTimeout(() => setDebounced(() => value), delay); onCleanup(() => clearTimeout(timer)); });
return debounced;}
function Search() { const [query, setQuery] = createSignal(''); const debouncedQuery = createDebounce(query, 300);
// Срабатывает только через 300мс после последнего ввода createEffect(() => { if (debouncedQuery()) fetchResults(debouncedQuery()); });
return <input onInput={e => setQuery(e.target.value)} />;}createMediaQuery — реактивный медиа-запрос
Заголовок раздела «createMediaQuery — реактивный медиа-запрос»function createMediaQuery(query: string): Accessor<boolean> { const mql = window.matchMedia(query); const [matches, setMatches] = createSignal(mql.matches);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches); mql.addEventListener('change', handler); onCleanup(() => mql.removeEventListener('change', handler));
return matches;}
function ResponsiveLayout() { const isMobile = createMediaQuery('(max-width: 768px)'); const prefersReducedMotion = createMediaQuery('(prefers-reduced-motion: reduce)');
return ( <div> <Show when={isMobile()} fallback={<DesktopNav />}> <MobileNav /> </Show> </div> );}@solid-primitives — официальная библиотека
Заголовок раздела «@solid-primitives — официальная библиотека»@solid-primitives — коллекция из 100+ готовых примитивов. Каждый пакет устанавливается отдельно:
npm install @solid-primitives/timernpm install @solid-primitives/storagenpm install @solid-primitives/medianpm install @solid-primitives/event-listenernpm install @solid-primitives/intersection-observernpm install @solid-primitives/fetchnpm install @solid-primitives/keyboardОбзор популярных пакетов
Заголовок раздела «Обзор популярных пакетов»| Пакет | Ключевые примитивы |
|---|---|
@solid-primitives/timer | createTimer, makeTimer, createInterval |
@solid-primitives/storage | createLocalStorage, createSessionStorage, makePersisted |
@solid-primitives/media | createMediaQuery, createBreakpoints |
@solid-primitives/fetch | createFetch с кешированием и повторами |
@solid-primitives/keyboard | createKeyHold, useKeyDownList |
@solid-primitives/scroll | createScrollPosition, useWindowScrollPosition |
@solid-primitives/intersection-observer | createIntersectionObserver, createViewportObserver |
@solid-primitives/gesture | createGesture, свайпы, зум |
Пример: createTimer
Заголовок раздела «Пример: createTimer»import { createTimer } from '@solid-primitives/timer';
function Stopwatch() { const [running, setRunning] = createSignal(false); const [elapsed, setElapsed] = createSignal(0);
// delay может быть сигналом! false = пауза createTimer( () => setElapsed(e => e + 100), () => running() ? 100 : false, setInterval );
return ( <div> <span>{(elapsed() / 1000).toFixed(1)}с</span> <button onClick={() => setRunning(r => !r)}> {running() ? 'Пауза' : 'Старт'} </button> </div> );}Композиция примитивов
Заголовок раздела «Композиция примитивов»Настоящая сила — в том, что примитивы можно компоновать:
// Примитив высшего уровня, использующий другие примитивыfunction createPersistentDebounced<T>( key: string, initial: T, delay = 500) { const [value, setValue] = createLocalStorage(key, initial); const debounced = createDebounce(value, delay); const isDesktop = createMediaQuery('(min-width: 1024px)');
return { value, debounced, isDesktop, setValue, };}Типизация примитивов
Заголовок раздела «Типизация примитивов»Хорошо типизированный примитив на порядок улучшает DX. Используй Accessor, Setter и дженерики:
import type { Accessor, Setter } from 'solid-js';
// Tuple как у createSignal — удобно деструктурироватьfunction createToggle( initial = false): [get: Accessor<boolean>, actions: { toggle: () => void; setTrue: () => void; setFalse: () => void; set: Setter<boolean>;}] { const [value, set] = createSignal(initial);
return [value, { toggle: () => set(v => !v), setTrue: () => set(true), setFalse: () => set(false), set, }];}
// Использованиеconst [isOpen, { toggle, setFalse }] = createToggle();Распространённые ошибки
Заголовок раздела «Распространённые ошибки»// ❌ Вызов createSignal вне реактивного контекстаconst counter = createCounter(); // Error: Calling signal outside owner
// ✅ Внутри компонентаfunction App() { const counter = createCounter(); // OK}
// ❌ Утечка памяти — нет onCleanup!function createBadListener(el: Element, event: string, fn: EventListener) { createEffect(() => { el.addEventListener(event, fn); // Забыли onCleanup — слушатель не удаляется при размонтировании! });}
// ✅ Правильно — всегда убираем за собойfunction createGoodListener(el: Element, event: string, fn: EventListener) { createEffect(() => { el.addEventListener(event, fn); onCleanup(() => el.removeEventListener(event, fn)); });}Интерактивный пример
Заголовок раздела «Интерактивный пример»Три кастомных примитива в действии — useLocalStorage, useDebounce, useMediaQuery: