← React/Оптимизация производительности React-приложений#283 из 430← ПредыдущийСледующий →+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-помощника...