Каждый лишний рендер — удар по перформансу. В React с его культом иммутабельности (который тянется с 2013 года) эта проблема знакома каждому, кто пристально наблюдал за тем как ведут себя перф и метрики.
Теория
Путь от костылей к микрозадачам
Под капотом React при каждом изменении стейта строит свежий Virtual DOM для компонента и всех его “детей”, а потом запускает диффинг со старым деревом. Процесс не бесплатный — готовьтесь греть CPU и забивать Main Thread пользователя.
До 18-й версии батчинг нативной автоматикой не отличался. React умел склеивать обновления, только если они происходили внутри его собственных обработчиков событий вроде onClick или onChange. Но стоило завернуть setState в банальный setTimeout, fetch или промис — вся магия рушилась. Логика ломалась, и фреймворк генерировал по отдельному рендеру на каждое микроизменение.
В React 18 подвезли полноценный Automatic Batching на механизме микрозадач, и правила игры наконец-то переписали. Теперь неважно, где вы вызываете setState — синхронные обновления падают в единую очередь и отрабатывают за один UI-тик, даже внутри асинхронных цепочек. Жизнь стала лучше, но если сравнивать React с Vue 3, становится очевидно: этого всё равно мало.
Собственно, эта причина и привела меня к созданию NPM пакета @pravosleva/reactive-engine. Движок построен на Fine-grained reactivity — паттерне, который вырос из классического Functional Reactive Programming образца 1997 года и трансформировался в концепцию Dataflow programming. Если не усложнять теорией, можно привести аналогию работы Excel — обновление одной ячейки триггерит пересчет только связанных вычислений.
Как заставить React работать так же производительно как это делает Vue 3?
Самый рабочий способ — отобрать у него тяжелую бизнес-логику, для которой он изначально не проектировался и, будем объективны, он ее “не вывозит”.
Я пробовал два подхода, оба жизнеспособны:
Вынести расчеты в Web Worker, делаем там что хотим, освободив основной поток.
Вынести механизм вычислений из UI-слоя (который оставляем Реакту) на уровень данных и микрозадач с применение реактивного программирования.
Собственно, ради второго пункта я пилил Reactive Engine — направленный граф вычислений (далее в статье будет фигурировать как Ядро). Со временем движок обзавелся адаптерами для React 18+, Vue 3.2+ (есть даже экспериментальный для Angular 16+). Оставляю ссылку на большой обзор в своем блоге.
«Почему не Reatom, Effector или MobX?» — резонно спросите вы. Ведь авторы этих инструментов уже добились выдающихся результатов в управлении состоянием.
Основная задача была в том, чтобы создать инструмент с околонулевым порогом входа, чтобы за штурвал мог уверенно сесть не только опытный пилот, но и вчерашний студент.
Соответственно, в моем случае сложная задача по оптимизации производительности сводится к простой: Чтобы React летал как Vue 3, нужно забрать у него тяжелые вычисления и переложить их на плоский реактивный граф зависимостей с умным планировщиком транзакций на уровне микрозадач.
«Почему не Vue 3?» — опять же, спросите вы.
И я с вами соглашусь. Vue 3 действительно хорош, т.к. уже из коробки имеет похожую оптимизацию, но она работает на UI-слое. Инструмент о котором я рассказываю, абстрагирован от UI-слоя и “умеет в транзакции” на уровне данных — вот и вся разница.
Представьте, что ваше JavaScript-приложение — это тяжелый ракетоноситель на стартовой площадке. Чтобы сдвинуть с места сложный интерфейс и не просесть под нагрузкой, стандартных инструментов реактивности порой не хватает — основной поток браузера начинает тормозить, а метрики краснеть от стыда и безысходности. Библиотека @pravosleva/reactive-engine создавалась как высокоэффективная силовая установка для фронтенда, задача которой решить проблемы производительности. Вместо громоздких и хаотичных перерисовок она использует высокоточные сигналы, точечно доставляя импульс изменений ровно в те узлы DOM, где он необходим. А встроенная система автобатчинга работает как умная камера сгорания: она объединяет множество мелких обновлений в единый пакет, защищая интерфейс от микрофризов. Далее в статье мы заглянем под капот этого программного двигателя, разберем принципы его “тяги” и посмотрим, как с его помощью можно радикально оптимизировать метрики Core Web Vitals (особенно INP и CLS).
Профит: Разгон Core Web Vitals до зеленых зон
Давайте перейдем к профитам. Ради чего вообще стоило строить реактивный двигатель @pravosleva/reactive-engine и уходить от стандартного реактовского стейта? Ниже — три главные метрики, которые гарантированно выигрывают от мелкозернистой реактивности и автобатчинга:
Interaction to Next Paint (INP). Самая болезненная метрика, место которой VDOM отвел в отдельном котле под названием “Общая отзывчивость интерфейса на клики и ввод”. Поскольку мы подписываемся на атомарные фрагменты состояния, любые обновления идут в обход глобального репроцессинга и сверки дерева React (в соответствии с его философией иммутабельности). Компоненты, которых изменения не коснулись, вообще не тратят ресурсы на рендеринг. Как итог — Main Thread освобождается мгновенно, браузер не залипает на тяжелых задачах, а пользователь видит эффект от процесса Paint сразу после клика.
Cumulative Layout Shift (CLS). Отвечает за визуальную стабильность и реагирует на дерганье верстки. За счет того, что в движок “из коробки” зашит автобатчинг на транзакциях, все связанные вычисления склеиваются в один UI-кадр. Интерфейс перерисовывается строго один раз. Никаких промежуточных “миганий”, недогруженных стейтов и микро-сдвигов макета, которые так бесят пользователей и Lighthouse.
Total Blocking Time (TBT). Лабораторный показатель, который отражает общую отзывчивость страницы. Мелкозернистая реактивность превращает тяжеловесный и монолитный VDOM-диффинг в плоский граф легковесных микрозадач. Время блокировки основного потока падает, длинные таски (те что более 50 мс) исчезают как явление, а интерфейс начинает «летать как самолет».
Итак, связь между автобатчингом (примененным совместно с реактивным подходом в JS), его влиянием на рендеринг и итоговыми показателями Core Web Vitals выглядит следующим образом:
Метрика |
Иммутабельный подход (React/Redux/Context) |
Мутабельный подход |
|---|---|---|
INP |
Высокий из-за избыточного VDOM reconciliation при частых кликах/вводах |
Низкий (обновляются строго изолированные DOM-узлы) |
CLS |
Возможны скачки при рассинхронизации асинхронных задач и UI-рендеров |
Минимальный (строгий батчинг склеивает обновления в один цикл отрисовки) |
TBT |
Высокая (длинный стек вызовов от корня приложения к дочерним элементам) |
Низкая (плоский граф зависимостей, точечные быстрые микрозадачи) |
Практика: Использование на фронте
Все примеры доступны в исходном коде. Их можно запустить в демо-режиме на локалке. Демонстрация кода будет без стилизации для акцента внимания над логикой написания целевого кода.
Небольшое уточнение - в своих примерах я использую:
Node 22.20+
React 18+
Возможно, что-то еще…
Сущности которыми оперирует Ядро движка
Сущность / Метод Ядра |
Назначение |
|---|---|
Сигнал / |
Минимальная неделимая ячейка реактивного состояния (источник истины) |
Вычисляемое свойство / |
“Ленивое” вычисляемое значение, производное от других сигналов |
Эффект / |
Потребитель реактивного графа (не создает новых данных). Это функция, которая автоматически перезапускается каждый раз, когда меняются сигналы или вычисляемые свойства, прочитанные внутри её тела. Эффекты используются для синхронизации состояния с “внешним миром” |
Ресурс / |
Декларативная реактивная обертка над асинхронными операциями (в частности, запросы к API). Из коробки решает проблему Race Conditions: если зависимости изменились до того, как завершился предыдущий сетевой запрос, механизм автоматически отменит его на уровне Ядра. |
«Почему не useEffect?» — спросите вы.
Эффект в
@pravosleva/reactive-engineавтоматически трассирует зависимости. Разработчику больше не нужно руками писать массив зависимостей[userId, query]. Движок по геттерам.valueсам поймет, на что подписаться.
Пример 100: Простой счетчик с вычисляемым удвоенным значением на сигналах в экосистеме React
Исходники примера здесь.
Как видно, бизнес-логика “уехала” из реакт-компонента, оставляя компонент “чистым”:
import { AbstractService } from '@pravosleva/reactive-engine' import { ReactiveEngine } from '@pravosleva/reactive-engine/react' class Logic extends AbstractService { public counter = this.engine.signal(0) public doubledCounter = this.engine.computed(() => this.counter.value * 2) public inc = () => { this.counter.value += 1 } } const engine = new ReactiveEngine() export const Example100 = () => { const logic = engine.inject(Logic) const counter = engine.use(logic.counter) const doubledCounter = engine.use(logic.doubledCounter) return ( <div> <div>Computed</div> <code>{counter} | x2 = {doubledCounter}</code> <div> <button onClick={logic.inc} >INC</button> </div> </div> ) }
«Что это нам дало?» — спросите вы.
Самое важное - Возможность абстрагировать логику не только от Реакта, но и от других разновидностей UI-слоя.
Пример 003: Тот же счетчик в экосистеме Vue 3
Исходники примера здесь.
<script setup lang="ts"> import { AbstractService } from '@pravosleva/reactive-engine' import { ReactiveEngine as ReactiveEngine4Vue } from '@pravosleva/reactive-engine/vue' class CounterLogic extends AbstractService { public counter = this.engine.signal<number>(0, 'vue-example:counter'); public inc = () => { this.counter.value += 1 } } const engine = new ReactiveEngine4Vue() const logic = engine.inject(CounterLogic) const counter = engine.use(logic.counter) </script> <template> <div> <div>Vue 3 Signal Example</div> <code>{{ counter }}</code> <div> <button @click="logic.inc">INC</button> </div> </div> </template>
Пример 205: Абстрагированный сервис для ГдеБенз API и Leaflet
Исходники примера здесь.
В этом примере при перемещении карты происходит запрос за АЗС, маркеры которых находятся в видимой области.
Кстати, можно заметить, обсуждение бизнес-логики абстагировано в плоскость ненавязчивого ООП, без привязки к Реакту:
import L from 'leaflet' import 'leaflet/dist/leaflet.css' import 'leaflet.markercluster' import 'leaflet.markercluster/dist/MarkerCluster.css' import 'leaflet.markercluster/dist/MarkerCluster.Default.css' import { AbstractService, withDebounce, withStaleWhileRevalidate } from '@pravosleva/reactive-engine' export interface Station { id: number name: string title: string lat: number lng: number slug: string } export class MapLogic extends AbstractService { public bbox = this.createSignal<string>('44.2097,33.2144,45.8785,34.9832', 'example-205:map:signal:bbox') public stationsResource = this.engine.resource( withDebounce( withStaleWhileRevalidate( async (bboxValue, abortSignal) => { const url = new URL('/gdebenzin-vite-proxy/api/v1/stations', window.location.origin) url.searchParams.append('bbox', bboxValue) const res = await fetch(url.toString(), { signal: abortSignal, headers: { 'Accept': 'application/json' } }) if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`) return res.json() as Promise<Station[]> }, { initialData: [] } ), { delay: 500 } ), this.bbox, { name: 'map:resource:fetch-stations', validateBeforeFetch: (bboxValue) => !!bboxValue, } ) private validStationsSignal = this.createSignal<Station[]>([]) private markerCache = new Map<number, L.Marker>() private displayedMarkers = new Set<L.Marker>() public activeStationId = this.createSignal<number | null>(null) private map: L.Map | null = null private clusterGroup: L.MarkerClusterGroup | null = null private effectCleanup: (() => void) | null = null private globalPopup: L.Popup | null = null public markers = this.engine.computed<L.Marker[]>(() => { const stations = this.validStationsSignal.value const currentIds = new Set(stations.map(s => s.id)) for (const cachedId of this.markerCache.keys()) { if (!currentIds.has(cachedId)) { this.markerCache.delete(cachedId) } } return stations .filter(station => station.lat && station.lng) .map(station => { if (this.markerCache.has(station.id)) { return this.markerCache.get(station.id)! } // Демонстрация контроля перехвата открытия popup (вместо вызова метода bindPopup на маркере как это задумано в библиотеке leaflet) const newMarker = L.marker([station.lat, station.lng]) // Перехватываем клик по маркеру newMarker.on('click', (e) => { L.DomEvent.stopPropagation(e) this.openGlobalPopupForStation(station) }) this.markerCache.set(station.id, newMarker) return newMarker }) }) public initializeMap = (container: HTMLDivElement) => { if (this.map) return const [south, west, north, east] = this.bbox.value.split(',').map(Number) const bounds = L.latLngBounds([south, west], [north, east]) this.map = L.map(container).fitBounds(bounds) L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors' }).addTo(this.map) // Создаем независимый инстанс глобального попапа // ... // Следим за тем, когда пользователь закрывает попап крестиком // ... // Перехватываем клики по маркерам, добавляем слой, обрабатываем завершение "перетаскивания" карты // ... // Реактивный эффект для удержания попапа на карте при обновлении данных this.engine.effect(() => {/* ... */}, 'map:effect:keep-popup-alive') } // Логика открытия глобального независимого попапа private openGlobalPopupForStation(station: Station) {/* ... */} public destroyMap = () => {/* ... */} // Чистый, стандартный метод синхронизации слоев без костылей с вырезанием маркеров private syncClusterLayers(nextMarkers: L.Marker[]) {/* ... */} private handleMapMoveEnd = () => {/* ... */} }
Документация доступна на русском и постепенно развивается.
Под капотом библиотеки есть специальные декораторы для расширения базового функционала, и мы плавно переходим к следующей теме.
Расширение функционала: Декораторы
Давайте посмотрим, как можно организовать работу с декораторами, поставляемыми в составе библиотеки (полный список декораторов в составе библиотеки движка доступен в документации и периодически дополняется).
Декораторы в JavaScript — это специальные функции, которые позволяют изменять или расширять поведение классов, их методов, свойств или обычных функций без изменения их исходного кода.
Мы возьмем для примера декоратор withCache (мемоизация) — это важнейший инструмент оптимизации, который применяется в сценариях с редко изменяющимися данными, когда нам необходимо полностью исключить повторные паразитные запросы к серверу при циклическом обращении к одним и тем же параметрам стейта.
В отличие от дебаунса и троттлинга, которые управляют временной частотой вызовов, кэширование управляет хранением данных. Если для конкретного ключа зависимостей в оперативной памяти уже лежит свежий ответ, декоратор возвращает его за 0 миллисекунд, полностью отменяя сетевую активность.
Посмотрим же, как использовать кэширование ресурса с двумя сигналами: Просто оберните ваш fetcher в функцию withCache. Всё остальное взаимодействие с сигналами остаётся прежним.
import { engine } from './yourEngineInstance' import { withCache } from '@pravosleva/reactive-engine' const userIdSignal = engine.signal(1, 'userId'); const tabSignal = engine.signal<'posts' | 'photos'>('posts', 'tab'); const userTabDeps = engine.computed(() => { return [userIdSignal.value, tabSignal.value] }) export const cachedUserResource = engine.resource( withCache( async ([userId, tab], abortSignal) => { const res = await fetch(`https://api.example.com/${userId}/${tab}`, { signal: abortSignal, }) if (!res.ok) throw new Error('Ошибка загрузки данных') return res.json() }, { ttl: 30 * 1000 } // Настройка времени жизни кэша: 30 секунд (в миллисекундах) ), userTabDeps, 'cachedUserResource' )
Следовало ожидать, возможности инструмента могут выходить за рамки использования на фронте, поэтому далее предлагаю обратить внимание на то, какие еще задачи он может решать.
Практика: Использование на бэке
Рассмотрим простой демонстрационный пример. Допустим, у нас есть существующий JS-рантайм (в виде BFF и не только), в котором происходит обращение по API за данными, которые редко меняются. Соответственно, мы можем добавить кэширование этих данных и мутировать объект запроса для использования в других миддлварах. Да, могли бы сделать фабрику для создания таких объектов кэширования, но для примера будет достаточно и этого:
expressApp.use('/api-with-internal-cache', (req: IRequest, res: IResponse, next: INextFunction) => { req.slugMapCacheInstance = slugMapCacheInstance next() })
Чтож, осталось дать описание новой логики в виде простейшего ООП:
import { ReactiveEngine, withCache, Resource } from '@pravosleva/reactive-engine' import path from 'path' import { universalHttpClient } from '~/srv.utils/universalHttpClient' import { ILocalSlugItem } from './types' export type TLocalSlugMap = Record<string, ILocalSlugItem> // В моем случае используется Next с hot reload в dev-режиме, это чтоб не плодить счетчики для моего случая: declare global { var __slugMapCacheServiceInstance: SlugMapCacheService | undefined var __slugMapCacheInterval: NodeJS.Timeout | undefined } class SlugMapCacheService { public engine: ReactiveEngine public slugMapResource: Resource<TLocalSlugMap> private lastUpdatedTimestamp: number = 0 private constructor() { this.engine = new ReactiveEngine() const cacheTriggerSignal = this.engine.signal(0) const cacheDeps = this.engine.computed<[number]>(() => [cacheTriggerSignal.value]) // Подписка на сигнал-триггер: this.slugMapResource = this.engine.resource( withCache( async ([triggerValue], abortSignal) => { const mapResult = await universalHttpClient.get<TLocalSlugMap>( '/static/local.slug-map.json', { signal: abortSignal } ) if (!mapResult.isOk || !mapResult.response) { throw new Error(mapResult.message || 'Не удалось загрузить local.slug-map.json') } return mapResult.response }, { ttl: 1 * 60 * 60 * 1000 } ), cacheDeps ) this.engine.effect(() => { const state = this.slugMapResource.value if (state && state.data && Object.keys(state.data).length > 0) { this.lastUpdatedTimestamp = Date.now() console.log(`Таймстамп кэша обновлен движком: ${new Date(this.lastUpdatedTimestamp).toLocaleTimeString()}`) } }) // Чистим старый таймер, если он остался от Hot Reload в Next.js if (global.__slugMapCacheInterval) clearInterval(global.__slugMapCacheInterval) // Инициатор изменения главного сигнала: global.__slugMapCacheInterval = setInterval(() => { cacheTriggerSignal.value += 1 }, 30 * 60 * 1000) } public static getInstance(): SlugMapCacheService { if (!global.__slugMapCacheServiceInstance) { global.__slugMapCacheServiceInstance = new SlugMapCacheService() } return global.__slugMapCacheServiceInstance } public updateTimestampDirectly(): void { this.lastUpdatedTimestamp = Date.now() } // ... } export const slugMapCacheInstance = SlugMapCacheService.getInstance()
В результате, мы имеем Синглтон с экземпляром движка (который будет инициализирован при запуске сервера), что следит за актуальностью кэша, который будет доступен в отдельном поле объекта запроса для следующих обработчиков.
Из множества идей применения: Можно создать инстанс движка как сервис, обеспечивающий сокет-соединение на серверной стороне с другой серверной стороной для обмена информацией.
Резюме
Как видно из примеров, решив проблемы производительности, мы параллельно решили проблемы Абстракции. Изучив исходники, можно обнаружить, что в инструмент заложены классические принципы объектно-ориентированного программирования, адаптированные под реализацию реактивного графа вычислений и построение модульных систем.
В частности, после перехода на реактивный подход, React-приложение готово к “взлету” по-настоящему.
Комментарии (23)

DmitryKazakov8
07.10.2026 16:04Ну очень много маркетинга и ложных обещаний при довольно банальной реализации… Посмотрел код репо, тестов очень мало, но даже при чтении наискосок много всего видно.
“Fine-grained reactivity”, “импульс изменений ровно в те узлы DOM” - нет, ререндеры Реакта через setState либо useSyncExternalStore, это ререндер всего компонента, а не конкретных узлов как в preact-signals например (но они это делают очень костыльно, кладут интерфейс реактового компонента в прототип сигнала, то есть по факту создают в дереве новые компоненты)
На каждую переменную делать
engine.use- выглядит совсем не удобно, представьте компонент-портянку с сотней таких ручных подписок.computed обновляется асинхронно через микротаску, и если не делать await Promise.resolve() после изменения то можно получить старые данные. Кейс редкий конечно, но очень внезапный, особенно для тех, кто работал со взрослыми системами реактивности
unsubscribeEffect = this.effect(…)- судя по всему computed из-за этого не ленивый, то есть пересчитывается даже если никто не читает. Могу ошибаться, читаю наискосок, но подозрительно.В динамических подписках типа
effect(() => Math.random() > 0.5 ? store.a : store.b)не факт что очистится лишняяЕще сомнительно выглядит батчинг в плане оптимизации, трекинг изменений в массивах / Map / Set тоже под вопросом и т.п.
На просторах интернета сотни, если не тысячи подобных библиотек, и полезны скорее для саморазвития. Не понятно, зачем еще одна.

pravo-sleva Автор
07.10.2026 16:04Ну очень много маркетинга и ложных обещаний
Ничего из этого, но спасибо. Отвечу по пунктам (возможно, не за раз)
Поинт 1. Это Аргумент. Но задача движка разгрузить UI-слой за счёт: 1) изоляции графа зависимостей от React/etc/whatever чтобы выжать из него максимум. И если мы про React - то цель достигнута. На это есть намек в самом начале статьи, 2) Если у вас изменилось некое вычисляемое свойство в бизнес-логике, граф пересчитает его за O(1). React-компонент, который отображает это свойство, стриггерится через useSyncExternalStore и ререндерится. Но все остальные компоненты приложения, которые не подписаны на этот конкретный узел, не ререндерятся.

pravo-sleva Автор
07.10.2026 16:04Поинт 2.
Про "портянку хуков" - разумеется, так делать не нужно. Попробуйте метод
reactive(и это только один из вариантов)⛔ Не для Vue!

DmitryKazakov8
07.10.2026 16:04В статье вот описан
const counter = engine.use(logic.counter), и тут еще один нюанс - например если в jsx будет<div>{props.data === 1 ? counter : null}</div>то компонент все равно будет ререндериться при изменении counter в сторе, т.к. уже подписан ручным хуком, верно? То есть в этом случае не "выжимается максимум", а наоборот - снижается перф, создаются лишние ререндеры (возможно - всего дерева компонентов, если это где-нибудь в App).Для чего вообще use, если с ним нужно настолько осторожно обращаться, чтобы себе в ногу не выстрелить. Оставили бы как в mobx или kr-observable только внешний враппер вообще без хуков и ручных подписок.

pravo-sleva Автор
07.10.2026 16:04А вы прям с лопатой )) Да, если хук вызван, компонент перерисуется в любом случае. Но помилуйте, милостивый государь, пример из статьи с
engine.use(logic.counter)- это просто демонстрация базового API для примитивов. Методuseумеет принимать объект или инстанс класса целиком, возвращая Proxy.const state = engine.use(myStore) return ( <div> {props.data === 1 ? state.counter : null} </div> )Ох тыж, тут кодить можно.
Собсна, вот где фича:
Если
props.data !== 1React выполняет веткуnull. Геттерstate.counterне вызываетсяДвижок видит, что свойство
counterне было прочитано в текущем кадре рендера, и НЕ подписывает компонент на измененияcounterЕсли
counterизменится в сторе, компонент НЕ будет ререндериться. Лишнего ререндера (тем более всего дерева App) не произойдет! Максимум перформанса выжимается автоматически
Про MobX - отдельная тема. Чуть позже прокомментирую.

pravo-sleva Автор
07.10.2026 16:04Комментируя подход MobX, я должен упомянуть фрагмент из официальной доки React, в котором говорится, что
useSyncExternalStore- ничто иное, как официальный стандарт подписки на внешнее состояние, и что он, мол, типа, гарантирует 100% предсказуемость поведения аппки в ряде бытовых условий. Я, кстати, пробовал создать аналогичный экспериментальный "магический враппер" который, кстати, даже работал, но в итоге отказался от этого. Вы спросите, "Почему?" Строго говоря, потому что в задачу движка изначально это поведение не входило. Ну и увеличение бандла в x10, думаю, того не стоит (это я пока просто предполагаю, есть основания).The current snapshot of the store which you can use in your rendering logic.
Но, кстати заметить, даже с этим производительность интерфейса существенно повысилась.

pravo-sleva Автор
07.10.2026 16:04(* аналогичный экспериментальный) - это уже про аналог MobX далее по тексту.
ekkebeatovich
Допустим, но что именно в reactive-engine делает порог входа околонулевым? Например по сравнению с reatom — в нем я больше всего разбираюсь. Сейчас я вижу с точки зрения порога входа не то чтобы нуль, но точно выше reatom, а сам reatom, в свою очередь, не совсем даже сопоставим с порогом входа zustand, который является эталоном нулевого входа
pravo-sleva Автор
Добрый вечер, коллега. Все наши рассуждения субъективны, а следовательно, относительны. Ни в чью сторону не кидаю камни (если это не какой-нибудь динозавр, переживший свое время - хотя, из опыта, в отдельных случаях можно сделать скидку), и не следует забывать про то, что способов решить любую математическую задачу всегда больше одного. В этой статье рассказано про инструмент с упором на производительность и простоту использования. Понимаю вашу логику: если эталон нулевого входа Zustand (где мы просто пишем плоский объект и функции-мутации), то Reatom кажется лаконичным. Отвечая на ваш вопрос, под "околонулевым порогом" в данном случае имелся ввиду полный отказ от функционально-декларативного стиля в пользу классического ООП. Ну и (опять же, субъективно), всегда пробрасывать контекст - по мне, так немного сквозной бойлерлейт, а вызов специальных функций для изменения стейта намекает на иммутабельный подход: в моем случае подход другой (поэтому дальше сравнивать смысла нет, если только не в разрезе производительности - до этого ещё дойдем в будущем).
Резюмирую ответ, в чем лично я вижу удобство ReactiveEngine в контексте нашей дискуссии:
Императивный подход без привыкания к синтаксису библиотеки (что, по мне, так позволяет быстро переключаться с React на Vue и т.д.) - здесь можно углубиться в рассуждениях, к примеру, я считаю, создание микростора должно быть в руках разраба, а не под чужим капотом, но эти вещи тоже можно считать вкусовщиной
Отсутствие сквозного контекста
При удалении инстанса класса сборщик мусора отработает в соответствии с тестами (здесь с Reatom ещё не сравнивал)
Логирование прозрачно (здесь с Reatom ещё не сравнивал)
ekkebeatovich
Сквозного контекста больше нет, это версия двухгодичной давности. Рекомендую покурить свежую доку реатома, где изложен современный синтаксис. Я так понимаю, информация про явный контекст пришла из разбора ИИ. Вот актуальный синтаксис:
По поводу императивного подхода без привыкания к синтаксису библиотеки — это достаточно непонятный аргумент. Не понятно что такого reactive-engine дает, что привыкать к этому не надо. В этом плане разве что mobx/kr-observable даёт некоторую "нативность", но точно не факт использования классов. Да и классическим ОПП в js классы нельзя назвать, классика — это то что было до классов, то есть функциональный стиль, чему и следует реатом.
3-4 пункты — в реатоме с этим все ок, логгер даже показывает графы причинности изменений (то есть рисует что от чего зависило при изменении атома или от чего вызван экшен)
Таким образом, я не увидел каких-то стойких и хорошо сформулированных аргументов. Может есть еще какие-то поинты?
pravo-sleva Автор
Давайте по порядку. В ваших утверждениях не все логично. Допустим, с обновленным синтаксисом Реатома - ок, спасибо за уточнение.
Попробую перефразировать то что скрыто между строк в моем предыдущем ответе ) Классы дают нативную инкапсуляцию свойств и методов. Разработчику не нужно привыкать к функциям библиотеки (atom, computed, action) - и да, в ReactiveEngine есть аналоги, но вы их используете один раз при объявлении свойств класса, дальнейшее взаимодействие происходит на чистом js/ts (императивный, мутабельный подход без "специй", если вы понимаете о чем я).
Похоже, вы путаете две вещи: Классику JavaScript - исторический функционально-прототипный стиль языка и классическое ООП - парадигму проектирования на основе классов (как в других языках). Когда говорят "классическое ООП", имеют в виду архитектурный паттерн, а не особенности реализации прототипного наследования под капотом JS образца ES6 (советую называть это синтаксическим сахаром - так вам будет проще отличать одно от другого). JavaScript-классы — это полноценная реализация классического ООП с точки зрения проектирования (инкапсуляция, наследование, полиморфизм).
Аргумент про MobX - ок, без проблем. Здесь тоже есть различия в подходах - можно сравнить их, и размеры бандлов, если очень хочется.
Про относительность суждений и взглядов я уже говорил, если вас все устраивает в том инструменте, к которому вы привыкли - используйте его, если он покрывает ваши задачи.
У меня нет задачи обратить вас в иную веру, или переучить чему-либо кого-то из оппонентов, если что.
ekkebeatovich
Замыкания тоже дают нативную капсуляцию свойств и методов. Не понимаю преимуществ классового подхода в этом плане. А то что разработчикам нужно привыкать к функциями библиотеки — ну как бы да, это в любой библиотеке надо привыкать, но засчитывать это за порог входа пожалуйста не надо, это плохая метрика измерения порога входа.
в любой библиотеке так происходит, в очередной раз — это нельзя засчитать за критерий. Даже если в одном случае используются нативные геттеры и сеттеры, а в другом явные .set и call operator, какой-то существенной разницы а этом нет для порога входа, разница только в определенных ейджкейсах (например, нативные сеттеры не поддерживают optional chaining при доступе к сеттеру)
ООП — это не парадигма программирования на основе классов, это ни в каком смысле не валидно, только если не виде вульгарного упрощения. Классы это один из вариантов как ООП может проявляется, но не единственный.
Я по-прежнему не услышал каких-то четких валидных критериев, по которым можно определить, что reactive-engine прост для новичков, поэтому я дам своих тейков почему до простоты далеко:
1) resource — в целом лишний термин и абстракция. Его можно было реализовать как асинхронный компьютед
2) resource и computed на самом деле не ленивые, то есть банально нельзя сделать кверю, которая дернится при первом монтировании компонента без дополнительного погружения в DI систему которая даёт ленивость и которая новичку входу сразу повышает
3) signal vs reactive — лишняя дихотомия, которая создает замешательство "а какой реактивный примитив мне применить к этой задаче?" Между друг другом очевидно они не стыкуются, на reactive нельзя отдельно подписаться, еще есть куча всяких ограничений на нем же. Это очевидное повышение порога входа на ровном месте, когда можно было просто ограничиться одним signal и не создавать второй способ делать одно и тоже. Если это было такое решение проблемы глубокой реактивности — то например реатом это элегантно решает без лишних абстракций за счет поддержки вложенных атомов друг в друга (атомизация)
в целом уже много противоречий и запутанностей, которые ничем не компенсируются и об простом входе речи уже не идет
pravo-sleva Автор
Я вынужден вас периодически возвращать к тейку про относительность. Привыкать к API нужно везде, тут спора нет. Но одно дело - выучить 3 "декоратора" для разметки класса и дальше писать обычный императивный код, и совсем другое - полностью перестроить мышление на функционально-реактивный конвейер данных. Именно эту разницу в ментальном сдвиге мы и называем "околонулевым порогом".
Вычисляемое свойство обязано быть синхронным и чистым, чтобы гарантировать O(1). (опровергните, плз)
Здесь вообще мимо. В коде ядра все computed и resource ленивые изначально (опровергните, плз).
Метод reactive - это просто синтаксический сахар, который под капотом пробегается по объекту и превращает его свойства в те же самые сигналы (опровергните, плз).
Вы много придумали невероятных фактов, из-за которых пакет никогда бы не публиковался, посмотрите код ещё раз, плз. В этот раз чёт все мимо )
Вы чуть выше сказали, что не видите стойких аргументов (напомню про относительность суждений), а я, в свою очередь, не вижу с вашей стороны понимания, как он работает (тут ничего страшного нет, я иного не ожидал в первый день).
ekkebeatovich
ну во-первых, чистым быть не обязана на мой взгляд, это просто популярная условность. Во-вторых, если компьютед возвращает промис, а не какое-то другое значение, то это не делает вычисление синхронным - создание промиса синхронно. В-третьих, как тут O(1) вообще связано с обсуждаемым вопросом? Если в компьютеде выполняется вычисление с вложенным циклом, как reactive-engine гарантирует O(1)?
тесты говорят обратное:
то есть, компьютед/ресурс даже не прочитали еще, а он уже может перевычислится, а ресурс еще может и сайдэффект запустить раньше времени, как его прочитают. То есть тут даже ленивый DI на самом деле не поможет.
по сути автор должен знать свой же код, верно? "те же сигналы" там не создаются, это отдельная сущность, совместимая с сигналами из движка. Следовательно, синтаксическим сахаром быть это и не может, ну просто другая семантика под капотом, даже если и похоже на те же сигналы.
Пример в отличии семантики:
подписки текут после остановки эффекта, а вот с сигналами бы это уже не прошло (тоже проверял - предлагаю написать тесты в ядре на это поведение)
отдельно подписаться - нельзя =), напрямую заюзать через
engine.useнельзя (нужен прокси в виде компьютеда как мост, который наверное тоже никак на порог входа не влияет и бойлерплейт не добавляет), всякие.pushтоже не работают (во vue/mobx в тоже время работает),.lengthне отслеживается если массив растет. То есть ограничения все же есть. Но при этом да, признаю, сигналы и реактивны норм стыкуются, перепроверил.Кстати, предлагаю сюда податься https://github.com/johnsoncodehk/reactive-framework-test-suite чтобы пофиксить все проблемы в реактивном ядре, заодно будет интересно и на результаты глянуть