Skip to content

Связывание инстансов

🟩 Способ 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

Почему эта архитектурная схема — высший пилотаж:

  1. Полная независимость от UI-цикла React: Если вы переключите роут, закроете или откроете другие вкладки приложения — этот мост никогда не порвется и не потребует повторной инициализации, так как он зафиксирован в слое данных (Data Layer).
  2. Идеальное разделение контекстов: При локальных кликах внутри виджета транзакции хоста не триггерятся. Системы общаются строго по детерминированному контракту — только когда хост сам решает объявить о смене глобального пользователя.

Почему импорт из @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) перехватывает управление и за один такт микрозадачи атомарно батчит три события:

  1. Выполняет ⚪ Первый расчет компута widget:computed:status [IS_OPTIMIZED=1] для пустой строки (0.010ms).
  2. Фиксирует входящую мутацию сигнала widget:target-user (from: ''to: 'user-1').
  3. Синхронно обновляет итоговый компут до стабильного состояния (🟢 Стабильно), гарантируя консистентность интерфейса еще до первого кадра отрисовки плагина.

Такт 2: Боевая синхронизация через эффект-мост (Size: 2)

При клике на кнопку смены пользователя на Хосте (user-1user-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, профайлер ядра предоставил исчерпывающую трассировку сквозного транзакционного батчинга между независимыми суверенными шедулерами:

  1. Инициализация графа при холодном старте (Size: 3 в widget): На этапе декларации полей в памяти создается эффект-мост. При маунте он выполняет стартовый проход. Внутренний флаг isFirstRun = true успешно блокирует ложные записи в консоль, предотвращая стартовый шум. Второй изолированный движок (widget) за один такт микрозадачи инициализирует кэш компута widget:computed:status, мутирует сигнал widget:target-user"" на 'user-1') и переводит вычисляемую строку в стабильное состояние (0.010ms).
  2. Атомарная транзакция хоста с бэджем эффекта (Size: 2 в host): При боевом переключении пользователя оператором (user-1user-2) в example-116 (host) фиксируется батч Size: 2. Сигнал host:active-user-id изменяет состояние, отображая двух легитимных подписчиков (subscribersCount: 2): хук отображения React и наш фоновый мост данных. Следом в этой же транзакции логгер успешно выводит обновленный тёмно-зелёный бэдж 🟩 EFFECT [host:bridge-effect:...] с замером времени выполнения (duration: '0.100ms') и счетчиком боевых тиков (🚀 Вызовов: 1), доказывая консистентность первой фазы Push-потока.
  3. Изолированный прием и перерасчет во втором ядре (Size: 2 в widget): Как только эффект в рамках транзакции хоста синхронно выполняет строку widgetLogic.currentTargetUser.value = 'user-2', просыпается шедулер example-116 (widget). Он атомарно батчит изменение сигнала widget:target-user и запускает перерасчет компута статуса (🟢 Стабильно), точечно обновляя плашку на экране плагина.
  4. Второй боевой цикл и накопление метрик (Size: 2 в host / widget): При повторном переключении пользователя (user-2user-1) вся цепочка транзакций повторяется со швейцарской точностью. При этом профайлер эффекта ядра наглядно демонстрирует накопление метрик телеметрии: счетчик вызовов увеличивается до 🚀 Вызовов: 2, а тайминг выполнения оптимизируется до 0.000ms, фиксируя выход реактивного контура на пиковую производительность рантайма.

🚨 Анализ утечек памяти (Memory Leaks Audit)

В отличие от динамических UI-подписок, архитектурная схема Способа 2, построенная на базе глобального декларативного вызова hostEngine.effect(), обладает абсолютной стерильностью и нулевым риском возникновения утечек памяти (Zero Memory Leaks).

Архитектурное обоснование:

  1. Глобальный иммунитет синглтонов: Объекты hostEngine и widgetEngine, а также их инжектированные зависимости hostLogic и widgetLogic декларируются как неизменяемые константы на уровне Core Data Layer всего приложения. Они инициализируются один раз при старте вкладки браузера и имеют идентичный жизненный цикл, равный времени существования сессии пользователя.
  2. Отсутствие анонимных накоплений: Метод engine.effect() регистрирует строго фиксированный именованный объект эффекта внутри внутренней коллекции ядра this.allEffects.add(effectObj). Поскольку этот вызов совершается статически в теле файла service.Example116.ts в единственном экземпляре, в системе физически не может возникнуть лавинообразного накопления дублирующих «зомби-слушателей».
  3. Полная независимость от UI-слоя: Поскольку мост синхронизации не использует хуки фреймворка (такие как useEffect), реактивный граф подписок полностью занулен на стороне React. Любые операции размонтирования (Unmount) экранов, сбросы инпутов или переключения роутера внутри SPA-приложения никак не влияют на стабильность удержания ссылок в памяти, гарантируя константный размер кучи (Heap Size) на протяжении многочасовой работы портала.

Пример 116

Инициализация инстансов:

ts
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-компонент:

tsx
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>
  )
}