Каждый лишний рендер — удар по перформансу. В 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 работать так же производительно как это делает Vue3?
Самый рабочий способ — отобрать у него тяжелую бизнес-логику, для которой он изначально не проектировался и, будем объективны, он ее «не вывозит».
Я пробовал два подхода, оба жизнеспособны:
-
Вынести расчеты в 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-слоя. Забегая вперёд, скажу, что аналогичным образом происходит интеграция в 2D и 3D движками (примеры можно найти в репозитории).
Пример 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')
Резюме
Как видно из примеров, решив проблемы производительности, мы параллельно решили проблемы Абстракции. Изучив исходники, можно обнаружить, что в инструмент заложены классические принципы объектно-ориентированного программирования, адаптированные под реализацию реактивного графа вычислений и построение модульных систем.
В частности, React-приложение теперь готово чтобы «взлететь» по-настоящему.
ссылка на оригинал статьи https://habr.com/ru/articles/1091536/