Как 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?

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

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

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

ссылка на оригинал статьи https://habr.com/ru/articles/1091536/