Эта статья - логическое продолжение моих экспериментов с реактивностью в JavaScript.
В современной фронтенд-разработке реактивность очень важна. Но в React она может быть достигнута посредством библиотек (MobX, к примеру). Поэтому я задался целью создать легковесный реактивный движок достаточно простой для понимания и тестирования.
Вот что я хочу вам показать 👉 https://www.npmjs.com/package/@pravosleva/reactive-engine
Документация
- 🇷🇺 На русском - активно дополняется
- 🇬🇧 На английском - дополняется менее активно, пока русскоязычный вариант остаётся более полным
NPM-пакет @pravosleva/reactive-engine решает именно эту задачу, предоставляя разработчикам специализированный движок (синглтон-экземпляр ReactiveEngine) для управления реактивными состояниями и автоматического вычисления зависимостей на чистом JavaScript.
Основная идея инструмента
Архитектура @pravosleva/reactive-engine базируется на концепции направленного графа вычислений. Вместо того чтобы вручную вызывать функции обновления при каждом изменении переменной, вы описываете связи между данными один раз. Движок сам берет на себя задачу по цепочке обновить все зависимые узлы.
Ключевые преимущества такого подхода:
- Разделение ответственности (Decoupling). Бизнес-логика полностью отделена от слоя представления (UI-рендеринга).
- Минимизация лишних вычислений. Движок кэширует промежуточные результаты и пересчитывает только то, что действительно изменилось.
- Предсказуемый Data Flow. Потоки данных движутся строго в одном направлении, исключая бесконечные циклы обновлений.

Как это устроено под капотом?
Подобные реактивные движки обычно реализуют три фундаментальных типа сущностей:
- Атомы / Сигналы (Atoms / Signals / Observables) - базовые ячейки хранения данных. Они хранят примитивы или объекты и генерируют событие всякий раз, когда их значение перезаписывается.
- Вычисляемые свойства (Computed / Computed Nodes) - узлы графа, которые зависят от других ячеек. Они лениво (lazy) вычисляют свое значение и автоматически подписываются на те атомы, которые были прочитаны в процессе выполнения их функции.
- Эффекты / Реакции (Effects / Reactions) - конечные точки графа, которые не возвращают новых данных, но выполняют побочные эффекты (Side Effects): сохраняют данные в LocalStorage, отправляют API-запросы или дергают методы обновления интерфейса.
Пример использования на чистом JS
Архитектура @pravosleva/reactive-engine базируется на концепции направленного графа вычислений. Вместо того чтобы вручную вызывать функции обновления при каждом изменении переменной, вы описываете связи между данными один раз через свойство .value. Движок сам берет на себя задачу по цепочке обновить все зависимые узлы.
Давайте представим стандартную задачу: расчет стоимости корзины товаров с учетом динамической скидки и отправкой аналитики. С использованием реактивного движка на чистом JS код принимает декларативный вид:
import { ReactiveEngine } from '@pravosleva/reactive-engine'
// Инициализируем движок (Синглтон)
const engine = new ReactiveEngine()
// Инициализируем базовые состояния (Сигналы)
const price = engine.signal(1000)
const quantity = engine.signal(2)
const discountPercent = engine.signal(10) // 10%
// Описываем вычисляемые узлы графа (Computed)
// Движок автоматически отслеживает, к каким .value было обращение внутри функций
const baseTotalPrice = engine.computed(() => price.value * quantity.value)
const finalPrice = engine.computed(() => {
const total = baseTotalPrice.value
const discount = (total * discountPercent.value) / 100
return total - discount
})
// Создаем побочный эффект (Effect)
engine.effect(() => {
console.log(`[UI Update] Итоговая сумма к оплате: ${finalPrice.value} руб.`)
})
// Работа с реактивным графом
// В консоли сразу сработает эффект при инициализации:
// "[UI Update] Итоговая сумма к оплате: 1800 руб."
// Меняем количество товара (Операция ++ вызывает сеттер свойства .value)
quantity.value++
// Движок автоматически пересчитает baseTotalPrice -> finalPrice -> вызовет эффект:
// В консоли: "[UI Update] Итоговая сумма к оплате: 2700 руб."Пример использования в React
В библиотеке @pravosleva/reactive-engine реактивные сущности (сигналы) предоставляют доступ к своему состоянию через свойство .value. Благодаря встроенным геттерам и сеттерам, изменение значения через конструкцию .value++ автоматически перехватывается движком и запускает цепочку обновлений.
Создание хранилища
// ~/store.ts
import { ReactiveEngine } from '@pravosleva/reactive-engine/react'
// ☝️ ВАЖНО! Будьте внимательны!
// В версиях v1.x (и выше) классы движка имеют разделенный экспорт
// для работы в разных окружениях: React, Vue 3.2+, Angular 16+
const engine = new ReactiveEngine()
// Базовая ячейка состояния (Сигнал)
export const counterSignal = engine.signal(0)
// Вычисляемый узел графа (Computed). Автоматически зависит от counterSignal.
export const doubleComputed = engine.computed(() => counterSignal.value * 2)
// Побочный эффект (Effect). Срабатывает при каждом изменении counterSignal.
engine.effect(() => {
console.log(`[Лог] Текущий счетчик изменился: ${counterSignal.value}`)
})Интеграция в React-компонент
Для связки с UI-слоем React библиотека предоставляет удобный хук useReactiveValue, который подписывает компонент на изменения (но можно и без него в стиле MobX через observer - примеры есть в доке).
// Counter.tsx
import { useReactiveValue } from '@pravosleva/reactive-engine/react'
import { counterSignal, doubleComputed } from '~/store'
export const Counter = () => {
// Хук автоматически подпишется на изменения и вызовет ререндер
const count = useReactiveValue(counterSignal)
const doubleCount = useReactiveValue(doubleComputed)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<h3>Счетчик: {count}</h3>
<p>Удвоенное значение (Computed): {doubleCount}</p>
{/* Операция ++ разворачивается в counterSignal.value = counterSignal.value + 1 */}
{/* Сеттер свойства .value перехватывает это действие и обновляет граф */}
<button onClick={() => counterSignal.value++}>Увеличить</button>
<button onClick={() => counterSignal.value--}>Уменьшить</button>
</div>
)
}Реакции и побочные эффекты через useReactiveSubscription
Иногда вам нужно просто отреагировать на изменение сигнала (например, запустить анимацию, вызвать уведомление или отправить метрику в аналитику), но при этом не нужно перерисовывать сам компонент. Для этого используется хук подписки.
Простой пример: Логирование изменений
Компонент ниже вообще не будет делать ререндер при кликах, но эффект внутри хука отработает на каждое изменение сигнала.
import React from 'react'
import { useReactiveSubscription } from '@pravosleva/reactive-engine/react'
import { counterSignal } from '~/store'
export const LoggerButton = () => {
// Хук изолирован от рендеров. Он просто выполнит коллбек при изменении сигнала
useReactiveSubscription(counterSignal, (newValue) => {
console.log(`[Фидбек] Счетчик изменился на: ${newValue}`)
})
return (
<button onClick={() => counterSignal.value++}>
Кликни меня (Компонент не рендерится, но лог идет)
</button>
)
}Продвинутый пример: Синхронизация с императивными API браузера
Хук идеально подходит для интеграции реактивного стейта со сторонними библиотеками, холстами (<canvas>), картами или нативными API браузера (например, тостами, медиа-плеерами или localStorage).
// ~/store.ts
export const isMutedSignal = engine.signal(false, 'isMuted')// AudioPlayer.tsx
import React, { useRef } from 'react'
import { useReactiveSubscription } from '@pravosleva/reactive-engine/react'
import { isMutedSignal } from '~/store'
export const AudioPlayer = () => {
const videoRef = useRef<HTMLVideoElement>(null)
// Синхронизируем реактивное состояние со свойством нативного DOM-узла
useReactiveSubscription(isMutedSignal, (isMuted) => {
if (videoRef.current) {
videoRef.current.muted = isMuted;
}
})
return (
<div>
<video ref={videoRef} src="video.mp4" controls />
<button onClick={() => { isMutedSignal.value = !isMutedSignal.value; }}>
Переключить звук
</button>
</div>
)
}Таким образом, библиотека предоставляет полный цикл управления потоком данных: State (Signal) -> Derivatives (Computed) -> UI (useReactiveValue) -> Reactions (useReactiveSubscription)
Умная автоочистка вычислений (Zero-Config Garbage Collection)
Движок под капотом использует современное JavaScript API — FinalizationRegistry. Как только React удаляет компонент или меняет зависимости в useMemo, старая ссылка на вычисление уничтожается, а ядро автоматически удаляет брошенные реактивные эффекты и очищает внутренний кэш.
Пример: Безопасное инлайн-вычисление без утечек памяти
import React, { useMemo } from 'react'
import { useReactiveValue } from '@pravosleva/reactive-engine/react'
import { engine, globalProductsSignal } from '~/store'
export const FilteredCatalog = ({ category }: { category: string }) => {
// Вы можете безбоязненно использовать стандартный useMemo.
// При смене категории старая ссылка сотрется, а движок сам зачистит allEffects ядра!
const dynamicComputed = useMemo(() => {
return engine.computed(() =>
globalProductsSignal.value.filter(p => p.category === category)
);
}, [category]);
const filteredList = useReactiveValue(dynamicComputed);
return (
<ul>
{filteredList.map(p => <li key={p.id}>{p.name}</li>)}
</ul>
)
}Еще пример из доки: Асинхронные ресурсы с зависимостями от нескольких сигналов
Если ваш сетевой запрос зависит от фильтров, пагинации или ID пользователя, объедините их в computed, чтобы resource автоматически перезапускал fetch-логику и отменял потерявшие актуальность запросы:
// apiStore.ts
import { engine } from '~/store'
export const userIdSignal = engine.signal(1, 'userId')
export const tabSignal = engine.signal<'posts' | 'todos'>('posts', 'tab')
// Объединяем сигналы в единый вычисляемый массив зависимостей
const requestDeps = engine.computed(() => {
return [userIdSignal.value, tabSignal.value] as const
});
// Создаем реактивный асинхронный ресурс
export const userDataResource = engine.resource(
async ([userId, tab], abortSignal) => {
const res = await fetch(`https://typicode.com{userId}/${tab}`, {
signal: abortSignal, // Передаем нативный токен отмены
});
if (!res.ok) throw new Error('Ошибка при загрузке данных')
return res.json()
},
requestDeps, // Передаем зависимости
'userData'
)В компоненте это выглядит максимально декларативно:
// UserProfile.tsx
import React from 'react'
import { useReactiveValue } from '@pravosleva/reactive-engine/react'
import { userIdSignal, tabSignal, userDataResource } from './apiStore'
export const UserProfile = () => {
// Читаем объект состояния ресурса: { data, loading, error }
const { data, loading, error } = useReactiveValue(userDataResource);
const tab = useReactiveValue(tabSignal);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<button onClick={() => { tabSignal.value = 'posts'; }}>Вкладка Посты</button>
<button onClick={() => { tabSignal.value = 'todos'; }}>Вкладка Задачи</button>
<button onClick={() => { userIdSignal.value += 1; }}>Следующий пользователь</button>
<h4>Текущая вкладка: {tab}</h4>
{loading && <p>Загрузка данных по сети...</p>}
{error && <p style={{ color: 'red' }}>Произошла ошибка: {error.message}</p>}
{data && <pre>{JSON.stringify(data.slice(0, 3), null, 2)}</pre>}
</div>
)
}Кэширование запросов с поддержкой времени жизни (TTL)
Вы можете использовать утилиты-декораторы для кэширования ответов сервера, чтобы при частом переключении вкладок не спамить сеть повторными запросами.
import { engine } from '~/store'
import { withCache } from '@pravosleva/reactive-engine' // Либо какая-то другая кастомная утилита для кеширования
const searchSignal = engine.signal('', 'search')
export const cachedSearchResource = engine.resource(
withCache(
async (query, abortSignal) => {
const res = await fetch(`https://example.com{query}`, { signal: abortSignal })
return res.json()
},
{ ttl: 30 * 1000 } // Кэш будет валиден 30 секунд для каждого уникального query
),
searchSignal
);Использование Core-сервисов во Vue 3 Composition API
Версия движка, импортируемая из @pravosleva/reactive-engine/vue предоставляет бесшовную интеграцию полиморфных Core-сервисов вашего приложения с Vue 3 Composition API.
Класс ReactiveEngine из подпакета /vue расширяет базовое ядро и преобразует сигналы движка в стандартные Vue ShallowRef объекты с автоматическим управлением жизненным циклом подписок, полностью защищая приложение от утечек памяти как в UI-компонентах, так и в независимых областях видимости эффектов (EffectScope).
Особенности рантайма и автоматическая очистка
Адаптер спроектирован как универсальное (isomorphic) решение и автоматически определяет контекст, в котором он был вызван:
- Внутри компонентов (
setup): Если метод.use()вызван во время инициализации компонента, адаптер автоматически регистрирует хукonUnmountedи отписывается от сигналов ядра при размонтировании DOM-узла. - Внутри EffectScope (Pinia / Кастомные Composables): Если метод вызван вне UI, но внутри активной области видимости эффектов (например, в Pinia-сторе), адаптер регистрирует хук
onScopeDispose. Подписка будет уничтожена вместе со сбросом этого скоупа. - Глобальный контекст: Если метод вызван в глобальной области видимости, подписка останется активной на всё время жизни приложения.
Для людей, изучающих Vue, ниже представлен каноничный пример интеграции чистой бизнес-логики счетчика в компонент с использованием синтаксиса <script setup>:
<script setup lang="ts">
import { AbstractService } from '@pravosleva/reactive-engine';
import { ReactiveEngine as ReactiveEngine4Vue } from '@pravosleva/reactive-engine/vue';
import clsx from 'clsx';
// Импортируем ваши общие стили песочницы (CSS/SCSS модули)
import baseClasses from '~/ui.common.module.scss';
import btnClasses from '~/ui.button.module.scss';
// 1. Описываем изолированную бизнес-логику (Ядро/Сервис) — код 1-в-1 как в React/Angular
class CounterLogic extends AbstractService {
public counter = this.engine.signal<number>(0, 'example:vue:counter');
public doubledCounter = this.engine.computed<number>(() => this.counter.value * 2, 'example:vue:computed');
public inc = () => {
this.counter.value += 1;
};
}
// 2. Инициализируем Vue-версию движка
const engine = new ReactiveEngine4Vue();
// 3. Внедряем сервис из DI-контейнера движка
const logic = engine.inject(CounterLogic);
// 4. Локальные Vue-реактивные обертки со стартовыми значениями сигналов.
// Метод .use() возвращает стандартный ShallowRef<T> объект.
const counter = engine.use(logic.counter);
const doubledCounter = engine.use(logic.doubledCounter);
</script>
<template>
<!-- Использование классов и clsx идентично React-окружению -->
<div :class="clsx(baseClasses.unit, baseClasses.stack2)">
<div :class="baseClasses.absoluteUnitLabel">Vue 3 Signal Example</div>
<!--
⚠️ Обратите внимание!
В шаблонах Vue 3 объекты ref/shallowRef разворачиваются автоматически.
Писать `counter.value` внутри тегов {{ }} НЕ нужно — это вызовет ошибку.
-->
<code>{{ counter }} | x2 = {{ doubledCounter }}</code>
<div :class="baseClasses.catSection">
<!-- Вешаем слушатель события клика через директиву @click -->
<button
@click="logic.inc"
:class="clsx(btnClasses.neonBtn, btnClasses['neonBtn--primary'], btnClasses['neonBtn--outlined'])"
>
INC (Vue)
</button>
</div>
</div>
</template>Архитектурные преимущества интеграции:
- Zero-overhead реактивность: Благодаря использованию
shallowRefвместо глубокогоref, Vue не тратит ресурсы процессора на рекурсивный прокси-обход тяжелых структур данных, приходящих из ядра. - Принудительные триггеры (
triggerRef): Внутри подписки адаптера зашит вызовtriggerRef. Это гарантирует, что если сигнал вашего ядра обновит внутреннее свойство сложного объекта или массива без мутации самой ссылки, Vue гарантированно и мгновенно перерисует интерфейс. - Полная совместимость с экосистемой: Полученные через
.use()переменные являются нативными реактивными примитивами Vue. Вы можете передавать их в watch-трекеры, вычисляемые свойстваcomputed(() => ...)самого фреймворка или выводить в секции<style>черезv-bind.
Резюме
Пакет @pravosleva/reactive-engine - это отличный выбор для разработчиков, которым нужен полный контроль над реактивностью без необходимости тащить за собой тяжеловесные экосистемы вроде MobX или RxJS. Он позволяет структурировать хаотичные потоки данных в понятный, легко тестируемый и высокопроизводительный вычислительный граф.