Каждый лишний рендер — удар по перформансу. В 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-приложение готово к “взлету” по-настоящему.

