68. Effector: Продвинутый уровень
Effector: Продвинутый уровень — оркестрация и масштабирование
Заголовок раздела «Effector: Продвинутый уровень — оркестрация и масштабирование»Ты освоил базу Effector. Теперь погружаемся в продвинутые паттерны — это те инструменты, которые превращают хорошее приложение в отличное: быстрое, предсказуемое и лёгкое для тестирования 🏆
Представь sample как умного диспетчера аэропорта ✈️: он знает, когда самолёт готов к вылету (clock), какой груз взять (source), как его упаковать (fn) и куда отправить (target).
sample() — главный оператор Effector
Заголовок раздела «sample() — главный оператор Effector»sample — это сердце реактивной логики. Он забирает данные из source в момент срабатывания clock и передаёт в target:
import { sample } from 'effector';
// Полная форма:sample({ clock: trigger, // когда срабатывать (Event | Store | Effect | массив) source: $data, // откуда брать данные (Store | Event | объект сторов) filter: $condition, // условие (Store<boolean> | функция-предикат) fn: (data, clockPayload) => transform(data), // трансформация target: output, // куда отправить (Event | Effect | Store | массив)});
// Минимальная форма (clock === source):sample({ clock: buttonClicked, target: submitFx });
// Забрать значение стора при событии:sample({ clock: submitClicked, source: $formData, target: submitFormFx,});
// С трансформацией:sample({ clock: userTyped, source: { query: $query, filters: $filters }, fn: ({ query, filters }) => ({ q: query.trim(), ...filters }), target: searchFx,});sample с массивом источников и целей
Заголовок раздела «sample с массивом источников и целей»// Несколько источников объединяются в объектsample({ clock: pageOpened, source: { userId: $userId, token: $token }, fn: ({ userId, token }) => ({ userId, headers: { Authorization: token } }), target: fetchUserFx,});
// Несколько целей — отправить в несколько мест одновременноsample({ clock: loginSuccess, source: $userData, target: [$user, persistToStorageFx, analyticsTrackFx], // рассылаем всем});filter — условный sample
Заголовок раздела «filter — условный sample»// filter как Store<boolean>sample({ clock: addToCartClicked, source: $product, filter: $isLoggedIn, // выполнится только если пользователь залогинен target: addToCartFx,});
// filter как предикат-функцияsample({ clock: priceChanged, source: $price, filter: (price) => price > 0, // выполнится только при положительной цене target: updatePriceFx,});
// Типизированный guard через filtersample({ clock: itemSelected, source: $selectedItem, filter: (item): item is NonNullable<typeof item> => item !== null, target: editItemFx,});attach() — фабрика эффектов с привязкой к данным
Заголовок раздела «attach() — фабрика эффектов с привязкой к данным»attach создаёт новый эффект, который автоматически получает данные из стора:
import { attach } from 'effector';
const $token = createStore<string>('');
// Базовый эффект (без авторизации)const baseRequestFx = createEffect(async ({ url, token }: { url: string; token: string }) => { const res = await fetch(url, { headers: { Authorization: \`Bearer \${token}\` } }); return res.json();});
// attach: привязываем $token к baseRequestFxconst fetchProfileFx = attach({ source: $token, effect: baseRequestFx, mapParams: (url: string, token) => ({ url, token }), // склеиваем параметры});
// Теперь fetchProfileFx принимает только url — token берётся автоматически!fetchProfileFx('/api/profile');split() — маршрутизация событий
Заголовок раздела «split() — маршрутизация событий»import { split } from 'effector';
const statusChanged = createEvent<'success' | 'error' | 'pending'>();
// split разделяет один поток на несколько по условиямconst { successStatus, errorStatus, pendingStatus } = split(statusChanged, { successStatus: (s) => s === 'success', errorStatus: (s) => s === 'error', pendingStatus: (s) => s === 'pending',});
// Каждый поток — самостоятельное событие$successMessages.on(successStatus, (msgs) => [...msgs, 'Операция выполнена!']);$errorMessages.on(errorStatus, (msgs) => [...msgs, 'Произошла ошибка!']);Domain — группировка юнитов
Заголовок раздела «Domain — группировка юнитов»Domain позволяет группировать юниты и управлять ими централизованно (например, для DevTools или тестов):
import { createDomain } from 'effector';
const authDomain = createDomain('auth');
// Создаём юниты через доменconst $user = authDomain.createStore<User | null>(null);const login = authDomain.createEvent<Credentials>();const logout = authDomain.createEvent();const loginFx = authDomain.createEffect(async (creds: Credentials) => { return api.login(creds);});
// Домен позволяет отслеживать все юниты внутриauthDomain.onCreateStore((store) => { console.log('Новый стор в домене:', store.shortName);});Fork API — SSR и тестирование
Заголовок раздела «Fork API — SSR и тестирование»Fork API — главное преимущество Effector для SSR и изолированного тестирования:
import { fork, allSettled, serialize, hydrate } from 'effector';
// --- SSR (сервер) ---async function renderPage(userId: number) { // Создаём изолированный scope — не трогаем глобальное состояние! const scope = fork();
// Выполняем эффекты в изолированном scope await allSettled(fetchUserFx, { scope, params: userId });
// Сериализуем для передачи клиенту const data = serialize(scope); // => { "$user": {...}, "$settings": {...} }
return { data };}
// --- SSR (клиент) ---const scope = fork({ values: window.__EFFECTOR_DATA__ });
// --- Тестирование ---describe('counter model', () => { it('should increment', async () => { const scope = fork({ values: [[$count, 5]], // начальное значение стора });
await allSettled(incremented, { scope });
expect(scope.getState($count)).toBe(6); // проверяем стор в scope });
it('should load user', async () => { const scope = fork({ handlers: [ // мокируем эффекты [fetchUserFx, async () => ({ id: 1, name: 'Test User' })], ], });
await allSettled(pageOpened, { scope });
expect(scope.getState($user)).toEqual({ id: 1, name: 'Test User' }); });});scopeBind() — привязка к scope вне React
Заголовок раздела «scopeBind() — привязка к scope вне React»import { scopeBind } from 'effector';
// Вне React (например, в WebSocket обработчике)const scope = fork();
// Привязываем событие к конкретному scopeconst boundIncrement = scopeBind(incremented, { scope });
// Теперь boundIncrement() работает в изоляции scopesocket.on('tick', () => boundIncrement());Patronum — утилиты для Effector
Заголовок раздела «Patronum — утилиты для Effector»import { debounce, throttle, every, some, condition, reset } from 'patronum';
// Дебаунс событияconst debouncedSearch = debounce({ source: searchTyped, timeout: 300 });
// Составной filter: оба условия должны быть trueconst $canSubmit = every({ stores: [$nameValid, $emailValid, $ageValid], predicate: Boolean });
// Условие: if/else для юнитовcondition({ source: submitClicked, if: $isLoggedIn, then: submitFormFx, else: redirectToLoginFx,});
// Сброс нескольких сторов одним событиемreset({ clock: logoutClicked, target: [$user, $token, $cart, $settings] });Полная архитектура: модульный подход
Заголовок раздела «Полная архитектура: модульный подход»import { createStore, createEvent, createEffect, sample } from 'effector';
// Eventsexport const loginFormSubmitted = createEvent<LoginForm>();export const logoutClicked = createEvent();
// Effectsexport const loginFx = createEffect(api.login);export const logoutFx = createEffect(api.logout);
// Storesexport const $user = createStore<User | null>(null);export const $authToken = createStore<string | null>(null);export const $isAuth = $user.map(Boolean);
// Logic$user .on(loginFx.doneData, (_, { user }) => user) .reset(logoutFx.done);
$authToken .on(loginFx.doneData, (_, { token }) => token) .reset(logoutFx.done);
sample({ clock: loginFormSubmitted, target: loginFx });sample({ clock: logoutClicked, target: logoutFx });
// features/profile/model.ts — зависит от authimport { $authToken } from '../auth/model';
const fetchProfileFx = attach({ source: $authToken, effect: async (token, userId: number) => { return api.getProfile(userId, token!); },});🔗 Полезные ссылки
Заголовок раздела «🔗 Полезные ссылки»Практика
Заголовок раздела «Практика»Попробуйте примеры в интерактивном редакторе: