SIDs
Effector основан на идее атомарного store. Это означает, что в приложении нет централизованного контроллера состояния или другой точки входа для сбора всех состояний в одном месте.
Итак, возникает вопрос — как отличать юниты между разными окружениями? Например, если мы запускаем приложение на сервере и сериализуем его состояние в JSON, как узнать, какая часть этого JSON должна быть помещена в конкретный store на клиенте?
Давайте обсудим, как эта проблема решается другими менеджерами состояний.
Другие менеджеры состояний
Один store
В менеджере состояний с одним store (например, Redux) этой проблемы вообще не существует. Это один store, который можно сериализовать и десериализовать без какой-либо дополнительной информации.
Фактически, один store принуждает вас к созданию уникальных имен для каждой его части неявным образом. В любом объекте вы не сможете создать дублирующие ключи, так что путь к части store — это уникальный идентификатор этой части.
// server.ts
import { createStore } from "single-store-state-manager";
function handlerRequest() {
const store = createStore({ initialValue: null });
return {
// Можно просто сериализовать весь store
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),
});
Это здорово, что не нужно никаких дополнительных инструментов для сериализации и десериализации, но у одного store есть несколько проблем:
- Он не поддерживает tree-shaking и code-splitting, вам все равно придется загружать весь store
- Из-за своей архитектуры он требует дополнительных инструментов для исправления производительности (например,
reselect
) - Он не поддерживает микрофронтенды и другие вещи, которые становятся все более популярными
Множественные stores
К сожалению, менеджеры состояний, построенные вокруг идеи множественных stores, плохо решают эту проблему. Некоторые инструменты предлагают решения, подобные одному store (MobX), некоторые вообще не пытаются решить эту проблему (Recoil, Zustand).
Например, общий паттерн для решения проблемы сериализации в MobX — это Root Store Pattern, который разрушает всю идею множественных stores.
Мы рассматриваем SSR как первоклассного гражданина современных веб-приложений и собираемся поддерживать code-splitting или микрофронтенды.
Уникальные идентификаторы для каждого store
Из-за архитектуры с множественными stores, Effector требует уникального идентификатора для каждого store. Это строка, которая используется для различения stores между разными окружениями. В мире Effector такие строки называются sid
.
:::tip TL;DR
sid
— это уникальный идентификатор store. Он используется для различения stores между разными окружениями.
:::
Давайте добавим его в некоторые stores:
const $name = createStore(null, { sid: "name" });
const $age = createStore(null, { sid: "age" });
Теперь мы можем сериализовать и десериализовать stores:
// server.ts
async function handlerRequest() {
// создаем изолированный экземпляр приложения
const scope = fork();
// заполняем stores данными
await allSettled($name, { scope, params: "Igor" });
await allSettled($age, { scope, params: 25 });
const state = JSON.serialize(serialize(scope));
// -> { "name": "Igor", "age": 25 }
return { state };
}
После этого кода у нас есть сериализованное состояние нашего приложения. Это простой объект со значениями stores. Мы можем вернуть его обратно в stores на клиенте:
// Предположим, что сервер поместил состояние в HTML
const serverState = readServerStateFromWindow();
const scope = fork({
// Просто парсим все состояние и используем его как состояние клиента
values: JSON.parse(serverState),
});
Конечно, написание sid
для каждого store — это скучная работа. Effector предоставляет способ сделать это автоматически с помощью плагинов для трансформации кода.
Автоматический способ
Безусловно, создание уникальных идентификаторов вручную — это довольно скучная работа.
К счастью, существуют effector/babel-plugin
и @effector/swc-plugin
, которые автоматически создадут SIDs.
Поскольку инструменты трансляции кода работают на уровне файла и запускаются до этапа сборки, возможно сделать SIDs стабильными для каждого окружения.
Предпочтительно использовать effector/babel-plugin
или @effector/swc-plugin
вместо добавления SIDs вручную.
Пример кода
Обратите внимание, что здесь нет никакой центральной точки — любое событие любой “фичи” может быть вызвано из любого места, и остальные части будут реагировать соответствующим образом.
// 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 или каким-либо другим клиентским приложением, на этом статья была бы закончена.
Граница сериализации
Но в случае с рендерингом на стороне сервера всегда есть граница сериализации — точка, где все состояние преобразуется в строку, добавляется в ответ сервера и отправляется в браузер клиента.
Проблема
И в этот момент нам все еще нужно собрать состояния всех stores приложения каким-то образом!
Кроме того, после того как клиентский браузер получил страницу, нам нужно “гидрировать” все обратно: распаковать эти значения на клиенте и добавить это “серверное” состояние в клиентские экземпляры всех stores.
Решение
Это сложная проблема, и для ее решения effector нужен способ связать “серверное” состояние какого-то store с его клиентским экземпляром.
Хотя это можно было бы сделать путем введения “корневого store” или чего-то подобного, что управляло бы экземплярами stores и их состоянием за нас, это также принесло бы нам все минусы этого подхода, например, гораздо более сложный code-splitting — поэтому это все еще нежелательно.
Здесь нам очень помогут SIDs. Поскольку SID, по определению, одинаков для одного и того же store в любом окружении, 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>
`,
],
});
}
Обратите внимание, что здесь нет прямого импорта каких-либо stores приложения. Состояние собирается автоматически, и его сериализованная версия уже содержит всю информацию, которая понадобится для гидрации.
Когда сгенерированный ответ поступает в браузер клиента, серверное состояние должно быть гидрировано в клиентские stores. Благодаря SIDs, гидрация состояния также работает автоматически:
// 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>,
);
На этом этапе состояние всех stores в clientScope
такое же, как было на сервере, и для этого не потребовалось никакой ручной работы.
Уникальные SIDs
Стабильность SIDs обеспечивается тем, что они добавляются в код до того, как произойдет какая-либо сборка.
Но поскольку оба плагина, и babel
, и swc
, могут “видеть” содержимое только одного файла в каждый момент времени, есть случай, когда SIDs будут стабильными, но могут быть не уникальными.
Чтобы понять почему, нам нужно углубиться немного дальше во внутренности плагинов.
Оба плагина 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
во внутренние stores фабрики:
// 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
, и внутренние stores обоих этих экземпляров будут иметь одинаковые SIDs!
Эти SIDs будут стабильными, но не уникальными.
Чтобы исправить это, нам нужно сообщить плагину о нашей кастомной фабрике:
// .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(),
});
Благодаря этому SIDs внутренних юнитов фабрики также уникальны, и мы можем безопасно сериализовать и десериализовать их.
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, использование глобальных переменных для этой цели безопасно.
Конечно, реальная реализация немного сложнее, но идея остается той же.
Резюме
- Любой менеджер состояний с множественными stores требует уникальных идентификаторов для каждого store, чтобы различать их между разными окружениями.
- В мире Effector такие строки называются
sid
. - Плагины для трансформации кода добавляют
sid
и мета-информацию к созданию юнитов Effector, таких какcreateStore
илиcreateEvent
. - Плагины для трансформации кода оборачивают кастомные фабрики вспомогательной функцией
withFactory
, которая позволяет сделатьsid
внутренних юнитов уникальными.
Документация на английском языке - самая актуальная, поскольку её пишет и обновляет команда effector. Перевод документации на другие языки осуществляется сообществом по мере наличия сил и желания.
Помните, что переведенные статьи могут быть неактуальными, поэтому для получения наиболее точной и актуальной информации рекомендуем использовать оригинальную англоязычную версию документации.