Связывание инстансов
📡 Способ 3. Синхронизация на UI-слое фреймворка (Динамические плагинные мосты)
Синхронизация независимых инстансов реактивного ядра посредством механизмов жизненного цикла фреймворка (хук useEffect в React, функции watch во Vue или effect в Angular) — это паттерн с динамическим временем жизни связи (Lazy & Disposable Binding). В этой модели слой бизнес-логики сервисов остается абсолютно изолированным (Loose Coupling), а логика сквозного Push-вещания между ядрами инкапсулируется непосредственно на границе UI-представления.
💡 Когда это уместно и необходимо использовать:
- Динамические плагины и вкладки (On-Demand Widgets): Идеально подходит для тяжелых изолированных модулей, которые загружаются на экран пользователя временно (например, открывающееся модальное окно «Калькулятор тарифа», вкладка «Продвинутая сквозная аналитика» или виджет «Технический чат»). Нам не нужно держать кросс-движковый мост в оперативной памяти вечно на протяжении всей сессии работы портала. Подписка рождается строго в момент рендеринга виджета и бесследно уничтожается при его закрытии.
- Строгий Garbage Collection (Защита от утечек памяти): Незаменимо в SPA-приложениях с интенсивным клиентским роутингом. Механизм автоматической зачистки (функция очистки в
returnхукаuseEffect) гарантирует, что при размонтировании (Unmount) экрана плагина его локальный эффект вhostEngineбудет синхронно аннулирован. Это полностью исключает накопление «зомби-слушателей» в глобальном реестре ядра, высвобождает ресурсы кучи (Heap Memory) и обеспечивает кристальную чистоту долгосрочного рантайма системы.
🔬 Пошаговый рантайм-анализ (Способ 3: UI-Level Hook Synchronization)
Использование встроенного логирования инстансов позволило детально верифицировать поведение динамического моста, чье время жизни контролируется непосредственно UI-слоем React:
- Первичный Push-мост при инициализации (
Size: 3в widget): В момент рендеринга и маунта компонента срабатывает хукuseEffect, который регистрирует эффектbridge:host-to-widget-sync. При холодном старте он синхронно считывает значение хоста ('user-1') и пробрасывает его во второй движок. Архитектураexample-115 (widget)атомарно батчит первый расчет компутаwidget:computed:status, фиксацию входящего изменения сигнала и обновление итоговой вычисляемой строки. - Боевой клик на Хосте и перехват хуком (
Size: 1в host): При мутации сигналаhost:active-user-id(user-1➔user-2) в первом ядре фиксируется транзакция минимального размераSize: 1. Обратите внимание на списокsubscribersхост-сигнала: там находятся ровно два подписчика — хук отображения'react:use:host:active-user-id'и динамический'bridge:host-to-widget-sync'. В отличие от Способа 2, этот мост не выводится бэджем🟩 EFFECTв логах хоста, так как он запущен внутри React-эффекта, что делает транзакцию хоста лаконичной. - Изолированный прием данных плагином (
Size: 2в widget): Поймав изменение через хук,example-115 (widget)запускает суверенный шедулер. Сигналwidget:target-userфиксирует мутацию (from: 'user-1'➔to: 'user-2'), успешно обновляя локальный компут статуса (🟢 Стабильноза0.010ms) и плавно перерисовывая зеленую плашку виджета. - Полная автономия локальных действий (
Size: 2в widget): При нажатии кнопки «💥 Локальный клик виджета» инкрементируется внутренний сигналwidget:local-counter(0➔1). Транзакция полностью изолируется внутри плагина, а логгерexample-115 (host)сохраняет абсолютное молчание, доказывая надежность защиты главного графа от интерфейсного шума.
🚨 Анализ утечек памяти (Memory Leaks Audit)
В отличие от вечных JS-подписок, архитектурная схема Способа 3, построенная на базе React-хука useEffect, обладает динамическим временем жизни связи и гарантирует 100% защиту от утечек памяти (Lazy & Disposable Binding).
Архитектурное обоснование:
- Строгий жизненный цикл UI-компонента: Мост синхронизации инициализируется только тогда, когда компонент
Example115физически монтируется на экран. Если пользователь переключит роут, закроет вкладку виджета или перейдет на другую страницу приложения, React запустит функцию очистки (cleanup) изreturn () => unsubscribeHost(). - Гарантированное аннулирование «зомби-эффектов»: Вызов функции отписки синхронно удаляет объект моста из коллекции
engine.pendingEffectsи глобального реестраhostEngine.allEffects. Это полностью разрывает связь между инстансами в памяти:hostEngineмгновенно вычеркивает плагин из списка своих подписчиков (subscribers), позволяя сборщику мусора (Garbage Collector) беспрепятственно очистить всю кучу (Heap Memory) второго движка. - Безопасность для динамических модулей: Этот подход является единственным легитимным решением для динамически создаваемых окон, чатов и кастомных плагинов. Он гарантирует, что сколько бы раз пользователь ни открывал и не закрывал виджет, в главном ядре системы (
hostEngine) никогда не накопится скрытых устаревших ссылок.
⚠️ Рекомендация для разработчиков:
Способ 3 является эталоном для временных, контекстных виджетов, существующих только внутри конкретного экрана. Однако помните, что данная подписка жестко привязана к UI-слою: если вам потребуется, чтобы плагин продолжал фоном синхронизировать данные с хостом во время навигации по другим (скрытым) страницам приложения, следует перенести мост на уровень слоя данных, используя Способ 1 или Способ 2.
Пример 115
import { AbstractService } from '@pravosleva/reactive-engine'
// Глобальный сервис хост-приложения (Инстанс 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
}
}
// Внутренний сервис изолированного виджета (Инстанс 2)
export class WidgetInternalService extends AbstractService {
// Виджет хранит локальную копию ID, чтобы крутить вокруг неё свои вычисления
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
}
}React-компонент:
import { useEffect } from 'react'
import baseClasses from '~/ui.common.module.scss'
import btnClasses from '~/ui.button.module.scss'
import { ReactiveEngine } from '@pravosleva/reactive-engine/react'
import { HostGlobalService, WidgetInternalService } from './services'
import clsx from 'clsx'
// 🌟 Создаем два АБСОЛЮТНО независимых государства в памяти
const hostEngine = new ReactiveEngine({
logger: {
isEnabled: true,
filter: /^host:.*/,
instanceName: 'example-115 (host)',
}
})
const widgetEngine = new ReactiveEngine({
logger: {
isEnabled: true,
filter: /^widget:.*/,
instanceName: 'example-115 (widget)',
}
})
export const Example115 = () => {
// Инжектируем синглтоны в их родные инстансы движков
const hostLogic = hostEngine.inject(HostGlobalService)
const widgetLogic = widgetEngine.inject(WidgetInternalService)
// Оформляем стандартные подписки для вывода на экран
const activeUserId = hostEngine.use(hostLogic.activeUserId)
const widgetStatus = widgetEngine.use(widgetLogic.widgetStatus)
// 🌟 МОСТ ДРУЖБЫ И СИНХРОНИЗАЦИИ (Cross-Engine Bridge):
useEffect(() => {
// Подписываемся на изменения в Первом движке (Host)
const unsubscribeHost = hostEngine.effect(() => {
const freshHostUserId = hostLogic.activeUserId.value
// Передаем значение во Второй движок (Widget) напрямую в его сигнал!
// Это абсолютно безопасно, так как вызов происходит на границе систем
widgetLogic.currentTargetUser.value = freshHostUserId
}, 'bridge:host-to-widget-sync')
// При размонтировании (unmount) уничтожаем мост
return () => unsubscribeHost()
}, [hostLogic, widgetLogic])
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>
)
}