Внутреннее устройство
Эта страница объясняет, что происходит под публичным API Virentia. Для обычной разработки приложений это знать не обязательно. Раздел полезен, если вы пишете адаптеры, разбираете порядок выполнения или решаете, где должен жить новый примитив.
Юниты — это ноды графа
Публичное API говорит про сторы, события, эффекты и реакции. Внутри каждый из этих примитивов владеет нодами графа или создает их. Нода — маленький исполняемый шаг графа. Ноды связаны через next, поэтому один юнит может запустить другой, не зная, кто именно к нему подключен.
Когда внешняя граница запускает событие, Virentia не обходит весь граф рекурсивно. Вместо этого она кладет задачу в kernel queue. В задаче есть нода, payload, scope и контекст выполнения. После этого queue последовательно выполняет ноды.
Поэтому payload и scope идут вместе. Payload говорит следующей ноде, что произошло. Scope говорит сторам, где читать и писать значения.
Сторы — определения, scopes хранят значения
Стор — не само значение. Стор владеет стабильной identity и знает, как по ней читать или писать данные. Scope хранит фактические значения.
Когда код читает query.value, стор берет scope из текущего контекста выполнения и ищет в этом scope собственный id. Если значения еще нет, стор возвращает начальное значение.
Когда код пишет query.value = "docs", стор запускает свою ноду в текущем scope. Нода записывает новое значение в scope.values и уведомляет подписчиков, которые смотрят на тот же scope.
Благодаря этому код модели можно переиспользовать. Модель можно импортировать один раз, а каждый экземпляр приложения, запрос, тест или кешированный экран получит собственную карту значений.
Реакции — связи с поведением
Явная реакция прикрепляет свою ноду к исходному юниту.
Например, если реакция слушает queryChanged, Virentia добавляет ноду реакции в queryChanged.node.next. Когда событие запускается, kernel доходит до этой ноды с тем же payload и scope. Тело реакции может писать сторы, вызывать эффекты или запускать другую логику модели.
Автоматические реакции — основной режим для большинства правил модели. При создании они запускаются один раз и запоминают сторы, которые были прочитаны во время run. Эти ноды сторов становятся зависимостями. Когда одна из зависимостей меняется, реакция запускается снова и обновляет список зависимостей.
Явные реакции остаются альтернативой для мест, где важна конкретная причина запуска: событие, эффект или юнит жизненного цикла. В таком коде on делает источник и payload частью правила.
Эффекты — это цепочки нод
Эффект — не просто async-функция. Это небольшой граф вокруг асинхронной операции.
Стартовая нода увеличивает $inFlight, обновляет $pending и вызывает started. Нода выполнения делает await handler. Завершающая нода уменьшает $inFlight, обновляет $pending и вызывает юниты успешного или неуспешного завершения.
Этот жизненный цикл доступен как обычные юниты: done, doneData, fail, failData, settled, $pending и $inFlight. Код модели может реагировать на них так же, как на события.
Отмена привязана к каждому выполняющемуся вызову. Handler получает AbortSignal, а dispose у владельца может отменить вызовы эффекта, созданные внутри него.
Kernel Queue
Kernel queue задает контролируемый порядок выполнения графа. Нода может вернуть значение, остановить текущую ветку, завершить ее ошибкой или поставить следующие ноды в очередь.
Каждая задача в очереди несет:
- ноду, которую нужно запустить;
- payload, с которым выполняется эта ветка;
- текущее значение, полученное от предыдущей ноды;
- scope;
- контекст выполнения;
- метаданные для интеграций.
Scope всегда часть задачи в очереди. Это важный момент: если юнит стартовал в scope, следующие ноды получают тот же scope, если низкоуровневая интеграция намеренно не меняет его.
Границы и контекст scope
allSettled(unit, { scope }) — самая явная граница, потому что scope передается напрямую. Он запускает работу графа в заданном scope и ждет завершения асинхронной работы.
scoped(scope, fn) — короткая рамка выполнения. Он кладет scope в текущий контекст выполнения, чтобы обычный код мог читать и писать сторы. Когда callback-функция завершается, предыдущий контекст scope восстанавливается.
Если callback возвращает promise, scoped(scope, fn) сохраняет этот scope для promise-цепочки до ее завершения. Это удобно для асинхронной работы, которой владеет приложение, но scoped не стоит воспринимать как универсальный async context для любого параллельного сценария.
scoped(scope).wrap(fn) нужен для интеграций. Он один раз захватывает scope и снова открывает его, когда другая библиотека позже вызывает вашу callback-функцию.
Владельцы и очистка
Владельцы нужны, потому что модели, созданные во время выполнения, должны уметь отвязывать свою работу. Реакции, подписки и функции очистки, зарегистрированные внутри owner, привязаны к нему.
Когда вызывается dispose, Virentia запускает функции очистки и отвязывает связи графа, созданные внутри владельца. Так динамические модели не оставляют реакции после закрытия модального окна, вкладки или кешированного экрана.
Практическая польза
Большая часть прикладного кода не должна думать о нодах. Она должна говорить на языке сторов, событий, эффектов, реакций, скоупов и владельцев.
Модель нод важна, когда вы строите bindings для фреймворков, слой совместимости, helpers для persistence, тестовые helpers, devtools или новый примитив. На этом уровне вопросы всегда одни: какая нода запускается, какой payload идет дальше, какой scope владеет значениями и кто потом очищает связи.