Как React учился батчингу и как научить его «летать»?

Каждый лишний рендер — удар по перформансу. В 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-приложение теперь готово чтобы «взлететь» по-настоящему.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.