← React/Оптимизация производительности React-приложений#283 из 383← ПредыдущийСледующий →+25 XP
Полезно по теме:Гайд: React или VueПрактика: React setТермин: React HooksТема: React: хуки и экосистема

Оптимизация производительности React-приложений

С чего начинать: измеряй, потом оптимизируй

Золотое правило: сначала измерь, потом оптимизируй. Преждевременная оптимизация добавляет сложность без пользы. Сначала убедитесь, что проблема существует.

Инструменты измерения:

  • React DevTools Profiler — показывает какие компоненты рендерятся и сколько времени занимают
  • Chrome DevTools Performance — полная картина: JS, paint, layout
  • Lighthouse — оценка Core Web Vitals (LCP, FID/INP, CLS)
  • web-vitals библиотека — измерение в production
  • React DevTools Profiler

    Профилировщик показывает:

  • Какие компоненты рендерились при каждом взаимодействии
  • Сколько времени занял каждый рендер
  • Почему компонент перерендерился
  • // Измерение через 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>

    Code Splitting

    Уже рассматривали в уроке о 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>
    }

    Bundle Analyzer

    Узнайте что занимает место в вашем бандле:

    # Для Vite:
    npx vite-bundle-visualizer
    
    # Для Create React App:
    npx source-map-explorer 'build/static/js/*.js'

    Типичные находки:

  • moment.js (70KB) — заменить на date-fns или dayjs
  • lodash (71KB) — использовать lodash-es с tree-shaking
  • иконочные библиотеки — импортировать только нужные иконки
  • Web Vitals в production

    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) + 'мс')

    Оптимизация производительности React-приложений

    С чего начинать: измеряй, потом оптимизируй

    Золотое правило: сначала измерь, потом оптимизируй. Преждевременная оптимизация добавляет сложность без пользы. Сначала убедитесь, что проблема существует.

    Инструменты измерения:

  • React DevTools Profiler — показывает какие компоненты рендерятся и сколько времени занимают
  • Chrome DevTools Performance — полная картина: JS, paint, layout
  • Lighthouse — оценка Core Web Vitals (LCP, FID/INP, CLS)
  • web-vitals библиотека — измерение в production
  • React DevTools Profiler

    Профилировщик показывает:

  • Какие компоненты рендерились при каждом взаимодействии
  • Сколько времени занял каждый рендер
  • Почему компонент перерендерился
  • // Измерение через 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>

    Code Splitting

    Уже рассматривали в уроке о 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>
    }

    Bundle Analyzer

    Узнайте что занимает место в вашем бандле:

    # Для Vite:
    npx vite-bundle-visualizer
    
    # Для Create React App:
    npx source-map-explorer 'build/static/js/*.js'

    Типичные находки:

  • moment.js (70KB) — заменить на date-fns или dayjs
  • lodash (71KB) — использовать lodash-es с tree-shaking
  • иконочные библиотеки — импортировать только нужные иконки
  • Web Vitals в production

    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.

    Загружаем среду выполнения...
    Загружаем AI-помощника...