Золотое правило: сначала измерь, потом оптимизируй. Преждевременная оптимизация добавляет сложность без пользы. Сначала убедитесь, что проблема существует.
Инструменты измерения:
Профилировщик показывает:
// Измерение через Profiler API:
import { Profiler } from 'react'
function onRenderCallback(id, phase, actualDuration) {
console.log(id + ' ' + phase + ': ' + actualDuration.toFixed(2) + 'мс')
}
<Profiler id="ProductList" onRender={onRenderCallback}>
<ProductList items={items} />
</Profiler>React перерендеривает компонент при изменении state или props. Проблема: дочерние компоненты рендерятся даже если их пропсы не изменились.
function Parent() {
const [count, setCount] = useState(0)
return (
<>
<button onClick={() => setCount(c => c + 1)}>+1</button>
<HeavyComponent /> {/* перерендерится при каждом клике, хотя не использует count! */}
</>
)
}
// Решение 1: React.memo
const HeavyComponent = React.memo(function HeavyComponent() {
return <ExpensiveUI />
})
// Решение 2: useMemo для вычислений
const expensiveValue = useMemo(() => compute(data), [data])
// Решение 3: useCallback для функций
const handleClick = useCallback(() => doSomething(id), [id])Самая мощная оптимизация для длинных списков — рендерить только видимые элементы. При 10 000 элементах в DOM браузер работает медленно. При 20 видимых — мгновенно.
Библиотека react-window:
import { FixedSizeList } from 'react-window'
// Рендерит только ~15 видимых строк из 10000:
<FixedSizeList
height={600}
itemCount={10000}
itemSize={50} // высота каждого элемента в px
width="100%"
>
{({ index, style }) => (
<div style={style}> {/* style содержит position: absolute, top: ... */}
Элемент #{index}: {items[index].name}
</div>
)}
</FixedSizeList>Уже рассматривали в уроке о Suspense, но ключевые правила:
1. Разделяй по маршрутам — каждая страница отдельным чанком
2. Разделяй тяжёлые библиотеки — charting, pdf, editor библиотеки
3. Prefetch для ожидаемых переходов — при hover на ссылку начинай загрузку
// Prefetch при hover:
function NavLink({ to, children }) {
const handleHover = () => {
// Начинаем загрузку страницы при наведении
import('./pages/' + to)
}
return <Link to={'/' + to} onMouseEnter={handleHover}>{children}</Link>
}Узнайте что занимает место в вашем бандле:
# Для Vite:
npx vite-bundle-visualizer
# Для Create React App:
npx source-map-explorer 'build/static/js/*.js'Типичные находки:
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals'
function sendToAnalytics(metric) {
fetch('/analytics', {
method: 'POST',
body: JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating, // 'good', 'needs-improvement', 'poor'
})
})
}
getCLS(sendToAnalytics)
getFID(sendToAnalytics)
getLCP(sendToAnalytics)1. Измерь — Profile, Lighthouse, Core Web Vitals
2. Найди — самые медленные компоненты, самые большие чанки
3. Виртуализируй — списки больше 100 элементов
4. Мемоизируй — только если есть измеримая проблема с рендерами
5. Разбей бандл — lazy для страниц и тяжёлых компонентов
6. Оптимизируй зависимости — замени тяжёлые библиотеки
Сравнение полного рендера списка vs виртуализация: измерение времени DOM-операций, реализация windowing-алгоритма с только видимыми элементами
// Сравниваем "наивный" рендер 10000 элементов
// с виртуализированным (только видимые).
// --- Параметры ---
const TOTAL_ITEMS = 10000
const ITEM_HEIGHT = 40 // px
const CONTAINER_HEIGHT = 400 // px (видимая область)
const VISIBLE_COUNT = Math.ceil(CONTAINER_HEIGHT / ITEM_HEIGHT) // ~10 элементов
// --- Генерация данных ---
const items = Array.from({ length: TOTAL_ITEMS }, (_, i) => ({
id: i,
name: 'Товар ' + i,
price: Math.round(100 + Math.random() * 9900),
category: ['Электроника', 'Одежда', 'Продукты'][i % 3],
}))
console.log('Всего элементов:', TOTAL_ITEMS)
console.log('Видимых элементов (viewport):', VISIBLE_COUNT)
// --- 1: Наивный рендер (без виртуализации) ---
function naiveRender(allItems) {
const start = performance.now()
// Симулируем создание DOM-узлов для каждого элемента
const domNodes = allItems.map(item => ({
type: 'div',
height: ITEM_HEIGHT,
content: item.name + ' — ' + item.price + '₽',
}))
const renderTime = performance.now() - start
return {
renderedCount: domNodes.length,
time: renderTime.toFixed(3),
memoryEstimate: (domNodes.length * 200 / 1024).toFixed(1) + 'KB', // ~200 байт/узел
}
}
// --- 2: Виртуализированный рендер ---
function virtualizedRender(allItems, scrollTop = 0) {
const start = performance.now()
// Вычисляем какие элементы видимы
const startIndex = Math.floor(scrollTop / ITEM_HEIGHT)
const endIndex = Math.min(
startIndex + VISIBLE_COUNT + 2, // +2 для буфера
allItems.length
)
// Рендерим ТОЛЬКО видимые + буфер
const visibleItems = allItems.slice(startIndex, endIndex)
const domNodes = visibleItems.map((item, i) => ({
type: 'div',
height: ITEM_HEIGHT,
// Позиционируем абсолютно по вычисленному offset
top: (startIndex + i) * ITEM_HEIGHT,
content: item.name + ' — ' + item.price + '₽',
}))
// Контейнер имеет полную высоту (для правильного скролла)
const containerHeight = allItems.length * ITEM_HEIGHT
const renderTime = performance.now() - start
return {
renderedCount: domNodes.length,
totalHeight: containerHeight,
visibleRange: [startIndex, endIndex],
time: renderTime.toFixed(3),
memoryEstimate: (domNodes.length * 200 / 1024).toFixed(1) + 'KB',
topOffset: domNodes[0]?.top + 'px',
}
}
// --- Сравнение ---
console.log('
=== Наивный рендер ===')
const naive = naiveRender(items)
console.log('Создано DOM-узлов:', naive.renderedCount)
console.log('Время:', naive.time + 'мс (обработка JS)')
console.log('Память DOM:', naive.memoryEstimate)
console.log('⚠️ Браузер должен разместить', naive.renderedCount, 'элементов в layout!')
console.log('
=== Виртуализированный рендер ===')
const virtual = virtualizedRender(items, 0)
console.log('Создано DOM-узлов:', virtual.renderedCount, '(из', TOTAL_ITEMS + ')')
console.log('Время:', virtual.time + 'мс')
console.log('Память DOM:', virtual.memoryEstimate)
console.log('Видимый диапазон: [' + virtual.visibleRange[0] + ', ' + virtual.visibleRange[1] + ']')
console.log('Высота контейнера:', virtual.totalHeight + 'px (полная, для скролла)')
// --- Симуляция прокрутки ---
console.log('
=== Прокрутка виртуального списка ===')
const scrollPositions = [0, 400, 2000, 10000, 20000]
scrollPositions.forEach(scrollTop => {
const result = virtualizedRender(items, scrollTop)
const itemIndex = Math.floor(scrollTop / ITEM_HEIGHT)
console.log('scrollTop=' + scrollTop + 'px → рендерим элементы [' +
result.visibleRange[0] + '-' + result.visibleRange[1] + ']' +
' (видим ~' + VISIBLE_COUNT + ' из ' + TOTAL_ITEMS + ')')
})
// --- Benchmark: количество обновлений ---
console.log('
=== Benchmark рендеров ===')
let renders = 0
const benchStart = performance.now()
// Имитируем 1000 "обновлений" (скролл, ввод, etc.)
for (let i = 0; i < 1000; i++) {
const scrollTop = Math.random() * (TOTAL_ITEMS * ITEM_HEIGHT)
virtualizedRender(items, scrollTop)
renders++
}
const benchTime = performance.now() - benchStart
console.log(renders + ' виртуализированных рендеров за', benchTime.toFixed(1) + 'мс')
console.log('Среднее время одного рендера:', (benchTime / renders).toFixed(3) + 'мс')Золотое правило: сначала измерь, потом оптимизируй. Преждевременная оптимизация добавляет сложность без пользы. Сначала убедитесь, что проблема существует.
Инструменты измерения:
Профилировщик показывает:
// Измерение через Profiler API:
import { Profiler } from 'react'
function onRenderCallback(id, phase, actualDuration) {
console.log(id + ' ' + phase + ': ' + actualDuration.toFixed(2) + 'мс')
}
<Profiler id="ProductList" onRender={onRenderCallback}>
<ProductList items={items} />
</Profiler>React перерендеривает компонент при изменении state или props. Проблема: дочерние компоненты рендерятся даже если их пропсы не изменились.
function Parent() {
const [count, setCount] = useState(0)
return (
<>
<button onClick={() => setCount(c => c + 1)}>+1</button>
<HeavyComponent /> {/* перерендерится при каждом клике, хотя не использует count! */}
</>
)
}
// Решение 1: React.memo
const HeavyComponent = React.memo(function HeavyComponent() {
return <ExpensiveUI />
})
// Решение 2: useMemo для вычислений
const expensiveValue = useMemo(() => compute(data), [data])
// Решение 3: useCallback для функций
const handleClick = useCallback(() => doSomething(id), [id])Самая мощная оптимизация для длинных списков — рендерить только видимые элементы. При 10 000 элементах в DOM браузер работает медленно. При 20 видимых — мгновенно.
Библиотека react-window:
import { FixedSizeList } from 'react-window'
// Рендерит только ~15 видимых строк из 10000:
<FixedSizeList
height={600}
itemCount={10000}
itemSize={50} // высота каждого элемента в px
width="100%"
>
{({ index, style }) => (
<div style={style}> {/* style содержит position: absolute, top: ... */}
Элемент #{index}: {items[index].name}
</div>
)}
</FixedSizeList>Уже рассматривали в уроке о Suspense, но ключевые правила:
1. Разделяй по маршрутам — каждая страница отдельным чанком
2. Разделяй тяжёлые библиотеки — charting, pdf, editor библиотеки
3. Prefetch для ожидаемых переходов — при hover на ссылку начинай загрузку
// Prefetch при hover:
function NavLink({ to, children }) {
const handleHover = () => {
// Начинаем загрузку страницы при наведении
import('./pages/' + to)
}
return <Link to={'/' + to} onMouseEnter={handleHover}>{children}</Link>
}Узнайте что занимает место в вашем бандле:
# Для Vite:
npx vite-bundle-visualizer
# Для Create React App:
npx source-map-explorer 'build/static/js/*.js'Типичные находки:
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals'
function sendToAnalytics(metric) {
fetch('/analytics', {
method: 'POST',
body: JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating, // 'good', 'needs-improvement', 'poor'
})
})
}
getCLS(sendToAnalytics)
getFID(sendToAnalytics)
getLCP(sendToAnalytics)1. Измерь — Profile, Lighthouse, Core Web Vitals
2. Найди — самые медленные компоненты, самые большие чанки
3. Виртуализируй — списки больше 100 элементов
4. Мемоизируй — только если есть измеримая проблема с рендерами
5. Разбей бандл — lazy для страниц и тяжёлых компонентов
6. Оптимизируй зависимости — замени тяжёлые библиотеки
Сравнение полного рендера списка vs виртуализация: измерение времени DOM-операций, реализация windowing-алгоритма с только видимыми элементами
// Сравниваем "наивный" рендер 10000 элементов
// с виртуализированным (только видимые).
// --- Параметры ---
const TOTAL_ITEMS = 10000
const ITEM_HEIGHT = 40 // px
const CONTAINER_HEIGHT = 400 // px (видимая область)
const VISIBLE_COUNT = Math.ceil(CONTAINER_HEIGHT / ITEM_HEIGHT) // ~10 элементов
// --- Генерация данных ---
const items = Array.from({ length: TOTAL_ITEMS }, (_, i) => ({
id: i,
name: 'Товар ' + i,
price: Math.round(100 + Math.random() * 9900),
category: ['Электроника', 'Одежда', 'Продукты'][i % 3],
}))
console.log('Всего элементов:', TOTAL_ITEMS)
console.log('Видимых элементов (viewport):', VISIBLE_COUNT)
// --- 1: Наивный рендер (без виртуализации) ---
function naiveRender(allItems) {
const start = performance.now()
// Симулируем создание DOM-узлов для каждого элемента
const domNodes = allItems.map(item => ({
type: 'div',
height: ITEM_HEIGHT,
content: item.name + ' — ' + item.price + '₽',
}))
const renderTime = performance.now() - start
return {
renderedCount: domNodes.length,
time: renderTime.toFixed(3),
memoryEstimate: (domNodes.length * 200 / 1024).toFixed(1) + 'KB', // ~200 байт/узел
}
}
// --- 2: Виртуализированный рендер ---
function virtualizedRender(allItems, scrollTop = 0) {
const start = performance.now()
// Вычисляем какие элементы видимы
const startIndex = Math.floor(scrollTop / ITEM_HEIGHT)
const endIndex = Math.min(
startIndex + VISIBLE_COUNT + 2, // +2 для буфера
allItems.length
)
// Рендерим ТОЛЬКО видимые + буфер
const visibleItems = allItems.slice(startIndex, endIndex)
const domNodes = visibleItems.map((item, i) => ({
type: 'div',
height: ITEM_HEIGHT,
// Позиционируем абсолютно по вычисленному offset
top: (startIndex + i) * ITEM_HEIGHT,
content: item.name + ' — ' + item.price + '₽',
}))
// Контейнер имеет полную высоту (для правильного скролла)
const containerHeight = allItems.length * ITEM_HEIGHT
const renderTime = performance.now() - start
return {
renderedCount: domNodes.length,
totalHeight: containerHeight,
visibleRange: [startIndex, endIndex],
time: renderTime.toFixed(3),
memoryEstimate: (domNodes.length * 200 / 1024).toFixed(1) + 'KB',
topOffset: domNodes[0]?.top + 'px',
}
}
// --- Сравнение ---
console.log('
=== Наивный рендер ===')
const naive = naiveRender(items)
console.log('Создано DOM-узлов:', naive.renderedCount)
console.log('Время:', naive.time + 'мс (обработка JS)')
console.log('Память DOM:', naive.memoryEstimate)
console.log('⚠️ Браузер должен разместить', naive.renderedCount, 'элементов в layout!')
console.log('
=== Виртуализированный рендер ===')
const virtual = virtualizedRender(items, 0)
console.log('Создано DOM-узлов:', virtual.renderedCount, '(из', TOTAL_ITEMS + ')')
console.log('Время:', virtual.time + 'мс')
console.log('Память DOM:', virtual.memoryEstimate)
console.log('Видимый диапазон: [' + virtual.visibleRange[0] + ', ' + virtual.visibleRange[1] + ']')
console.log('Высота контейнера:', virtual.totalHeight + 'px (полная, для скролла)')
// --- Симуляция прокрутки ---
console.log('
=== Прокрутка виртуального списка ===')
const scrollPositions = [0, 400, 2000, 10000, 20000]
scrollPositions.forEach(scrollTop => {
const result = virtualizedRender(items, scrollTop)
const itemIndex = Math.floor(scrollTop / ITEM_HEIGHT)
console.log('scrollTop=' + scrollTop + 'px → рендерим элементы [' +
result.visibleRange[0] + '-' + result.visibleRange[1] + ']' +
' (видим ~' + VISIBLE_COUNT + ' из ' + TOTAL_ITEMS + ')')
})
// --- Benchmark: количество обновлений ---
console.log('
=== Benchmark рендеров ===')
let renders = 0
const benchStart = performance.now()
// Имитируем 1000 "обновлений" (скролл, ввод, etc.)
for (let i = 0; i < 1000; i++) {
const scrollTop = Math.random() * (TOTAL_ITEMS * ITEM_HEIGHT)
virtualizedRender(items, scrollTop)
renders++
}
const benchTime = performance.now() - benchStart
console.log(renders + ' виртуализированных рендеров за', benchTime.toFixed(1) + 'мс')
console.log('Среднее время одного рендера:', (benchTime / renders).toFixed(3) + 'мс')Создай компонент `VirtualList`, который эффективно рендерит большой список, показывая только видимые элементы. Используй `useState` для хранения scrollTop, `useMemo` для вычисления видимых элементов. Компонент принимает `items` (массив данных), `itemHeight` (высота элемента в px), `containerHeight` (высота контейнера). Заполни пропуски `???` для вычисления диапазона видимых элементов с буфером.
useState для scrollTop, useMemo для visibleItems (мемоизация вычислений), Math.max для realStart, item.id или index для key.