ESPN DeportesCon qué poquito Chile humilló lo poquito de MéxicoESPNIf they're not starting, they're departing: The QB2 talent drainRTP Desporto12h30 Aviso a CR7: "Jesus não vira cara à luta"The Jerusalem PostMaine Senate candidate Troy Jackson opposes US giving Israel 'blank check'VanguardAFCON title saga: CAS to hear Senegal’s appeal against Morocco on ThursdayZDF heuteEntdecken Sie das ZDF-NachrichtenstudioEgypt IndependentWhistleblower says Trump demanded FBI probe into protesters over a TikTok he sawStraits Times SportMan City should accept punishment for rule breaches, says LinekerEuronewsOne-minute siren sounds in Tel Aviv to mark October 7 anniversaryWirtualna PolskaNie chcieli stać w korku. Wyprzedzali na pasie awaryjnym. Nagranie z Czechn-tvKonzern lässt sich aber Zeit: Deutsche Bahn schränkt Homeoffice drastisch einThe South AfricanTransfer report: Bafana star gains attention in England, Spain, Germany, France and Italy
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.