🚀 ReactiveEngine
Минималистичный реактивный движок на TypeScript с Dependency Injection и интеграцией для React.
yarn add @pravosleva/reactive-engine📦 Основные компоненты
1. ReactiveEngine
Центральный узел системы. Управляет состоянием, эффектами, DI и связью с React.
- signal(value, options) — создание атомарного состояния.
- computed(fn) — производные данные с кэшированием.
- reactive(obj) — глубокая реактивность через Proxy.
- resource(fetcher, source?) — асинхронные запросы с авто-отменой.
- inject(Class) — получение или создание синглтон-сервиса.
- use(signal) — React-хук для подписки на изменения.
2. BaseService
Абстрактный класс для бизнес-логики.
- Правило: Всегда инициализируйте сигналы напрямую в полях класса для корректной работы типов и DI.
🛠 Руководство пользователя
Шаг 1: Настройка (Entry Point)
Создайте и экспортируйте единственный экземпляр движка.
import { useState, useEffect } from 'react';
import { ReactiveEngine } from './utils/ReactiveEngine';
export const engine = new ReactiveEngine();
engine.setReactAdapters(useState, useEffect); // Связываем с ReactШаг 2: Создание логики (Service)
Опишите данные и методы их изменения.
import { BaseService } from './BaseService';
export class CounterService extends BaseService {
// Сигналы с runtime-валидацией
public count = this.engine.signal(0, {
name: 'counter',
validate: (v) => v >= 0 || "Значение не может быть отрицательным"
});
// Вычисляемое значение (авто-обновление)
public double = this.engine.computed(() => this.count.value * 2);
increment = () => this.count.value++;
}Шаг 3: Использование в React
Подключайте логику к компонентам без лишнего бойлерплейта.
import { engine } from './reactive';
import { CounterService } from './CounterService';
export const Counter = () => {
const store = engine.inject(CounterService); // Получаем сервис (синглтон)
const count = engine.use(store.count); // Подписываемся на изменения
return <button onClick={store.increment}>{count}</button>;
};⚡️ Продвинутые возможности
Асинхронные данные (Resource)
Автоматически перезагружает данные при изменении зависимости (source).
this.user = this.engine.resource(
async (id, signal) => {
const res = await fetch(`/api/user/${id}`, { signal });
return res.json();
},
this.userId // Зависимость
);Отладка (Logger)
Включите логирование всех изменений в консоль:
engine.onSignalChange = (name, next, prev) => {
console.log(`[${name}] changed from`, prev, 'to', next);
};⚠️ Важные правила
- Никакого undefined: Всегда инициализируйте сигналы в полях класса сразу при объявлении.
- Имена сигналов: Всегда давайте имена сигналам (this.engine.signal(0, 'my_name')) для удобной отладки в логах.
- Untrack: Если нужно прочитать значение сигнала внутри эффекта, не создавая зависимости, используйте engine.untrack(() => signal.value).
Вариант стандарта структуры папок
src/
├── core/ # Ядро фреймворка (не зависит от проекта)
│ ├── ReactiveEngine.ts # Основной класс движка
│ ├── BaseService.ts # Базовый класс для сервисов
│ ├── types.ts # Общие интерфейсы (Signal, Resource, etc.)
│ └── index.ts # Публичное API ядра
│
├── services/ # Бизнес-логика (Store)
│ ├── index.ts # Экземпляр engine и экспорт всех инъекций
│ ├── User/
│ │ ├── UserService.ts # Логика пользователя
│ │ └── types.ts # DTO и интерфейсы данных
│ └── Counter/
│ └── CounterService.ts
│
├── components/ # UI-слой (React)
│ ├── Shared/ # Общие компоненты
│ └── Features/ # Компоненты с логикой
│ └── UserProfile/
│ ├── UserProfile.tsx # Использует engine.use(store.user)
│ └── styles.module.css
│
├── hooks/ # Глобальные React-хуки
│ └── useStore.ts # Хелперы типа useUserStore()
│
└── main.tsx # Точка входа (настройка engine.setReactAdapters)
Ключевые файлы для организации:
- src/services/index.ts (Конфигурация)
Здесь ты создаешь единственный экземпляр и настраиваешь его.
import { useState, useEffect } from 'react';
import { ReactiveEngine } from '../core/ReactiveEngine';
export const engine = new ReactiveEngine();
engine.setReactAdapters(useState, useEffect);
// Включаем логгер в dev-режиме
if (import.meta.env.DEV) {
engine.onSignalChange = (name, next, prev) => console.log(`[${name}]`, { prev, next });
}2. src/hooks/useStore.ts (Удобство)
Чтобы не импортировать engine и Class каждый раз:
import { engine } from '../services';
import { UserService } from '../services/User/UserService';
export const useUserStore = () => engine.inject(UserService);Почему такая структура?
- Core в изоляции: Ты можешь скопировать папку core в другой проект, и она будет работать.
- Масштабируемость: Каждый сервис (User, Counter, Auth) лежит в своей папке.
- Легкие тесты: Тесты для services/ лежат рядом с файлами .ts и не требуют запуска браузера.
Еще о том, как это готовить
Для того чтобы структура была максимально чистой, мы используем паттерн "Barrel" (Бочка). Это позволяет импортировать всё необходимое из одной точки, избегая «леса» из импортов в компонентах.
Вот как настроить автоматические экспорты:
1. src/core/index.ts (Публичное API ядра)
Здесь мы собираем все системные файлы.
export * from './ReactiveEngine';
export * from './BaseService';
export * from './types';2. src/services/index.ts (Главный конфигуратор)
Этот файл становится единственной точкой инициализации.
import { useState, useEffect } from 'react';
import { ReactiveEngine } from '../core';
import { CounterService } from './Counter/CounterService';
import { UserService } from './User/UserService';
// 1. Инициализация экземпляра
export const engine = new ReactiveEngine();
engine.setReactAdapters(useState, useEffect);
// 2. Регистрация глобальных сервисов (если нужно предоставить моки или фабрики)
// engine.provide(UserService, (eng) => new UserService(eng));
// 3. Удобный экспорт типизированных хуков для доступа к сторам
export const useCounterStore = () => engine.inject(CounterService);
export const useUserStore = () => engine.inject(UserService);
// Экспортируем сам движок для использования engine.use в компонентах
export { CounterService, UserService };3. Как теперь выглядят импорты в компоненте (UserProfile.tsx)
Вместо того чтобы искать файлы по всей папке utils или components, вы просто пишете:
import { engine, useUserStore } from '@/services';
export const UserProfile = () => {
const userStore = useUserStore();
const { data, loading } = engine.use(userStore.userProfile);
return (
<div>{loading ? '...' : data?.name}</div>
);
};4. Настройка Alias в tsconfig.json (Бонус)
Чтобы не писать длинные пути типа ../../services, добавь алиасы. Это сделает код еще чище.
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/core/*": ["src/core/*"],
"@/services": ["src/services/index"],
"@/hooks/*": ["src/hooks/*"]
}
}
}Почему это эффективно:
- Инкапсуляция: Все настройки движка (логи, адаптеры) скрыты внутри services/index.ts.
- Одна точка входа: Если ты решишь сменить название сервиса или путь к нему, тебе нужно будет поправить это только в одном index.ts.
- Удобство для команды: Разработчик просто пишет use... и IDE сама предлагает импорт из @/services.
PERF
Для сравнения твоего ReactiveEngine с Redux нужно оценивать их в двух плоскостях: алгоритмическая сложность (как работает код) и производительность в браузере (как часто обновляется UI). Твой движок по своей природе ближе к MobX или Vue, поэтому он архитектурно отличается от Redux. Вот основные критерии и способы сравнения:
1. Механика обновлений (Push vs Pull)
- Redux (Глобальный вещатель): Когда происходит dispatch, Redux уведомляет все подключенные компоненты. Каждый компонент должен запустить selector, чтобы понять, изменились ли его данные. Если селекторов сотни, это может тормозить основной поток.
- ReactiveEngine (Точечные подписки): Благодаря signal и Proxy, твой движок знает точно, какой компонент зависит от какого поля. Обновляется только тот компонент, чьи данные реально изменились.
Результат: На огромных деревьях объектов твой движок будет быстрее "из коробки" без мемоизации (useMemo, reselect).
2. Как замерить на практике?
А. Инструменты (Chrome DevTools)
- Открой вкладку Performance в браузере.
- Запусти запись.
- Выполни массовое обновление (например, измени 1000 элементов списка).
- Смотри на параметр Scripting (сколько времени JS считал изменения) и Rendering (сколько времени React перерисовывал их).
Б. Тест "1000 счетчиков" Создай страницу с 1000 независимых компонентов-счетчиков.
- В Redux: При изменении одного счетчика проверку пройдут все 1000 селекторов.
- В ReactiveEngine: Сработает только 1 подписка.
- Замерь FPS (кадры в секунду) при частом нажатии на кнопку.
3. Сравнение "на бумаге"
Критерий Redux ReactiveEngine
Сложность O(N), где N - кол-во подписчиков O(K), где K - кол-во реально изменившихся полей обновления
Память Низкая (простые объекты) Чуть выше (из-за хранения объектов Proxy и Set подписчиков)
МасштабируемостьТребует ручной оптимизации Масштабируется автоматически
4. Где Redux может выиграть?
Redux использует обычные JS-объекты. Твой движок использует Proxy. Чтение данных через Proxy на доли наносекунд медленнее, чем чтение из обычного объекта. Если тебе нужно совершить 1 000 000 математических операций над данными в секунду, Redux (точнее, чистый JS) будет быстрее. Но в контексте UI-фреймворка эти задержки незаметны. Итог Твой движок будет эффективнее в приложениях с высокой частотой обновлений (чаты, трейдинговые графики, игры), так как он избавляет React от лишней работы по сравнению данных (diffing).
Экземпляр engine.ts на моем примере
import { ReactiveEngine } from '~/utils/ReactiveEngine'
import { useState, useEffect } from 'react'
// Создаем единственный экземпляр движка на все приложение
const engine = new ReactiveEngine()
// Обязательно передаем хуки React для работы engine.use()
engine.setReactAdapters(useState, useEffect)
// Дальше можно регистрировать логгеры, DevTools и сервисы
if (process.env.NODE_ENV === 'development') {
// engine.onSignalChange = (n, next, prev) => {
// console.log(`%c[${n}]`, 'color: #2196F3; font-weight: bold;', prev, '→', next)
// }
engine.onSignalChange = (name, next, prev) => {
console.groupCollapsed(
`%cИзменение%c %c${name}%c`,
name !== 'unnamed_signal' ? 'color: #888; font-weight: lighter;' : 'color: #505050; font-weight: lighter;',
'',
name !== 'unnamed_signal' ? 'color: #2196F3; font-weight: bold;' : 'color: #155f9b; font-weight: bold;',
);
console.log("%cБыло:", "color: #9e9e9e;", prev);
console.log("%cСтало:", "color: #4CAF50; font-weight: bold;", next);
console.groupEnd();
};
}
export { engine }
/* NOTE: Это классический паттерн Singleton (одиночка), и для реактивного движка это самый правильный подход.
Вот несколько причин, почему один экземпляр engine — это стандарт:
1. Единое пространство состояний
Если ты создашь два engine, то сервис, зарегистрированный в первом, не сможет «увидеть» сигналы из второго. Один экземпляр гарантирует, что все части твоего приложения (UI, сетевые запросы, логика) общаются на одном «языке» и через одну шину событий.
2. Dependency Injection (DI) работает корректно
Метод engine.inject(CounterStore) внутри хранит кэш созданных сервисов (Map). Если экземпляр один, то в любом месте приложения, вызвав inject, ты получишь один и тот же объект стора. Это позволяет легко передавать данные между компонентами, которые находятся в разных частях дерева.
3. Интеграция с DevTools и Middleware
Логгер или DevTools подключаются к конкретному экземпляру. Если их будет много, тебе придется подключать отладчик к каждому отдельно, что превратит консоль в хаос.
*/
/* NOTE: Маленький совет по импорту:
Поскольку ты теперь импортируешь engine везде,
убедись, что у тебя нет циклических зависимостей.
- Плохо: engine.ts импортирует StoreA, а StoreA импортирует engine.
- Хорошо: StoreA импортирует только типы или сам ReactiveEngine как класс для конструктора,
а экземпляр engine используется уже в компонентах или при запуске приложения.
*/
Пример простого чатика (клиентская часть с использованием socket.io)
~/services/SimpleChat/Logic.socket.io.ts
import { io, Socket } from 'socket.io-client';
import { BaseServiceEnhanced } from '~/utils/ReactiveEngine';
import { ChatMessage } from './types';
export class Logic extends BaseServiceEnhanced {
public messages = this.engine.signal<ChatMessage[]>([], 'SimpleChatService:socket.io:signal:messages');
public status = this.engine.signal<'connecting' | 'open' | 'closed'>('closed', 'SimpleChatService:socket.io:signal:connection');
// Сигнал текущей комнаты. Изменение этого сигнала вызовет перезапуск эффекта.
public currentRoom = this.engine.signal<string>('general', 'SimpleChatService:socket.io:signal:room');
private socket?: Socket;
// Список имен пользователей, которые сейчас печатают
public typingUsers = this.engine.signal<string[]>([], 'SimpleChatService:socket.io:signal:typingUsers');
// Храним таймеры для каждого печатающего пользователя, чтобы удалять их по истечении времени
private typingTimeouts = new Map<string, any>();
private lastTypingSent = 0;
protected onInit() {
this.engine.effect(() => {
// Если сигнал еще не определен, просто выходим из эффекта
// if (!this.currentRoom) return;
// Подписываемся на изменение комнаты
const room = this.currentRoom.value;
const url = 'ws://pravosleva.pro';
this.status.value = 'connecting';
this.messages.value = []; // Очищаем чат при смене комнаты
const socket = io(url, { reconnectionAttempts: 100 });
this.socket = socket;
socket.on('connect', () => {
this.status.value = 'open';
// Сообщаем серверу, что мы вошли в конкретную комнату
socket.emit('join_room', room);
console.log(`📡 Joined room: ${room}`);
});
// Слушаем сообщения именно для этой комнаты
socket.on('room_message', (data: ChatMessage) => {
this.messages.value = [...this.messages.value, data];
});
socket.on('from-client:mx:experimental-metrix:pong-ok', (data: ChatMessage) => {
this.messages.value = [...this.messages.value, data];
});
socket.on('disconnect', () => {
this.status.value = 'closed';
});
// 1. Слушаем событие начала печати от других
socket.on('user_typing', (userName: string) => {
this.handleRemoteTyping(userName);
});
// Очистка при смене комнаты или уничтожении сервиса
return () => {
socket.emit('leave_room', room);
socket.close();
this.socket = undefined;
};
});
}
// Обработка входящего события "печатает..."
private handleRemoteTyping(userName: string) {
// Если пользователь уже в списке, сбрасываем старый таймер удаления
if (this.typingTimeouts.has(userName)) {
clearTimeout(this.typingTimeouts.get(userName));
} else {
// Добавляем в список, если его там нет
this.typingUsers.value = [...this.typingUsers.value, userName];
}
// Через 3 секунды удаляем пользователя из списка "печатающих"
const timeout = setTimeout(() => {
this.typingUsers.value = this.typingUsers.value.filter(u => u !== userName);
this.typingTimeouts.delete(userName);
}, 3000);
this.typingTimeouts.set(userName, timeout);
}
// Метод для смены комнаты из UI
public switchRoom(roomName: string) {
this.currentRoom.value = roomName;
}
public sendMessage(text: string) {
if (this.socket?.connected) {
this.socket.emit('send_to_room', {
room: this.currentRoom.value,
text,
user: 'Me',
timestamp: Date.now()
});
}
}
public sendTestMessage(text: string) {
if (this.socket?.connected) {
this.socket.emit(
'from-client:mx:experimental-metrix:ping',
{
metrixEventType: 'from-client:mx:experimental-metrix:ping',
reportType: 'default',
stateValue: 'local-reactive-engine-exp',
room: this.currentRoom.value,
text,
user: 'Me',
ts: Date.now(),
app: {
name: 'reactive-engine-exp',
version: '0.0.1'
},
},
(arg: unknown) => console.log(arg)
);
}
}
// Метод для вызова из React при вводе текста
public sendTypingNotification() {
const now = Date.now();
// Отправляем уведомление не чаще чем раз в 2 секунды (Throttling)
if (now - this.lastTypingSent > 2000) {
this.socket?.emit('typing', 'MyUserName'); // В реальности берем имя из AuthStore
this.lastTypingSent = now;
}
}
}
/* NOTE: Основные отличия от чистого WebSocket:
1. Нет ручного JSON.parse: Socket.io автоматически превращает объекты в JSON и обратно.
2. Типизированные события: Вместо одного onmessage, ты можешь сделать `socket.on('chatMessage', ...)` и `socket.on('adminAction', ...)`.
3. Встроенный Reconnect: Тебе больше не нужен reconnectTrigger и setTimeout. Ты просто настраиваешь reconnectionAttempts в конфиге io().
4. Статус connected: Вместо проверки readyState === 1, используется свойство socket.connected.
Как это влияет на архитектуру:
Твой ReactiveEngine всё так же отлично работает в связке с Socket.io.
Сигналы (messages, status) обновляются внутри коллбэков сокета,
и React-компоненты мгновенно получают новые данные через engine.use().
NOTE: Про комнаты.
1. Для работы с комнатами (Rooms) или пространствами имен (Namespaces) в Socket.io мы будем использовать сигнал currentRoom. Как только его значение меняется, наш effect автоматически переподключит сокет к новой комнате.
В Socket.io есть два пути: Namespaces (разные URL) и Rooms (логическое разделение внутри одного соединения).
Рассмотрим наиболее гибкий вариант с Rooms через emit('join').
2. Как это работает в React
Благодаря тому, что currentRoom — это сигнал,
переключение комнат превращается в одну строку кода,
а всё «тяжелое» переподключение сокета происходит под капотом в движке.
const chat = engine.inject(SimpleChatServiceLogic);
const room = engine.use(chat.currentRoom);
const messages = engine.use(chat.messages);
return (
<div>
<h2>Room: {room}</h2>
<div className="tabs">
<button onClick={() => chat.switchRoom('general')}>General</button>
<button onClick={() => chat.switchRoom('crypto')}>Crypto</button>
<button onClick={() => chat.switchRoom('dev')}>Dev</button>
</div>
<div className="messages">
{messages.map(m => (
<p key={m.timestamp}><b>{m.user}:</b> {m.text}</p>
))}
</div>
<input onKeyDown={(e) => e.key === 'Enter' && chat.sendMessage(e.currentTarget.value)} />
</div>
);
Преимущества такой реализации:
- Изоляция данных: При смене комнаты messages.value = [] гарантирует, что пользователь не увидит сообщения из предыдущего канала.
- Автоматизация: Тебе не нужно вручную вызывать socket.off() или socket.leave(). Функция return внутри effect делает это автоматически при каждом переключении.
- Единый источник истины: currentRoom можно менять из любой части приложения (например, из бокового меню или через URL параметры), и чат сам подстроится.
На этом наша архитектура полностью покрывает работу с реальным временем.
NOTE: Для реализации индикатора печати (Typing Indicator) нам нужно решить две задачи:
- Отправлять событие typing на сервер, когда пользователь вводит текст (с защитой от слишком частых вызовов — Throttling).
- Получать события от других пользователей и отображать их список, автоматически скрывая имя пользователя, если он перестал печатать (через Timeout).
NOTE: Апгрейд до использования комнат и печатающих людей
- Добавим сигнал typingUsers и методы для обработки этого состояния.
Добавим сигнал typingUsers и методы для обработки этого состояния. 2. Использование в React компоненте Теперь мы просто подписываемся на сигнал typingUsers и вызываем уведомление в onChange инпута.
export const ChatInput = () => {
const chat = engine.inject(SimpleChatServiceLogic);
const typingUsers = engine.use(chat.typingUsers);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// Уведомляем сервер, что мы печатаем
chat.sendTypingNotification();
};
return (
<div>
<div style={{ height: '20px', fontSize: '12px', color: 'gray' }}>
{typingUsers.length > 0 && (
`${typingUsers.join(', ')} ${typingUsers.length > 1 ? 'печатают...' : 'печатает...'}`
)}
</div>
<input
type="text"
onChange={handleChange}
placeholder="Введите сообщение..."
/>
</div>
);
};Почему это решение надежное:
- Реактивность: Как только массив typingUsers меняется (добавилось имя или сработал таймер удаления), React-компонент мгновенно обновляет строку состояния.
- Throttling: Мы не спамим сервер событием typing на каждое нажатие клавиши, а отправляем его раз в 2 секунды.
- Авто-очистка: Если у пользователя пропал интернет или он просто закрыл вкладку, не допечатав, через 3 секунды его имя само исчезнет из списка у других участников благодаря setTimeout.
~/services/SimpleChat/types.ts
// Создаем константу типов
export const MessageType = {
Chat: 'chat',
Notification: 'notification',
System: 'system'
} as const;
// Извлекаем типы из значений объекта
export type TMessageType = typeof MessageType[keyof typeof MessageType]
export interface ChatMessage {
type: typeof MessageType.Chat;
user: string;
text: string;
timestamp: number;
message?: string;
ok?: boolean;
}
export interface NotificationMessage {
type: typeof MessageType.Notification;
level: 'info' | 'warning';
text: string;
}
export interface SystemMessage {
type: typeof MessageType.System;
event: 'user_joined' | 'user_left';
userId: string;
}
export type AppMessage = ChatMessage | NotificationMessage | SystemMessage;~/components/SimpleChat.tsx
import { engine } from '~/utils/ReactiveEngine'
import { Button } from '~/components/Button';
import baseClasses from '~/baseClasses.module.scss'
import { Logic as SimpleChatServiceLogic } from '~/services/SimpleChat/Logic.socket.io'
const capitalize = (str: string): string => !!str ? str.charAt(0).toUpperCase() + str.slice(1) : str;
export const SimpleChatService = () => {
const chatStore = engine.inject(SimpleChatServiceLogic);
const room = engine.use(chatStore.currentRoom);
const messages = engine.use(chatStore.messages);
const status = engine.use(chatStore.status);
return (
<div
style={{
borderRadius: '8px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
<b>SimpleChatService | Room: {room} | Socket status: {status}</b>
<div className={baseClasses.gridContainer3}>
{
['general', 'crypto', 'dev'].map((roomName) => (
<Button
key={roomName}
size="large"
variant={room === roomName ? 'contained' : 'outlined'}
color={room === roomName ? 'secondary-gray' : 'secondary'}
onClick={() => chatStore.switchRoom(roomName)}
>
{capitalize(roomName)}
</Button>
))
}
</div>
<ul>
{messages.map((m, i) => <li key={i}>{m.message}</li>)}
</ul>
<Button
size="large"
variant="outlined"
color='primary'
onClick={() => chatStore.sendTestMessage('Hello!')}
disabled={status !== 'open'}
>
Send tst msg
</Button>
</div>
);
};Пару комментариев:
- Метод
engine.inject()работает как Shared State.
При первом вызове он создает экземпляр класса, а при всех последующих (включая ре-рендеры компонента) — просто возвращает ссылку на уже созданный объект из Map. Результат: Переменная chat всегда ссылается на один и тот же объект в памяти. Это не вызывает лишних аллокаций или тяжелых вычислений при обновлении компонента.
- Когда это МОЖЕТ стать проблемой?
Есть два специфических сценария:
- А. Слишком частые вызовы inject в тяжелых циклах. Хотя поиск в Map работает быстро O(1), если компонент рендерится очень часто (например, на каждое движение мыши), вызов
engine.injectкаждый раз — это микро-нагрузка.
Решение: Если компонент гипер-активный, можно обернуть вызов в useMemo, но обычно это лишнее усложнение.
- Б. Проблема "Чистоты" (Side Effects). Если конструктор класса SimpleChatServiceLogic делает что-то тяжелое или запускает сетевые запросы сразу при создании, то первый компонент, который его вызовет, инициирует этот процесс.
Важно: Убедись, что engine.inject не вызывается в теле функции, которая должна быть "чистой" (без побочных эффектов), если ты используешь React Strict Mode.
В Strict Mode компонент вызывается дважды, но так как у тебя синглтон, создастся он всё равно один раз.
Для React это безопасно, так как engine.inject возвращает стабильную ссылку.
Это гораздо эффективнее, чем создавать new SimpleChatServiceLogic() внутри useState или useEffect.