Оптимізація рендеринга сторінок (Virtual DOM, Virtual Scroll)
Повільний рендеринг проявляється конкретно: список з 10 000 рядків вішає браузер при прокручуванні, дашборд із графіками гальмує при оновленні даних, модальне вікно відкривається з затримкою. Причини завжди вимірювані — зайві ре-рендери, DOM з тисячами вузлів, синхронні обчислення в render-шляху. Оптимізація починається з профілювання, а не з угадування.
Інструменти вимірювання
Перед будь-якою оптимізацією — установлення базової лінії:
React DevTools Profiler — записати сесію взаємодії, знайти компоненти з високою render duration та частими ре-рендерами. Особлива увага на why did this render — показує що змінилось.
Chrome Performance — Ctrl+Shift+P → Start profiling and reload page. У flame chart шукаємо довгих задач (>50ms), Recalculate Style, Layout thrashing.
// Швидка перевірка без DevTools
const start = performance.now();
// ... операція
console.log(`Took: ${performance.now() - start}ms`);
// Для компонентів — React Profiler API
import { Profiler } from 'react';
<Profiler id="ProductList" onRender={(id, phase, actualDuration) => {
if (actualDuration > 16) {
console.warn(`Slow render: ${id} took ${actualDuration}ms (${phase})`);
}
}}>
<ProductList />
</Profiler>
Віртуальний скролл: чому DOM вбиває продуктивність
1000 рядків у таблиці = 1000 DOM-вузлів (плюс комірки). Браузер утримує все в пам'яті, перераховує стилі при будь-якій зміні, scroll handler перебирає всі елементи. При 5000+ рядках сторінка починає гальмувати на будь-якому залізі.
Віртуалізація рендерить лише видимі рядки + невеличкий overscan. При прокручуванні — замінює вміст, зберігаючи один набір DOM-вузлів.
TanStack Virtual (колишній react-virtual)
Headless — не диктує стилі, працює з будь-яким CSS:
npm install @tanstack/react-virtual
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
interface VirtualListProps<T> {
items: T[];
itemHeight: number;
renderItem: (item: T, index: number) => React.ReactNode;
}
function VirtualList<T>({ items, itemHeight, renderItem }: VirtualListProps<T>) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => itemHeight,
overscan: 5, // скільки рядків рендерити за видимою областю
});
return (
<div
ref={parentRef}
style={{ height: '600px', overflow: 'auto' }}
>
{/* Контейнер із повною висотою — для правильного скролбара */}
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{renderItem(items[virtualItem.index], virtualItem.index)}
</div>
))}
</div>
</div>
);
}
Для рядків змінної висоти — measureElement:
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 80, // початкова оцінка
measureElement: (el) => el.getBoundingClientRect().height, // реальний розмір
});
// У рендері рядка:
<div
ref={virtualizer.measureElement}
data-index={virtualItem.index}
>
{/* змінний вміст */}
</div>
React Window для таблиць
Для таблиць із фіксованими розмірами рядків react-window простіше:
npm install react-window react-window-infinite-loader
npm install --save-dev @types/react-window
import { FixedSizeList } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
function VirtualTable({ data }: { data: Row[] }) {
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
<div style={style} className={`row ${index % 2 === 0 ? 'row--even' : ''}`}>
<div className="cell">{data[index].id}</div>
<div className="cell">{data[index].name}</div>
<div className="cell">{data[index].status}</div>
</div>
);
return (
<AutoSizer>
{({ height, width }) => (
<FixedSizeList
height={height}
itemCount={data.length}
itemSize={48}
width={width}
overscanCount={5}
>
{Row}
</FixedSizeList>
)}
</AutoSizer>
);
}
Усунення зайвих ре-рендерів
Profiler показує що компонент рендерится занадто часто. Причини та рішення:
Нові об'єкти/функції при кожному рендері:
// Проблема: нова функція на кожен рендер батька
function Parent() {
const handleClick = (id: number) => doSomething(id); // новий об'єкт
return <Child onClick={handleClick} />;
}
// Рішення: useCallback
function Parent() {
const handleClick = useCallback((id: number) => doSomething(id), []);
return <Child onClick={handleClick} />;
}
memo для стабільних компонентів:
const ProductCard = memo(function ProductCard({ product, onAddToCart }: Props) {
return (
<div className="card">
<h3>{product.name}</h3>
<button onClick={() => onAddToCart(product.id)}>У корзину</button>
</div>
);
}, (prev, next) => {
// Користувацький компаратор — рендерити тільки якщо змінились потрібні поля
return prev.product.id === next.product.id
&& prev.product.price === next.product.price
&& prev.onAddToCart === next.onAddToCart;
});
useMemo для дорогих обчислень:
function ProductList({ products, filters }: Props) {
// Без useMemo — фільтрація на кожен ре-рендер
const filtered = useMemo(
() => products.filter(applyFilters(filters)).sort(sortBy('price')),
[products, filters]
);
return filtered.map((p) => <ProductCard key={p.id} product={p} />);
}
Context: ізоляція оновлень
Context викликає ре-рендер у всіх споживачів при будь-якій зміні значення. Рішення — розділяти контексти за частотою змін:
// Погано: усе в одному контексті
const AppContext = createContext({ user, cart, theme, settings });
// Добре: розділити за частотою оновлень
const UserContext = createContext(user); // рідко
const CartContext = createContext(cart); // часто (по item'ам)
const ThemeContext = createContext(theme); // дуже рідко
Для високочастотних оновлень (курс, тікер) — use-context-selector:
import { createContext, useContextSelector } from 'use-context-selector';
const StoreContext = createContext({ price: 0, volume: 0 });
// Рендерится тільки при зміні price, ігнорує volume
function PriceDisplay() {
const price = useContextSelector(StoreContext, (s) => s.price);
return <span>{price}</span>;
}
Deferred Rendering: React 18 transitions
Важкі оновлення (фільтрація великого списку) не повинні блокувати введення:
import { useTransition, useDeferredValue } from 'react';
function SearchWithHeavyList({ items }: { items: Item[] }) {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
// Оновлення query — потрібне (показати введений символ)
// Оновлення фільтрованого списку — непотрібне
const deferredQuery = useDeferredValue(query);
const filtered = useMemo(
() => items.filter((item) => item.name.includes(deferredQuery)),
[items, deferredQuery]
);
return (
<>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Пошук..."
/>
{isPending && <span className="loading-indicator" />}
<VirtualList items={filtered} itemHeight={48} renderItem={...} />
</>
);
}
Web Workers для важких обчислень
Сортування/фільтрація мільйона записів не повинна блокувати main thread:
// filter.worker.ts
self.onmessage = ({ data: { items, filters } }) => {
const result = items.filter(applyFilters(filters));
self.postMessage(result);
};
import { useEffect, useRef, useState } from 'react';
function useWorkerFilter(items: Item[], filters: Filters) {
const [filtered, setFiltered] = useState(items);
const workerRef = useRef<Worker>();
useEffect(() => {
workerRef.current = new Worker(
new URL('./filter.worker.ts', import.meta.url),
{ type: 'module' }
);
workerRef.current.onmessage = ({ data }) => setFiltered(data);
return () => workerRef.current?.terminate();
}, []);
useEffect(() => {
workerRef.current?.postMessage({ items, filters });
}, [items, filters]);
return filtered;
}
Layout Thrashing
Чергування читання й запису DOM-властивостей викликає примусовий reflow:
// Погано: read → write → read → write = 4 reflows
const h1 = el1.offsetHeight;
el2.style.height = h1 + 'px';
const h2 = el3.offsetHeight;
el4.style.height = h2 + 'px';
// Добре: спочатку всі читання, потім усі запису
const h1 = el1.offsetHeight;
const h2 = el3.offsetHeight;
el2.style.height = h1 + 'px';
el4.style.height = h2 + 'px';
// Або через requestAnimationFrame
requestAnimationFrame(() => {
const heights = elements.map(el => el.offsetHeight);
requestAnimationFrame(() => {
targets.forEach((el, i) => el.style.height = heights[i] + 'px');
});
});
Чеклист оптимізації
Перед оптимізацією вимірюйте з Profiler. Потім за пріоритетом:
- Віртуалізація списків > 200 елементів — найбільший ефект
-
memo+useCallbackна компоненти з дорогими рендерами - Розділити контексти, ізолювати частові оновлення
-
useDeferredValue/startTransitionдля непотрібних оновлень -
useMemoдля дорогих обчислень (> 1–2 ms) - Web Workers для CPU-intensive задач
Строки виконання
- Аудит продуктивності (профілювання, звіт із рекомендаціями): 1 день
- Віртуалізація одного важкого списку/таблиці: 1–2 дні
- Комплексна оптимізація (віртуалізація + усунення ре-рендерів + workers): 3–7 днів
- Повний performance-рефакторинг крупного SPA: 2–4 тижні







