Сторы и их sid
Effector основан на идее атомарного стора. Это означает, что в приложении нет централизованного контроллера состояния или другой точки входа для сбора всех состояний в одном месте.
Итак, возникает вопрос — как отличать юниты между разными окружениями? Например, если мы запускаем приложение на сервере и сериализуем его состояние в JSON, как узнать, какая часть этого JSON должна быть помещена в конкретный стор на клиенте?
Давайте обсудим, как эта проблема решается другими менеджерами состояний.
Другие менеджеры состояний
Один стор
В менеджере состояний с одним стором (например, Redux) этой проблемы вообще не существует. Это один стор, который можно сериализовать и десериализовать без какой-либо дополнительной информации.
Фактически, один стор принуждает вас к созданию уникальных имен для каждой его части неявным образом. В любом объекте вы не сможете создать дублирующие ключи, так что путь к части стора — это уникальный идентификатор этой части.
// server.ts
import { createStore } from "single-store-state-manager";
function handlerRequest() {
const store = createStore({ initialValue: null });
return {
// Можно просто сериализовать весь стор
state: JSON.stringify(store.getState()),
};
}
// client.ts
import { createStore } from "single-store-state-manager";
// Предположим, что сервер поместил состояние в HTML
const serverState = readServerStateFromWindow();
const store = createStore({
// Просто парсим все состояние и используем его как состояние клиента
initialValue: JSON.parse(serverState),
});
Это здорово, что не нужно никаких дополнительных инструментов для сериализации и десериализации, но у одного стора есть несколько проблем:
- Он не поддерживает tree-shaking и code-splitting, вам все равно придется загружать весь стор
- Из-за своей архитектуры он требует дополнительных инструментов для исправления производительности (например,
reselect
) - Он не поддерживает микрофронтенды и другие вещи, которые становятся все более популярными
Множественные сторы
К сожалению, менеджеры состояний, построенные вокруг идеи множественных сторов, плохо решают эту проблему. Некоторые инструменты предлагают решения, подобные одному стору (MobX), некоторые вообще не пытаются решить эту проблему (Recoil, Zustand).
Например, общий паттерн для решения проблемы сериализации в MobX — это Root Store Pattern, который разрушает всю идею множественных сторов.
Мы рассматриваем SSR как первоклассного гражданина современных веб-приложений и собираемся поддерживать code-splitting или микрофронтенды.
Уникальные идентификаторы для каждого стора
Из-за архитектуры с множественными сторов, Effector требует уникального идентификатора для каждого стора. Это строка, которая используется для различения сторов между разными окружениями. В мире Effector такие строки называются sid
.
:::tip TL;DR
sid
— это уникальный идентификатор стора. Он используется для различения сторов между разными окружениями.
:::
Давайте добавим его в некоторые сторы:
const $name = createStore(null, { sid: "name" });
const $age = createStore(null, { sid: "age" });
Теперь мы можем сериализовать и десериализовать сторы:
// server.ts
async function handlerRequest() {
// создаем изолированный экземпляр приложения
const scope = fork();
// заполняем сторы данными
await allSettled($name, { scope, params: "Igor" });
await allSettled($age, { scope, params: 25 });
const state = JSON.serialize(serialize(scope));
// -> { "name": "Igor", "age": 25 }
return { state };
}
После этого кода у нас есть сериализованное состояние нашего приложения. Это простой объект со значениями сторов. Мы можем вернуть его обратно в сторы на клиенте:
// Предположим, что сервер поместил состояние в HTML
const serverState = readServerStateFromWindow();
const scope = fork({
// Просто парсим все состояние и используем его как состояние клиента
values: JSON.parse(serverState),
});
Конечно, написание sid
для каждого стора — это скучная работа. Effector предоставляет способ сделать это автоматически с помощью плагинов для трансформации кода.
Автоматический способ
Безусловно, создание уникальных идентификаторов вручную — это довольно скучная работа.
К счастью, существуют effector/babel-plugin
и @effector/swc-plugin
, которые автоматически создадут sid.
Поскольку инструменты трансляции кода работают на уровне файла и запускаются до этапа сборки, возможно сделать sid стабильными для каждого окружения.
Предпочтительно использовать effector/babel-plugin
или @effector/swc-plugin
вместо добавления sid вручную.
Пример кода
Обратите внимание, что здесь нет никакой центральной точки — любое событие любой “фичи” может быть вызвано из любого места, и остальные части будут реагировать соответствующим образом.
// src/features/first-name/model.ts
import { createStore, createEvent } from "effector";
export const firstNameChanged = createEvent<string>();
export const $firstName = createStore("");
$firstName.on(firstNameChanged, (_, firstName) => firstName);
// src/features/last-name/model.ts
import { createStore, createEvent } from "effector";
export const lastNameChanged = createEvent<string>();
export const $lastName = createStore("");
$lastName.on(lastNameChanged, (_, lastName) => lastName);
// src/features/form/model.ts
import { createEvent, sample, combine } from "effector";
import { $firstName, firstNameChanged } from "@/features/first-name";
import { $lastName, lastNameChanged } from "@/features/last-name";
export const formValuesFilled = createEvent<{ firstName: string; lastName: string }>();
export const $fullName = combine($firstName, $lastName, (first, last) => `${first} ${last}`);
sample({
clock: formValuesFilled,
fn: (values) => values.firstName,
target: firstNameChanged,
});
sample({
clock: formValuesFilled,
fn: (values) => values.lastName,
target: lastNameChanged,
});
Если это приложение было бы SPA или каким-либо другим клиентским приложением, на этом статья была бы закончена.
Граница сериализации
Но в случае с рендерингом на стороне сервера всегда есть граница сериализации — точка, где все состояние преобразуется в строку, добавляется в ответ сервера и отправляется в браузер клиента.
Проблема
И в этот момент нам все еще нужно собрать состояния всех сторов приложения каким-то образом!
Кроме того, после того как клиентский браузер получил страницу, нам нужно “гидрировать” все обратно: распаковать эти значения на клиенте и добавить это “серверное” состояние в клиентские экземпляры всех сторов.
Решение
Это сложная проблема, и для ее решения effector нужен способ связать “серверное” состояние какого-то стора с его клиентским экземпляром.
Хотя это можно было бы сделать путем введения “корневого стора” или чего-то подобного, что управляло бы экземплярами сторов и их состоянием за нас, это также принесло бы нам все минусы этого подхода, например, гораздо более сложный code-splitting — поэтому это все еще нежелательно.
Здесь нам очень помогут сиды. Поскольку сид, по определению, одинаков для одного и того же стора в любом окружении, effector может просто полагаться на него для обработки сериализации состояния и гидрации.
Пример
Это универсальный обработчик рендеринга на стороне сервера. Функция renderHtmlToString
— это деталь реализации, которая будет зависеть от используемого вами фреймворка.
// src/server/handler.ts
import { fork, allSettled, serialize } from "effector";
import { formValuesFilled } from "@/features/form";
async function handleServerRequest(req) {
const scope = fork(); // создает изолированный контейнер для состояния приложения
// вычисляем состояние приложения в этом scope
await allSettled(formValuesFilled, {
scope,
params: {
firstName: "John",
lastName: "Doe",
},
});
// извлекаем значения scope в простой js объект `{[storeSid]: storeState}`
const values = serialize(scope);
const serializedState = JSON.stringify(values);
return renderHtmlToString({
scripts: [
`
<script>
self._SERVER_STATE_ = ${serializedState}
</script>
`,
],
});
}
Обратите внимание, что здесь нет прямого импорта каких-либо сторов приложения. Состояние собирается автоматически, и его сериализованная версия уже содержит всю информацию, которая понадобится для гидрации.
Когда сгенерированный ответ поступает в браузер клиента, серверное состояние должно быть гидрировано в клиентские сторы. Благодаря сидам, гидрация состояния также работает автоматически:
// src/client/index.ts
import { Provider } from "effector-react";
const serverState = window._SERVER_STATE_;
const clientScope = fork({
values: serverState, // просто назначаем серверное состояние на scope
});
clientScope.getState($lastName); // "Doe"
hydrateApp(
<Provider value={clientScope}>
<App />
</Provider>,
);
На этом этапе состояние всех сторов в clientScope
такое же, как было на сервере, и для этого не потребовалось никакой ручной работы.
Уникальные sid
Стабильность sid’а обеспечивается тем, что они добавляются в код до того, как произойдет какая-либо сборка.
Но поскольку оба плагина, и babel
, и swc
, могут “видеть” содержимое только одного файла в каждый момент времени, есть случай, когда sid будут стабильными, но могут быть не уникальными.
Чтобы понять почему, нам нужно углубиться немного дальше во внутренности плагинов.
Оба плагина effector
используют один и тот же подход к трансформации кода. По сути, они делают две вещи:
- Добавляют
sid
и любую другую мета-информацию к вызовам фабрик Effector, таким какcreateStore
илиcreateEvent
. - Оборачивают любые кастомные фабрики с помощью вспомогательной функции
withFactory
, которая позволяет сделатьsid
внутренних юнитов также уникальными.
Встроенные фабрики юнитов
Рассмотрим первый случай. Для следующего исходного кода:
const $name = createStore(null);
Плагин применит следующие трансформации:
const $name = createStore(null, { sid: "j3l44" });
Плагины создают sid
как хэш от местоположения юнита в исходном коде. Это позволяет сделать sid
уникальными и стабильными.
Кастомные фабрики
Второй случай касается кастомных фабрик. Эти фабрики обычно создаются для абстрагирования какого-то общего паттерна.
Примеры кастомных фабрик:
createQuery
,createMutation
изfarfetched
debounce
,throttle
и т.д. изpatronum
- Любая кастомная фабрика в вашем коде, например фабрика сущности feature-flag
farfetched, patronum, @effector/reflect, atomic-router и @withease/factories поддерживаются по умолчанию и не требуют дополнительной настройки.
Для этого объяснения мы создадим очень простую фабрику:
// src/shared/lib/create-name/index.ts
export function createName() {
const updateName = createEvent();
const $name = createStore(null);
$name.on(updateName, (_, nextName) => nextName);
return { $name };
}
// src/feature/persons/model.ts
import { createName } from "@/shared/lib/create-name";
const personOne = createName();
const personTwo = createName();
Сначала плагин добавит sid
во внутренние сторы фабрики:
// src/shared/lib/create-name/index.ts
export function createName() {
const updateName = createEvent();
const $name = createStore(null, { sid: "ffds2" });
$name.on(updateName, (_, nextName) => nextName);
return { $name };
}
// src/feature/persons/model.ts
import { createName } from "@/shared/lib/create-name";
const personOne = createName();
const personTwo = createName();
Но этого недостаточно, потому что мы можем создать два экземпляра createName
, и внутренние сторы обоих этих экземпляров будут иметь одинаковые sid!
Эти sid будут стабильными, но не уникальными.
Чтобы исправить это, нам нужно сообщить плагину о нашей кастомной фабрике:
// .babelrc
{
"plugins": [
[
"effector/babel-plugin",
{
"factories": ["@/shared/lib/create-name"]
}
]
]
}
Поскольку плагин “видит” только один файл за раз, нам нужно предоставить ему фактический путь импорта, используемый в модуле.
Если в модуле используются относительные пути импорта, то полный путь от корня проекта должен быть добавлен в список factories
, чтобы плагин мог его разрешить.
Если используются абсолютные или псевдонимы путей (как в примере), то именно этот псевдонимный путь должен быть добавлен в список factories
.
Большинство популярных проектов экосистемы уже включены в настройки плагина по умолчанию.
Теперь плагин знает о нашей фабрике, и он обернет createName
внутренней функцией withFactory
:
// src/shared/lib/create-name/index.ts
export function createName() {
const updateName = createEvent();
const $name = createStore(null, { sid: "ffds2" });
$name.on(updateName, (_, nextName) => nextName);
return { $name };
}
// src/feature/persons/model.ts
import { withFactory } from "effector";
import { createName } from "@/shared/lib/create-name";
const personOne = withFactory({
sid: "gre24f",
fn: () => createName(),
});
const personTwo = withFactory({
sid: "lpefgd",
fn: () => createName(),
});
Благодаря этому sid внутренних юнитов фабрики также уникальны, и мы можем безопасно сериализовать и десериализовать их.
personOne.$name.sid; // gre24f|ffds2
personTwo.$name.sid; // lpefgd|ffds2
Как работает withFactory
withFactory
— это вспомогательная функция, которая позволяет создавать уникальные sid
для внутренних юнитов. Это функция, которая принимает объект с sid
и fn
свойствами. sid
— это уникальный идентификатор фабрики, а fn
— функция, которая создает юниты.
Внутренняя реализация withFactory
довольно проста: она помещает полученный sid
в глобальную область видимости перед вызовом fn
и удаляет его после. Любая функция создателя Effector пытается прочитать это глобальное значение при создании и добавляет его значение к sid
юнита.
let globalSid = null;
function withFactory({ sid, fn }) {
globalSid = sid;
const result = fn();
globalSid = null;
return result;
}
function createStore(initialValue, { sid }) {
if (globalSid) {
sid = `${globalSid}|${sid}`;
}
// ...
}
Из-за однопоточной природы JavaScript, использование глобальных переменных для этой цели безопасно.
Конечно, реальная реализация немного сложнее, но идея остается той же.
Резюме
- Любой менеджер состояний с множественными сторами требует уникальных идентификаторов для каждого стора, чтобы различать их между разными окружениями.
- В мире Effector такие строки называются
sid
. - Плагины для трансформации кода добавляют
sid
и мета-информацию к созданию юнитов Effector, таких какcreateStore
илиcreateEvent
. - Плагины для трансформации кода оборачивают кастомные фабрики вспомогательной функцией
withFactory
, которая позволяет сделатьsid
внутренних юнитов уникальными.
Документация на английском языке - самая актуальная, поскольку её пишет и обновляет команда effector. Перевод документации на другие языки осуществляется сообществом по мере наличия сил и желания.
Помните, что переведенные статьи могут быть неактуальными, поэтому для получения наиболее точной и актуальной информации рекомендуем использовать оригинальную англоязычную версию документации.