Оптимизация рендеринга страниц (Virtual DOM, Virtual Scroll) сайта
Медленный рендеринг проявляется конкретно: список из 10 000 строк вешает браузер при прокрутке, дашборд с графиками тормозит при обновлении данных, модальное окно открывается с задержкой. Причины всегда измеримы — лишние ре-рендеры, DOM с тысячами узлов, синхронные вычисления в render-пути. Оптимизация начинается с профилирования, а не с угадывания.
Инструменты измерения
Перед любой оптимизацией — установить baseline:
React DevTools Profiler — записать сессию взаимодействия, найти компоненты с высоким render duration и частыми ре-рендерами. Особое внимание на why did this render — показывает что изменилось.
Chrome Performance — Ctrl+Shift+P → Start profiling and reload page. В flame chart ищем длинные task'и (>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 reflow
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');
});
});
Checklist оптимизации
До начала оптимизации измерить с Profiler. Затем по приоритету:
- Виртуализация списков > 200 элементов — наибольший эффект
-
memo+useCallbackна компоненты с дорогими рендерами - Разбить контексты, изолировать частые обновления
-
useDeferredValue/startTransitionдля несрочных обновлений -
useMemoдля дорогих вычислений (> 1–2 ms) - Web Workers для CPU-intensive задач
Сроки
- Аудит производительности (профилирование, отчёт с рекомендациями): 1 день
- Виртуализация одного тяжёлого списка/таблицы: 1–2 дня
- Комплексная оптимизация (виртуализация + устранение ре-рендеров + workers): 3–7 дней
- Полный performance-рефакторинг крупного SPA: 2–4 недели







