Акторы
Акторы используют, когда один и тот же сценарий процесса может запускаться несколько раз параллельно, а каждый запуск должен иметь собственные состояние, контекст и жизненный цикл. Примеры: независимые загрузки, фоновые синхронизации и задачи.
В lite-fsm актор — это обычная машина-шаблон, которую MachineManager умеет создавать, адресовать и удалять.
Акторы особенно полезны, когда нужно управлять множеством однотипных, но независимых сущностей:
- сетевые операции: загрузки файлов, отправка сообщений, повторные попытки запросов;
- интерфейсные процессы: тосты, модальные окна, временные уведомления, drag-and-drop операции;
- фоновые процессы: синхронизации, очереди задач, таймеры, отложенные проверки;
- игры и симуляции: динамические объекты могут создаваться из шаблонов, жить независимо и адресоваться по одному экземпляру, группе или тегу. Например, одна волна объектов может быть создана как группа и затем остановлена, усилена или удалена общим событием.
Термины и определения
- Доменная машина — обычная машина в
MachineManager. Она описывает состояние приложения или доменной области: библиотеку, плеер, форму, корзину. - Шаблон актора — машина с состоянием
__INITвconfig. Из этого шаблонаMachineManagerсоздаёт рабочие экземпляры. - Активный актор — конкретный созданный экземпляр шаблона. У него есть собственные
state,contextи служебные поляactorId,groupId,groupTag. - Создание актора — запуск нового экземпляра шаблона. Это происходит, когда событие подходит под переход из
__INIT. - Терминальная цель — специальная цель
__RESOLVED,__REJECTEDили__CANCELLED. Когда актор попадает в такую цель,MachineManagerудаляет его из публичного объекта записей. - Событие без адресации (
unscoped) — событие безmeta.actorId,meta.groupIdиmeta.groupTag. Оно доставляется доменным машинам, активным акторам и шаблонам акторов; если есть подходящий переход из__INIT,MachineManagerсоздаёт новый актор. - Адресованное событие (
scoped) — событие с полями адресации вaction.meta. Оно отправляется конкретному актору, группе или тегу. - Поля адресации (
routing meta) — служебная часть события для выбора получателей среди акторов. Это не данные приложения и неmetaредьюсера.
Минимальный пример
Ниже пример параллельной загрузки альбомов по id. Доменная машина хранит массив загруженных id альбомов, а актор отвечает за один процесс загрузки. Если пользователь запускает две загрузки, MachineManager создаст два независимых актора из одного шаблона.
В примере предполагается, что в приложении уже есть типизированная версия createMachine, а store создан из этих машин с immerMiddleware. Поэтому редьюсеры написаны в стиле Immer с прямым изменением черновика.
import { createMachine } from "./fsm";
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const libraryContext: { albums: string[] } = {
albums: [],
};
const libraryMachine = createMachine({
config: {
READY: {
ALBUM_DOWNLOAD_DONE: null,
},
},
initialState: "READY",
initialContext: libraryContext,
reducer: (state, action, { nextState }) => {
state.state = nextState;
state.context.albums.push(action.payload.albumId);
},
});
const albumDownloadActor = createMachine({
config: {
__INIT: {
ALBUM_DOWNLOAD: "DOWNLOADING",
},
DOWNLOADING: {
ALBUM_DOWNLOAD_DONE: "__RESOLVED",
},
},
initialState: "__INIT",
initialContext: {
albumId: "",
},
effects: {
DOWNLOADING: async ({ action, transition }) => {
await delay(800);
transition({
type: "ALBUM_DOWNLOAD_DONE",
payload: { albumId: action.payload.albumId },
});
},
},
});
store.transition({
type: "ALBUM_DOWNLOAD",
payload: { albumId: "album-1" },
});
store.transition({
type: "ALBUM_DOWNLOAD",
payload: { albumId: "album-2" },
});Что здесь происходит:
albumDownloadActorполучаетALBUM_DOWNLOADчерез__INIT, создается в состоянииDOWNLOADINGи сохраняетalbumIdв контексте;- каждый созданный актор запускает свой эффект
DOWNLOADING; - имитация загрузки через
delayпоказывает параллельные независимые процессы без деталей сети; - после завершения актор отправляет
ALBUM_DOWNLOAD_DONEчерез обычныйtransition; - в эффекте актора обычный
transitionпо умолчанию адресует событие в группу текущего актора; - каждая загрузка альбома создана событием без адресации, поэтому находится в отдельной группе;
- это же зафиксированное событие видят доменные машины, поэтому
libraryMachineдобавляет id альбома вalbums; - терминальная цель
__RESOLVEDудаляет актор изstore.getState().albumDownloads.
Эффект DOWNLOADING не проверяет action.type, потому что он запускается именно при входе в DOWNLOADING. В этом примере у состояния один входящий переход: ALBUM_DOWNLOAD.
Пока обе загрузки идут, акторы видны в публичном объекте записей:
store.getState().albumDownloads;
// {
// "albumDownloads/0": {
// state: "DOWNLOADING",
// context: { albumId: "album-1" },
// meta: {
// actorId: "albumDownloads/0",
// groupId: "albumDownloads/0",
// groupTag: "albumDownloads"
// }
// },
// "albumDownloads/1": {
// state: "DOWNLOADING",
// context: { albumId: "album-2" },
// meta: {
// actorId: "albumDownloads/1",
// groupId: "albumDownloads/1",
// groupTag: "albumDownloads"
// }
// }
// }Когда акторы завершились через __RESOLVED, объект записей снова пуст:
store.getState().albumDownloads; // {}
store.getState().library.context.albums; // ["album-1", "album-2"]Примеры
Canvas-доска с передачей снимков через hydrate и dehydrate.
Пример, где загрузка альбома запускает акторы для отдельных треков.
Правила шаблона актора
Машина считается шаблоном актора, если в config есть буквальное состояние __INIT.
Для шаблона актора действуют дополнительные ограничения:
initialStateдолжен быть"__INIT";- новый актор создается только через явный переход из
__INIT; - цель перехода из
__INITне может бытьnullили"__INIT"; - состояния
__RESOLVED,__REJECTED,__CANCELLEDзарезервированы как терминальные цели и не должны быть ключами вconfig; - глобальный переход
"*"работает для публичных состояний актора, но не наследуется для__INIT; - публичные состояния актора — это обычные состояния без
__INITи терминальных состояний.
Шаблоны акторов работают только внутри MachineManager. Отдельно созданные Machine() и defineMachine().create() отклоняют шаблон актора при transition.
Создание и доставка событий
Событие без адресации доставляется доменным машинам, активным акторам и шаблонам акторов. Если событие совпало с переходом из __INIT, MachineManager создает новый актор.
store.transition({
type: "ALBUM_DOWNLOAD",
payload: { albumId: "album-1" },
});
store.transition({
type: "ALBUM_DOWNLOAD",
payload: { albumId: "album-2" },
});
Object.keys(store.getState().albumDownloads);
// ["albumDownloads/0", "albumDownloads/1"]Если активный актор тоже имеет переход для того же события без адресации, он получит событие, и новый актор будет создан в той же отправке. В простых сценариях разделяйте события запуска актора и события, которыми актор завершается.
Адресация событий
Адресация акторов задается через action.meta. Передавайте поля адресации вручную:
store.transition({
type: "ALBUM_DOWNLOAD_DONE",
payload: { albumId: "album-1" },
meta: { actorId: "albumDownloads/0" },
});В эффекте актора обычный transition(action) уже маршрутизирует событие в группу актора, который вызвал эффект:
transition({
type: "ALBUM_DOWNLOAD_DONE",
payload: { albumId: "album-1" },
});Если нужна другая область доставки, используйте методы для явной адресации:
transition.actor("albumDownloads/0", {
type: "ALBUM_DOWNLOAD_DONE",
payload: { albumId: "album-1" },
});
transition.group(self.groupId, {
type: "ALBUM_DOWNLOAD",
payload: { albumId: "bonus-disc" },
});
transition.tag(self.groupTag, {
type: "ALBUM_DOWNLOAD",
payload: { albumId: "bonus-disc" },
});
transition.unscoped({
type: "ALBUM_DOWNLOAD",
payload: { albumId: "bonus-disc" },
});Приоритет полей адресации: actorId > groupId > groupTag. Каждое поле принимает строку или массив строк. Дубликаты удаляются.
| Область доставки | Что получает событие |
|---|---|
actorId | Только указанные actorId. Новые акторы не создаются. |
groupId | Акторы внутри указанных групп. Подходящий __INIT может создать новых акторов в этих группах. |
groupTag | Все активные группы с указанным тегом. Подходящий __INIT может создать акторов в этих группах. |
| без адресации | Доменные машины, активные акторы и новая группа для подходящего __INIT. |
Неизвестный actorId, groupId или groupTag ничего не меняет в акторах, но доменные машины всё равно получают зафиксированное событие.
Группы
Каждый актор получает неизменяемый meta:
type ActorMeta = {
actorId: string;
groupId: string;
groupTag: string;
};При создании актора событием без адресации actorId и groupId обычно совпадают:
store.transition({
type: "ALBUM_DOWNLOAD",
payload: { albumId: "album-1" },
});
store.getState().albumDownloads["albumDownloads/0"].meta;
// {
// actorId: "albumDownloads/0",
// groupId: "albumDownloads/0",
// groupTag: "albumDownloads"
// }Если актор создается в существующей группе, он получает новый actorId, но сохраняет groupId группы:
store.transition({
type: "ALBUM_DOWNLOAD",
payload: { albumId: "disc-2" },
meta: { groupId: "albumDownloads/0" },
});
Object.values(store.getState().albumDownloads).map((actor) => actor.meta);
// [
// { actorId: "albumDownloads/0", groupId: "albumDownloads/0", groupTag: "albumDownloads" },
// { actorId: "albumDownloads/1", groupId: "albumDownloads/0", groupTag: "albumDownloads" }
// ]groupTag по умолчанию равен ключу шаблона в store. Его можно переопределить:
const albumDownloadActor = createMachine({
groupTag: "downloads",
config: {
__INIT: { ALBUM_DOWNLOAD: "DOWNLOADING" },
DOWNLOADING: { ALBUM_DOWNLOAD_DONE: "__RESOLVED" },
},
initialState: "__INIT",
initialContext: { albumId: "" },
});Один groupTag может использоваться несколькими шаблонами, если они должны попадать в одну группу адресации.
Распределённое создание акторов
Когда один и тот же шаблон создаёт акторы в нескольких изолированных средах, id по умолчанию вида templateKey/0 могут совпасть и нарушить hydrate({ strategy: "merge" }). Например:
- два узла P2P-приложения создают объекты на своих досках и обмениваются снимками;
- разные вкладки одного приложения синхронизируются через
BroadcastChannel; - сервер и клиент создают акторов и обмениваются снимками;
- разные шарды или регионы независимо генерируют акторы с одинаковыми именами шаблонов.
Для таких случаев MachineManager предоставляет три опции на уровне менеджера: originId, generateActorId, generateGroupId.
originId
originId это короткий идентификатор источника, который добавляется ко всем созданным менеджером id через разделитель #. С originId: "alice" менеджер выдает id вида alice#likeSync/0, alice#likeSync/1, и так далее.
const alice = MachineManager({ likeSync }, { originId: "alice" });
const bob = MachineManager({ likeSync }, { originId: "bob" });
alice.transition({ type: "LIKE", payload: { id: "a" } });
bob.transition({ type: "LIKE", payload: { id: "b" } });
bob.hydrate(alice.dehydrate(), { strategy: "merge" });
Object.keys(bob.getState().likeSync).sort();
// ["alice#likeSync/0", "bob#likeSync/0"]При hydrate Bob видит чужие id (alice#likeSync/0) и не изменяет свой счётчик: следующий локальный актор получит bob#likeSync/1. Чужие записи живут рядом со своими и адресуются как обычные акторы (transition.actor("alice#likeSync/0", ...)).
originId это произвольная строка без #. Подходят имена узлов, идентификаторы вкладок, urn:user:alice, shard/eu-1 и так далее. Пустая строка или строка с # отклоняется как LITE_FSM_INVALID_OPTIONS.
Менеджер без originId сохраняет совместимость с предыдущими версиями: id выглядят как templateKey/0, и любой id с # считается чужим, его счётчик не изменяется.
generateActorId и generateGroupId
Опция generateActorId задаёт пользовательский генератор actorId. Он вызывается при каждом создании актора и получает контекст { templateKey, groupTag, counter, originId, action }:
const manager = MachineManager(
{ player },
{
originId: "alice",
generateActorId: ({ originId, action }) => {
if (action.type !== "JOIN") throw new Error("expected JOIN");
return `${originId}#player/${action.payload.userId}`;
},
},
);
manager.transition({ type: "JOIN", payload: { userId: "userA" } });
manager.transition({ type: "MOVE", meta: { actorId: "alice#player/userA" } });Когда генератор возвращает доменный id вроде alice#player/userA, используйте его как стабильный ключ к актору: userId позволяет адресовать актор без поиска по контексту.
Если генератор вернёт уже занятый id или дубликат внутри одной отправки, менеджер выбросит LITE_FSM_INVALID_GENERATED_ID. Тот же код используется, если генератор вернул не строку или пустую строку.
generateGroupId устроен симметрично и применяется при создании новой группы для события без адресации.
Счётчик в контексте инкрементируется всегда, даже если генератор вернул собственный id. Это сохраняет монотонность счётчика и позволяет использовать его как резервное значение или часть пользовательского id.
Чтобы счётчик восстанавливался после hydrate, используйте числовой суффикс id в формате .../N (например, alice#custom/0). Если генератор использует UUID или другой суффикс, который нельзя разобрать как число, счётчик после hydrate не восстанавливается, но проверка коллизий через isTaken всё равно защищает от пересечений.
Эффекты акторов
Эффекты шаблона актора работают для каждого активного актора отдельно. В параметры эффекта добавляются self и transition с поддержкой адресации акторов.
Внутри эффекта актора:
selfсодержитactorId,groupIdиgroupTagтекущего актора;transition(action)по умолчанию отправляет событие в группу актора-отправителя;transition.unscoped(action)снимает адресацию актора и отправляет обычное событие;transition.actor(id, action),transition.group(id, action)иtransition.tag(id, action)задают явного получателя;MachineManagerдобавляетsenderActorId,senderGroupIdиsenderGroupTagв зафиксированное событие;- поздний
transitionиз уже удаленного актора игнорируется системой.
createEffect({ type: "latest" }) изолирован на уровне экземпляра: отмена эффекта одного актора не отменяет эффекты другого актора того же шаблона.
Сохранение в snapshot
По умолчанию акторы живут только во время работы приложения:
manager.dehydrate()пропускает шаблоны акторов, которые живут только во время работы приложения;manager.dehydrate({ machines: ["albumDownloads"] })бросит ошибку для такого шаблона;manager.hydrate(snapshot)пропускает записи акторов, которые живут только во время работы приложения.
Если записи акторов должны попадать в транспортный snapshot, задайте persistence: "snapshot" и храните только данные для передачи. actorId, groupId и groupTag остаются под управлением MachineManager.
Для hydrate(..., { strategy: "replace" }) шаблон актора, сохраняемый в снимок, считает входящий набор записей полным и удаляет акторов, которых там нет. Для merge существующие акторы сохраняются, а записи из снимка добавляются или обновляются.
replaceReducer и акторы
replaceReducer — низкоуровневый инструмент для middleware. Его используют, например, Immer-интеграция и DevTools-восстановление состояния. В прикладном коде не используйте replaceReducer как способ создавать, удалять или переносить акторы: для этого есть transition() и hydrate().
Если middleware через replaceReducer возвращает новый объект записей актора, MachineManager считает это внешней заменой и пересобирает внутренние индексы адресации. Такая запись должна сохранять публичную форму объекта записей акторов:
{
[actorId]: {
state: "PENDING",
context: {},
meta: {
actorId,
groupId: "downloads/0",
groupTag: "downloads",
},
},
}Ограничения:
- объект записей шаблона должен быть обычным объектом;
- ключ записи должен быть непустым
actorId; stateдолжен быть публичным состоянием изconfigшаблона, не__INITи не терминальным состоянием;contextдолжен быть объектом;- новый актор, которого еще нет во внутренних индексах менеджера, должен иметь
meta; meta.actorIdдолжен совпадать с ключом записи;meta.groupIdдолжен быть непустой строкой;meta.groupTagдолжен совпадать сgroupTagшаблона;- один
actorIdне может одновременно находиться в записях разных шаблонов; - существующий актор нельзя переносить из одного шаблона в другой.
Если актор нужно удалить через внешнюю замену состояния, удалите его запись из объекта. Не записывайте __RESOLVED, __REJECTED или __CANCELLED вручную: терминальные состояния обрабатываются только обычным переходом актора.
Если запись нарушает эти ограничения, менеджер бросит LITE_FSM_INVALID_ACTOR_SLICE и не применит замену частично.
Акторы, появившиеся только из внешней замены состояния, не считаются созданными обычным переходом текущего события. Для них менеджер синхронизирует публичные записи с внутренними индексами маршрутизации, но не запускает эффекты как событие входа в состояние. Поэтому такой путь подходит для восстановления состояния инструментами, но не для обычной бизнес-логики.
Практические рекомендации
- Используйте акторы для множественных независимых экземпляров одного жизненного цикла, а не как замену обычным доменным машинам.
- Делайте переход из
__INITявным и узким: это контракт создания актора. - Не злоупотребляйте глобальным переходом
"*": обычные состояния и переходы описывайте явно, а"*"оставляйте для действительно общего поведения вроде отмены или очистки. - Адресуйте конкретного актора через
actorId, группу связанных акторов черезgroupId, все группы одного типа черезgroupTag. - Завершайте актор через
__RESOLVED,__REJECTEDили__CANCELLED, чтобы объект записей не копил завершенные экземпляры. - В сохраняемых акторах включайте только транспортные данные в
dehydrate; служебные поля идентичности пусть остаются заMachineManager.