У високонавантажених вебзастосунках продуктивність інтерфейсу визначається не абстрактною швидкістю виконання циклів JavaScript, а передбачуваністю часу візуального відгуку на дії користувача. Коли користувач натискає кнопку, перемикає вкладку або вводить текст у поле пошуку, затримка між фізичним натисканням і появою оновленого кадру на екрані безпосередньо впливає на конверсію та сприйняття продукту.
З 2024 року ключовою метрикою чуйності Core Web Vitals у Google стала INP (Interaction to Next Paint). Якщо час відгуку перевищує 200 мс, сайт потрапляє до червоної зони Google, втрачаючи пошуковий трафік і довіру користувачів.
Головною причиною деградації INP у застосунках на React є холості ререндери (Render Waste). Коли батьківський компонент оновлює свій стан, за замовчуванням React каскадно викликає функції всіх дочірніх компонентів униз по дереву, блокуючи головний потік браузера (Main Thread) на десятки й сотні мілісекунд.
Сліпа оптимізація «на око» (хаотичне розставляння useMemo та React.memo по всьому проєкту) лише погіршує ситуацію, додаючи накладні витрати на порівняння пропсів та алокацію пам'яті. Єдиний системний інженерний шлях — інструментальне профілювання за допомогою React DevTools Profiler, діаграм Flamegraph, звітів Ranked Chart та браузерного LoAF API.
В архітектурі React 19 фаза обчислень розділена на дві принципові стадії:
Користувацьке введення (Клік / Тап / Введення символу)
│
▼
[Браузерний Event Loop]
│ ──► [Input Delay] (Очікування звільнення Main Thread)
▼
[React 19 Render Phase]
│ ──► Виклик функціональних компонентів, дифінг Fiber-дерева
│ ──► actualDuration: тривалість чистого обчислення дерева
▼
[React Commit Phase]
│ ──► Синхронна мутація реального DOM (appendChild, removeChild)
│ ──► Синхронні useLayoutEffect
▼
[Browser Lifecycle: Paint Phase]
│ ──► Recalculate Styles ──► Layout (Reflow) ──► Composite
▼
[Next Paint / Кадр на екрані]
│
└─► Тривалість циклу понад 50 мс = Long Frame (Деградація INP!)Метрика INP декомпозується на три складові:
INP = Input Delay + Processing Duration + Presentation Delay
React DevTools Profiler фіксує тривалість фаз Render і Commit для кожного записаного коміту. Ключові терміни профілювальника:
actualDuration / baseDuration демонструє ефективність оптимізацій.Активуйте чекбокс 'Record why each component rendered', увімкніть запис і відтворіть рівно одну ізольовану дію (клік або введення).
Знайдіть коміт із найбільшою тривалістю. У режимі Ranked Chart визначте компонент із найбільшим actualDuration.
Клікніть на компонент-винуватець і перегляньте підказку DevTools: чи змінилися пропси, локальний стан, чи рендер викликано батьком.
Запишіть профіль з обмеженням CPU 4x Throttling для виявлення Long Animation Frames (>50 мс) та відокремлення виконання скриптів від Layout.
Застосуйте спуск стану вниз (colocation), підйом контенту (children as slots) або мемоїзацію, після чого повторно заміряйте actualDuration.
У профілювальнику React DevTools у важкого компонента таблиці зафіксовано показники: actualDuration = 0.2 мс, baseDuration = 145 мс. Про що свідчать ці значення?
<Profiler> із системами телеметрії (Sentry, Datadog, OpenTelemetry) для виявлення повільних комітів у 99-го перцентиля (p99) аудиторії.Всередині вузла Fiber React зберігає часові мітки комітів, якщо рантайм зібрано з прапорцем enableProfilerTimer. Структура Fiber доповнюється спеціалізованими полями:
// Концептуальна структура таймінгів усередині FiberNode (React Fiber Internals)
interface ProfilerFiberTiming {
// Фактичний час рендеру вузла в поточному коміті (мілісекунди)
actualDuration: number;
// Позначка часу початку рендерингу (performance.now())
actualStartTime: number;
// Базова оцінка часу повного рендеру піддерева з нуля
treeBaseDuration: number;
// Посилання на альтернативний стан Fiber (current vs work-in-progress)
alternate: ProfilerFiberTiming | null;
}У стандартному production-бандлі react-dom ці поля видаляються компілятором задля економії розміру бандла та усунення накладних витрат на часті виклики performance.now(). Щоб увімкнути збір профілів у продакшені, необхідно налаштувати аліас на react-dom/profiling:
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
webpack: (config, { isServer }) => {
// Підміняємо react-dom на профільовану збірку лише для клієнтського коду
if (!isServer) {
config.resolve.alias = {
...config.resolve.alias,
'react-dom$': 'react-dom/profiling',
'scheduler/tracing': 'scheduler/tracing-profiling',
};
}
return config;
},
};
export default nextConfig;Під час аналізу сесії профілювальник React DevTools надає дві взаємодоповнюючі проекції:
actualDuration. Це експрес-інструмент для миттєвого виявлення найважчого вузла без необхідності розгортати глибокі гілки дерева.У налаштуваннях DevTools Profiler критично важливо увімкнути чекбокс: «Record why each component rendered while profiling». Це змушує React зберігати попередні пропси й стан вузла та виводити причину ререндеру у спливній підказці:
Props changed: [items, onClick]State changed: [filterQuery]Hook 3 changedParent component renderedПоширеною проблемою аналізу Flamegraph є безіменні сірі смуги з написом Anonymous або _c. Це трапляється під час експорту анонімних стрілкових функцій усередині HOC (memo, forwardRef):
// ❌ Анонімний компонент: у DevTools відображатиметься як Anonymous або _c
export const ProductCard = React.memo(({ item }: { item: Product }) => {
return <div>{item.title}</div>;
});
// ✅ Іменований компонент із явним displayName для DevTools
const ProductCardComponent = ({ item }: { item: Product }) => {
return <div>{item.title}</div>;
};
ProductCardComponent.displayName = 'ProductCard';
export const ProductCard = React.memo(ProductCardComponent);<Profiler> та інтеграція з LoAF API#React надає вбудований компонент <Profiler id="..." onRender={...}>, що дозволяє збирати метрики рендерингу програмно безпосередньо у продакшені.
Колбек onRender фіксує actualDuration, baseDuration та фазу рендеру (mount / update).
PerformanceObserver типу 'long-animation-frame' перехоплює браузерні кадри тривалістю понад 50 мс.
Зіставлення часових міток коміту React із блокуючими задачами браузера.
Надсилання батчу метрик в аналітику через navigator.sendBeacon без блокування Main Thread.
Команда оптимізувала React-компонент, скоротивши actualDuration з 80 мс до 4 мс, однак метрика INP на реальних користувачах залишилася на рівні 220 мс. Що є найбільш імовірною причиною?
Нижче наведено готовий виробничий компонент для відстеження тривалості рендерингу та перехоплення довгих кадрів (LoAF) із типобезпечним інтерфейсом:
import React, {
Profiler,
type ProfilerOnRenderCallback,
useEffect,
useRef,
type ReactNode,
} from 'react';
export interface PerformanceTelemetryPayload {
componentId: string;
phase: 'mount' | 'update';
actualDuration: number;
baseDuration: number;
startTime: number;
commitTime: number;
longFrameDetected: boolean;
blockingDuration?: number;
}
interface PerformanceProfilerProps {
id: string;
thresholdMs?: number;
onSlowRender?: (payload: PerformanceTelemetryPayload) => void;
children: ReactNode;
}
export const PerformanceProfiler: React.FC<PerformanceProfilerProps> = ({
id,
thresholdMs = 16.6,
onSlowRender,
children,
}) => {
const lastRenderTimeRef = useRef<number>(0);
useEffect(() => {
// Перевіряємо підтримку Long Animation Frames API (LoAF) у браузері
if (typeof window === 'undefined' || !('PerformanceObserver' in window)) {
return;
}
let observer: PerformanceObserver | null = null;
try {
const supportedTypes = PerformanceObserver.supportedEntryTypes;
if (supportedTypes.includes('long-animation-frame')) {
observer = new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
const loafEntry = entry as PerformanceEntry & {
duration: number;
blockingDuration: number;
};
// Якщо час коміту React перетинається з вікном Long Frame
if (
lastRenderTimeRef.current >= entry.startTime &&
lastRenderTimeRef.current <= entry.startTime + entry.duration
) {
console.warn(
`[LoAF Alert] Компонент <${id}> спричинив Long Frame: ` +
`тривалість ${loafEntry.duration.toFixed(1)} мс, ` +
`блокування потоку: ${loafEntry.blockingDuration.toFixed(1)} мс`
);
}
}
});
observer.observe({ type: 'long-animation-frame', buffered: true });
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown LoAF error';
console.warn('LoAF Observer initialization failed:', message);
}
return () => {
observer?.disconnect();
};
}, [id]);
const handleRender: ProfilerOnRenderCallback = (
profilerId,
phase,
actualDuration,
baseDuration,
startTime,
commitTime
) => {
lastRenderTimeRef.current = commitTime;
if (actualDuration >= thresholdMs) {
const payload: PerformanceTelemetryPayload = {
componentId: profilerId,
phase,
actualDuration,
baseDuration,
startTime,
commitTime,
longFrameDetected: actualDuration > 50,
};
if (onSlowRender) {
onSlowRender(payload);
} else {
console.warn(
`[Slow Render] <${profilerId}> (${phase}) зайняв ${actualDuration.toFixed(2)} мс ` +
`(Базова складність дерева: ${baseDuration.toFixed(2)} мс)`
);
}
}
};
return (
<Profiler id={id} onRender={handleRender}>
{children}
</Profiler>
);
};Розробники помічають у DevTools компонент з actualDuration = 0.04 мс, який рендериться під час кожного оновлення стану, і поспішно огортають його в React.memo та useMemo.
// ❌ Антипатерн: накладні витрати на порівняння пропсів перевищують час самого рендеру
interface StatusBadgeProps {
label: string;
}
export const StatusBadge = React.memo(({ label }: StatusBadgeProps) => {
return <span className="badge">{label}</span>;
});
// ✅ Правильне рішення: мемоїзувати лише вузли з actualDuration понад 8-16 мс
// або ті, що очолюють глибокі піддерева із сотень нащадків.
export const StatusBadgeSimple = ({ label }: StatusBadgeProps) => {
return <span className="badge">{label}</span>;
};Навіть якщо дочірній компонент обгорнуто в React.memo, передача нового літерала об'єкта або інлайн-функції призводить до постійної невідповідності пропсів у DevTools: «Why did this render? Props changed: [config, onPointSelect]».
// ❌ Антипатерн: новий об'єкт config і нова стрілкова функція на кожен рендер
export function Dashboard({ userId }: { userId: string }) {
const [counter, setCounter] = useState(0);
return (
<div>
<button type="button" onClick={() => setCounter((c) => c + 1)}>
Лічильник: {counter}
</button>
{/* HeavyChart рендериться заново на кожен клік button! */}
<HeavyChart
config={{ theme: 'dark', refreshRate: 5000 }}
onPointSelect={(point) => console.log(point)}
/>
</div>
);
}
// ✅ Правильне рішення: винесення статичних констант і стабільний useCallback
const STATIC_CHART_CONFIG = { theme: 'dark', refreshRate: 5000 } as const;
export function DashboardOptimized({ userId }: { userId: string }) {
const [counter, setCounter] = useState(0);
const handlePointSelect = useCallback((point: unknown) => {
console.log(point);
}, []);
return (
<div>
<button type="button" onClick={() => setCounter((c) => c + 1)}>
Лічильник: {counter}
</button>
<HeavyChart config={STATIC_CHART_CONFIG} onPointSelect={handlePointSelect} />
</div>
);
}Часта помилка — витрачати години на оптимізацію Fiber-рендеру, коли реальна затримка INP викликана стадією Presentation Delay (перерахунок CSS, складний reflow або важкі графічні фільтри браузера).
Бюджет INP = Input Delay + Processing Duration (React) + Presentation Delay (Браузер)
Якщо Processing Duration = 8 мс, але Presentation Delay = 160 мс:
Причина: Різка вставка елемента з селектором, що викликає перерахунок стилів 50 000 DOM-вузлів (* { ... })
або важкий backdrop-filter: blur(20px) на повноекранному контейнері.Рішення: Аналізувати повний трейс у панелі Chrome Performance, зіставляючи фази Layout та Recalculate Styles із комітами React.