ESPNDivision series: What Guardians, Yankees, Braves, Padres must do to stay aliveThe Jerusalem PostMaine Senate candidate Troy Jackson opposes US giving Israel 'blank check'PunchTinubu mourns NADECO chieftain Ralph ObiohaUN NewsWHO releases first global guidelines on child obesity as cases surgeInquirerDENR’s ‘Permitting on Wheels’ serves over 10,000 in first 5 monthsReality BlurredOn DWTS 35, let’s Old Yeller the Guillermo vote and boring pro-pro couplesZDF heuteAktuelle Pressemitteilungen des ZDFVarietyKiller Films Boards Ray Panthaki’s Directorial Debut ‘In Starland’ as Film Lands Tallinn World Premiere (EXCLUSIVE)VanguardOscar winner Eva Marie Saint dies at 102Antara NewsIndonesia pushes for fairer copyright royalties in digital economyObservador DesportoMette-Marit ausente de compromissos oficiais até ao verãoSCMP China‘No more dumping, no more spying’: European Parliament hardens line on China
The Daily Newsstand · Free, Always
Wednesday, October 7, 2026

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

Translate

Каждый лишний рендер — удар по перформансу. В 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?

Самый рабочий способ — отобрать у него тяжелую бизнес-логику, для которой он изначально не проектировался и, будем объективны, он ее «не вывозит».

Я пробовал два подхода, оба жизнеспособны:

  1. Вынести расчеты в Web Worker, делаем там что хотим, освободив основной поток.

  2. Вынести механизм вычислений из 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+

  • Возможно, что-то еще…

Сущности которыми оперирует Ядро движка

Сущность / Метод Ядра

Назначение

Сигнал / signal

Минимальная неделимая ячейка реактивного состояния (источник истины)

Вычисляемое свойство / computed

«Ленивое» вычисляемое значение, производное от других сигналов

Эффект / effect

Потребитель реактивного графа (не создает новых данных). Это функция, которая автоматически перезапускается каждый раз, когда меняются сигналы или вычисляемые свойства, прочитанные внутри её тела. Эффекты используются для синхронизации состояния с «внешним миром»

Ресурс / resource

Декларативная реактивная обертка над асинхронными операциями (в частности, запросы к 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-приложение теперь готово чтобы «взлететь» по-настоящему.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.