Связывание инстансов
🟩 Способ 2. Синхронизация на уровне эффектов ядра (Core-Level Effect Routing)
Синхронизация независимых инстансов реактивного ядра посредством декларативного метода engine.effect — это паттерн с постоянным временем жизни и сквозной трассировкой производительности (Core-Level Reactive Pipeline). В этой модели мост синхронизации описывается непосредственно в слое данных (Data Layer), намертво связывая граф зависимостей Первого движка (hostEngine) со свойствами Второго движка (widgetEngine), полностью минуя жизненный цикл и рендеринг компонентов UI-платформы.
💡 Когда это уместно и необходимо использовать:
- Архитектура крупных Microfrontends (Постоянные фоновые сервисы): Идеально подходит для модулей, которые должны находиться в состоянии непрерывной, бесшовной синхронизации на протяжении всего сеанса работы пользователя с порталом. Например, когда Глобальный контекст авторизации или Системный роутер хост-приложения обязаны мгновенно уведомлять изолированные, параллельно запущенные виджеты («Чат поддержки», «Уведомления», «Телеметрия сессии») о смене глобального состояния.
- Сквозной аудит и профилирование (Телеметрия транзакций): Незаменимо в проектах с высокими требованиями к дебагу и логированию каскадных вычислений. Поскольку регистрация моста происходит через
engine.effect, его выполнение вшивается в общий шедулер планировщика микрозадач. Профайлер ядра автоматически замеряет время выполнения кросс-движкового пуша, предоставляя разработчикам прозрачные зеленые бэджи🟩 EFFECTвнутри фиолетовой группыREACTIVE TRANSACTIONс точным хронометражем (duration: 0.015ms) и защитой от бесконечных циклов (heavy re-rendersворнинги). - Синхронизация по Push-модели (Экономия кучи памяти): Эффект ядра самостоятельно отслеживает мутации сигналов хоста, просыпаясь строго в момент реального изменения данных. Это исключает необходимость ручного управления подписками на стороне UI-компонентов, разгружает React от лишней работы и делает слой отображения максимально пассивным и легковесным.
📊 Что происходит в консоли DevTools (Анализ транзакции Способа 2):
Когда вы нажимаете кнопку «Выбрать User 2», в консоль прилетает монолитный фиолетовый батч от первого движка (hostEngine), который нативно включает в себя наш глобальный кросс-движковый эффект:
▼ 🟪 REACTIVE TRANSACTION Microtask Tick (Size: 2 | Duration: 0.110 ms) 🟢 stable (Filter: host)
► 🟦 SIGNAL [host:active-user-id] | 🔄 Изменений сигнала: 1 (from: 'user-1' to: 'user-2')
► 🟩 EFFECT [core-bridge:global-host-to-widget-sync] | 🟢 Стабильно (0.010ms)
[Execution Timestamp]: 4520.10 msВ ту же микросекунду, как только эффект core-bridge синхронно записывает новое значение во второй сигнал widgetLogic.currentTargetUser.value = 'user-2', просыпается второй изолированный шедулер (widgetEngine) и запускает собственную суверенную транзакцию перерасчета статуса:
▼ 🟪 REACTIVE TRANSACTION Microtask Tick (Size: 2 | Duration: 0.130 ms) 🟢 stable (Filter: widget)
► 🟦 SIGNAL [widget:target-user] | 🔄 Изменений сигнала: 1 (from: 'user-1' to: 'user-2')
► 🟩 COMPUTED [widget:computed:status] | 🟢 Стабильно (0.010ms)
[Execution Timestamp]: 4520.25 msПочему эта архитектурная схема — высший пилотаж:
- Полная независимость от UI-цикла React: Если вы переключите роут, закроете или откроете другие вкладки приложения — этот мост никогда не порвется и не потребует повторной инициализации, так как он зафиксирован в слое данных (Data Layer).
- Идеальное разделение контекстов: При локальных кликах внутри виджета транзакции хоста не триггерятся. Системы общаются строго по детерминированному контракту — только когда хост сам решает объявить о смене глобального пользователя.
Почему импорт из @pravosleva/reactive-engine/react важен
Теперь проект на 100% соответствует спецификации архитектурных слоев фреймворка. Слой данных использует универсальный AbstractService (который ничего не знает про DOM и может запускаться хоть в NodeJS-тестах), а ReactiveEngine импортируется из своей легитимной UI-обертки /react, предоставляющей методы синхронизации .use() для useSyncExternalStore.
Core notes (ablut isFirstRun in effect)
По спецификации реактивного программирования, метод this.engine.effect(fn) устроен так, что переданная функция fn обязана синхронно выполниться один раз прямо в момент создания. Это необходимо ядру, чтобы «холодным стартом» пробежаться по коду, задеть геттеры сигналов и собрать первичный граф зависимостей (узнать, кого эффект должен слушать). Если убрать флаг isFirstRun, то в консоль DevTools при самом старте приложения (еще до того, как пользователь кликнул хоть по одной кнопке) хлынет огромный поток логов вида 🟩 EFFECT [имя] 🟢 Стабильно. Этот спам на старте:
- Засоряет лог транзакций: Разработчик видит кучу зеленых бэджей, хотя реального изменения данных и «боевого» перезапуска эффекта еще не было.
- Искажает метрики батчинга: Стартовый запуск эффекта происходит синхронно на
Как это работает с флагом?
- Холодный старт (Регистрация эффекта): Вызывается
effectObj.run(). Код видитisFirstRun === true, пропускает блокif (!isFirstRun && engine.loggerOptions?.isEnabled)и ничего не отправляет в очередь логов. В самом конце прохода флаг опускается: isFirstRun = false. - Боевой тик (Мутация сигнала): Пользователь нажимает кнопку, сигнал будит эффект. Метод
.run()вызывается во второй раз. ТеперьisFirstRunравенfalse. Условие!isFirstRunуспешно выполняется, и логгер честно отправляет Payload в транзакцию, выводя на экран бэдж🟩 EFFECT.
Благодаря флагу isFirstRun в замыкании, в телеметрию попадают исключительно реактивные Push-перезапуски, вызванные реальным изменением данных в системе!
🔬 Практический разбор телеметрии: Трассировка кросс-движковых транзакций (Core-Level Bridge)
Предоставленный лог консоли наглядно демонстрирует работу модернизированного профайлера ядра в условиях взаимодействия двух полностью изолированных инстансов ReactiveEngine (hostEngine и widgetEngine), связанных декларативным мостом host:bridge-effect:... на уровне слоя данных.
Разобьем текущую рантайм-сессию на четыре системных такта планировщика:
Такт 1: Инициализация и первичный Push-мост при маунте (Size: 3)
В момент монтирования приложения и регистрации побочного эффекта в hostEngine срабатывает обязательный «холодный» запуск. Эффект считывает стартовое значение хоста ('user-1') и синхронно транслирует его во второй движок через мутацию сигнала: widgetLogic.currentTargetUser.value = 'user-1'. Второй изолированный движок (widgetEngine) перехватывает управление и за один такт микрозадачи атомарно батчит три события:
- Выполняет
⚪ Первый расчеткомпутаwidget:computed:status [IS_OPTIMIZED=1]для пустой строки (0.010ms). - Фиксирует входящую мутацию сигнала
widget:target-user(from: ''➔to: 'user-1'). - Синхронно обновляет итоговый компут до стабильного состояния (
🟢 Стабильно), гарантируя консистентность интерфейса еще до первого кадра отрисовки плагина.
Такт 2: Боевая синхронизация через эффект-мост (Size: 2)
При клике на кнопку смены пользователя на Хосте (user-1 ➔ user-2) в hostEngine запускается обособленный батч микрозадачи. Сигнал host:active-user-id мутирует, и в логах четко отображается состав его явных подписчиков (subscribersCount: 2):
react:use:host:active-user-id— хук подписки UI-слоя React для реактивного обновления синей плашки.host:bridge-effect:global-host-to-widget-sync [IS_OPTIMIZED=1]— наш фоновый кросс-движковый мост.
Благодаря интеграции эффектов в общую систему логирования ядра, профайлер теперь выводит полноценную строчку 🟩 EFFECT с замером времени выполнения (duration: "0.000ms") и инкрементом счетчика вызовов (🚀 Вызовов: 1), подтверждая нулевой накладной расход на межсистемную передачу примитивов. В этот же миг мост пушит значение во второй движок.
Такт 3: Прием данных и каскадный перерасчет Виджета (Size: 2)
Поймав сигнал от моста, widgetEngine запускает суверенную транзакцию микрозадачи. Сигнал widget:target-user фиксирует изменение (from: 'user-1' ➔ to: 'user-2'), у которого в подписчиках находится ровно один внутренний компут плагина. На финише батча вычисляемое свойство статуса виджета мгновенно переходит в состояние 🟢 Стабильно за 0.010ms, синхронизируя плагин с новыми метаданными хост-системы.
Такт 4: Суверенная жизнь Виджета в полной изоляции (Size: 2)
При совершении локального действия внутри плагина (клик, инкрементирующий сигнал widget:local-counter с 0 на 1), widgetEngine выполняет изолированную транзакцию Size: 2. Внутренний компут статуса пересчитывается и лениво отдает в React актуальный плоский пакет данных: [Виджет для user-2]. Локальных кликов: 1.
Главный архитектурный вывод: На этом такте транзакционный логгер hostEngine полностью промолчал. Благодаря полной изоляции ядер на уровне инстансов, спам вычислений, тяжелая логика анимаций или внутренние триггеры виджета плагина оказались заперты в своем суверенном шедулере. Они физически не способны вызвать паразитные проверки изменений, каскадные ререндеры или просадки производительности (Change Detection Lag) главного хост-приложения.
🔬 Пошаговый (FINAL) рантайм-анализ (Способ 2: Core-Level Effect Routing)
Благодаря внедрению логирования слоев effect в версии 1.5.3-beta, профайлер ядра предоставил исчерпывающую трассировку сквозного транзакционного батчинга между независимыми суверенными шедулерами:
- Инициализация графа при холодном старте (
Size: 3в widget): На этапе декларации полей в памяти создается эффект-мост. При маунте он выполняет стартовый проход. Внутренний флагisFirstRun = trueуспешно блокирует ложные записи в консоль, предотвращая стартовый шум. Второй изолированный движок (widget) за один такт микрозадачи инициализирует кэш компутаwidget:computed:status, мутирует сигналwidget:target-user(с""на'user-1') и переводит вычисляемую строку в стабильное состояние (0.010ms). - Атомарная транзакция хоста с бэджем эффекта (
Size: 2в host): При боевом переключении пользователя оператором (user-1➔user-2) вexample-116 (host)фиксируется батчSize: 2. Сигналhost:active-user-idизменяет состояние, отображая двух легитимных подписчиков (subscribersCount: 2): хук отображения React и наш фоновый мост данных. Следом в этой же транзакции логгер успешно выводит обновленный тёмно-зелёный бэдж🟩 EFFECT [host:bridge-effect:...]с замером времени выполнения (duration: '0.100ms') и счетчиком боевых тиков (🚀 Вызовов: 1), доказывая консистентность первой фазы Push-потока. - Изолированный прием и перерасчет во втором ядре (
Size: 2в widget): Как только эффект в рамках транзакции хоста синхронно выполняет строкуwidgetLogic.currentTargetUser.value = 'user-2', просыпается шедулерexample-116 (widget). Он атомарно батчит изменение сигналаwidget:target-userи запускает перерасчет компута статуса (🟢 Стабильно), точечно обновляя плашку на экране плагина. - Второй боевой цикл и накопление метрик (
Size: 2в host / widget): При повторном переключении пользователя (user-2➔user-1) вся цепочка транзакций повторяется со швейцарской точностью. При этом профайлер эффекта ядра наглядно демонстрирует накопление метрик телеметрии: счетчик вызовов увеличивается до🚀 Вызовов: 2, а тайминг выполнения оптимизируется до0.000ms, фиксируя выход реактивного контура на пиковую производительность рантайма.
🚨 Анализ утечек памяти (Memory Leaks Audit)
В отличие от динамических UI-подписок, архитектурная схема Способа 2, построенная на базе глобального декларативного вызова hostEngine.effect(), обладает абсолютной стерильностью и нулевым риском возникновения утечек памяти (Zero Memory Leaks).
Архитектурное обоснование:
- Глобальный иммунитет синглтонов: Объекты
hostEngineиwidgetEngine, а также их инжектированные зависимостиhostLogicиwidgetLogicдекларируются как неизменяемые константы на уровне Core Data Layer всего приложения. Они инициализируются один раз при старте вкладки браузера и имеют идентичный жизненный цикл, равный времени существования сессии пользователя. - Отсутствие анонимных накоплений: Метод
engine.effect()регистрирует строго фиксированный именованный объект эффекта внутри внутренней коллекции ядраthis.allEffects.add(effectObj). Поскольку этот вызов совершается статически в теле файлаservice.Example116.tsв единственном экземпляре, в системе физически не может возникнуть лавинообразного накопления дублирующих «зомби-слушателей». - Полная независимость от UI-слоя: Поскольку мост синхронизации не использует хуки фреймворка (такие как
useEffect), реактивный граф подписок полностью занулен на стороне React. Любые операции размонтирования (Unmount) экранов, сбросы инпутов или переключения роутера внутри SPA-приложения никак не влияют на стабильность удержания ссылок в памяти, гарантируя константный размер кучи (Heap Size) на протяжении многочасовой работы портала.
Пример 116
Инициализация инстансов:
import { AbstractService } from '@pravosleva/reactive-engine'
import { ReactiveEngine } from '@pravosleva/reactive-engine/react'
// 1. Инициализируем два абсолютно независимых инстанса реактивного ядра
export const hostEngine = new ReactiveEngine({
logger: {
isEnabled: true,
traceTime: false,
filter: /^host:.*/,
instanceName: 'example-116 (host)',
}
})
export const widgetEngine = new ReactiveEngine({
logger: {
isEnabled: true,
traceTime: false,
filter: /^widget:.*/,
instanceName: 'example-116 (widget)',
}
})
// 2. Описываем Глобальный сервис хост-приложения (Инстанс 1)
export class HostGlobalService extends AbstractService {
public activeUserId = this.engine.signal<string>('user-1', 'host:active-user-id')
public switchUser(id: string) {
this.activeUserId.value = id
}
}
// 3. Описываем Внутренний сервис изолированного виджета (Инстанс 2)
export class WidgetInternalService extends AbstractService {
public currentTargetUser = this.engine.signal<string>('', 'widget:target-user')
public widgetLocalCounter = this.engine.signal<number>(0, 'widget:local-counter')
// Вычисляемое свойство виджета
public widgetStatus = this.engine.computed(() => {
return `[Виджет для ${this.currentTargetUser.value}]. Локальных кликов: ${this.widgetLocalCounter.value}`
}, 'widget:computed:status')
public incLocal() {
this.widgetLocalCounter.value += 1
}
}
// 4. Регистрируем синглтоны в их родных контейнерах через inject
export const hostLogic = hostEngine.inject(HostGlobalService)
export const widgetLogic = widgetEngine.inject(WidgetInternalService)
// СТАТИЧЕСКИЙ КРОСС-ДВИЖКОВЫЙ МОСТ (Способ 2):
// Создаем декларативный эффект на уровне hostEngine. Он вечно живет в слое данных.
// Как только сигнал хоста изменится — ядро упакует этот тик в транзакцию хоста,
// синхронно разбудит коллбэк, и мы запушим значение во второй движок widgetEngine!
hostEngine.effect(() => {
const freshHostUserId = hostLogic.activeUserId.value // Намертво подписываемся к Engine 1
// Синхронно передаем значение во Второй изолированный движок (Engine 2)
widgetLogic.currentTargetUser.value = freshHostUserId
}, 'host:bridge-effect:global-host-to-widget-sync')React-компонент:
import baseClasses from '~/ui.common.module.scss'
import btnClasses from '~/ui.button.module.scss'
import { hostEngine, widgetEngine, hostLogic, widgetLogic } from './service.Example116'
import clsx from 'clsx'
export const Example116 = () => {
// 🟢 Оформляем подписки для вывода на экран через соответствующие инстансы движков
const activeUserId = hostEngine.use(hostLogic.activeUserId)
const widgetStatus = widgetEngine.use(widgetLogic.widgetStatus)
return (
<div className={clsx(baseClasses.unit, baseClasses.stack2)} style={{ width: '600px', display: 'flex', flexDirection: 'column', gap: '16px', fontFamily: 'system-ui' }}>
<div className={baseClasses.absoluteUnitLabel}>Engine instances sample</div>
{/* СЛОЙ 1: ХОСТ ПРИЛОЖЕНИЕ (ДВИЖЕК 1) */}
<div
className={baseClasses.stack1}
style={{ padding: '16px', background: '#1a1a24', borderRadius: '16px' }}
>
<h4 style={{ color: '#00b4d8' }}>🌐 Хост-приложение (Engine #1)</h4>
<div style={{ fontSize: 'small', fontFamily: 'monospace', background: '#000', padding: '8px', borderRadius: '6px', marginBottom: '10px', color: '#ccc' }}>
Текущий пользователь в Системе: <b>{activeUserId}</b>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={() => hostLogic.switchUser('user-1')}
className={clsx(
btnClasses.btn,
btnClasses.neonBtn,
btnClasses['neonBtn--primary'],
{
[btnClasses['neonBtn--contained']]: activeUserId === 'user-1',
[btnClasses['neonBtn--outlined']]: activeUserId !== 'user-1'
}
)}
>
User 1
</button>
<button
onClick={() => hostLogic.switchUser('user-2')}
className={clsx(
btnClasses.btn,
btnClasses.neonBtn,
btnClasses['neonBtn--primary'],
{
[btnClasses['neonBtn--contained']]: activeUserId === 'user-2',
[btnClasses['neonBtn--outlined']]: activeUserId !== 'user-2'
}
)}
>
User 2
</button>
</div>
</div>
{/* СЛОЙ 2: ИЗОЛИРОВАННЫЙ ВИДЖЕТ (ДВИЖЕК 2) */}
<div
className={baseClasses.stack1}
style={{ padding: '16px', background: '#111116', borderRadius: '16px' }}
>
<h4 style={{ color: '#42b883' }}>🧩 Изолированный Плагин-Виджет (Engine #2)</h4>
<div style={{ fontSize: 'small', fontFamily: 'monospace', background: '#000', padding: '8px', borderRadius: '6px', marginBottom: '10px', color: '#ccc' }}>
{widgetStatus}
</div>
<button
onClick={() => widgetLogic.incLocal()}
className={clsx(btnClasses.btn, btnClasses.neonBtn, btnClasses['neonBtn--secondary'], btnClasses['neonBtn--contained'])}
>
💥 Локальный клик виджета (+1)
</button>
</div>
</div>
)
}