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

Продвинутая оптимизация: Profiler и React DevTools

React Profiler API

React Profiler — встроенный инструмент для измерения производительности рендеров:

import { Profiler } from 'react'

function onRenderCallback(
  id,             // "displayName" Profiler
  phase,          // "mount" или "update"
  actualDuration, // время рендеринга в мс
  baseDuration,   // расчётное время без мемоизации
  startTime,      // когда React начал рендеринг
  commitTime      // когда React зафиксировал рендеринг
) {
  console.log(`[${id}] ${phase}: ${actualDuration.toFixed(2)}мс`)
}

function App() {
  return (
    <Profiler id="Navigation" onRender={onRenderCallback}>
      <Navigation />
    </Profiler>
  )
}

React DevTools Profiler

В браузерном расширении React DevTools вкладка Profiler показывает:

Flame chart — визуализация дерева рендеринга:

  • Ширина = время рендеринга
  • Серый = не рендерился в этом коммите
  • Жёлтый/оранжевый = медленный рендер
  • Зелёный = быстрый рендер
  • Ранжирование — компоненты от самого медленного к быстрому.

    Как использовать:

    1. Открой DevTools → Profiler

    2. Нажми "Start profiling"

    3. Совершай действия в интерфейсе

    4. Нажми "Stop profiling"

    5. Исследуй flame chart

    why-did-you-render

    Библиотека для обнаружения ненужных ре-рендеров:

    // src/wdyr.ts (только для разработки)
    import React from 'react'
    
    if (process.env.NODE_ENV === 'development') {
      const whyDidYouRender = require('@welldone-software/why-did-you-render')
      whyDidYouRender(React, {
        trackAllPureComponents: true,
        // Или точечно для конкретного компонента:
        // logOwnerReasons: true,
      })
    }
    
    // Включить для конкретного компонента
    MyComponent.whyDidYouRender = true
    // why-did-you-render покажет:
    // [MyList] Re-rendered. Причина: изменились пропсы
    // Предыдущие: { items: Array(3) }
    // Текущие:    { items: Array(3) }  ← ОДИНАКОВЫЕ! Значение то же, но новая ссылка

    Анализ бандла: webpack-bundle-analyzer

    # Для Create React App
    npx source-map-explorer 'build/static/js/*.js'
    
    # Для Vite
    npm install rollup-plugin-visualizer -D
    // vite.config.ts
    import { visualizer } from 'rollup-plugin-visualizer'
    
    export default {
      plugins: [
        visualizer({
          filename: 'stats.html',
          open: true,
          gzipSize: true,
        })
      ]
    }

    Что искать в анализе бандла:

  • Большие библиотеки (moment.js ~300KB → date-fns ~20KB)
  • Дублирующийся код
  • Компоненты, которые попали в основной чанк, но используются редко
  • Code Splitting с React.lazy и Suspense

    import { lazy, Suspense } from 'react'
    
    // Ленивая загрузка: компонент загрузится только когда понадобится
    const HeavyChart = lazy(() => import('./HeavyChart'))
    const AdminPanel = lazy(() => import('./AdminPanel'))
    
    function App() {
      const isAdmin = useUser().role === 'admin'
    
      return (
        <Suspense fallback={<div>Загрузка...</div>}>
          <HeavyChart data={chartData} />
          {isAdmin && <AdminPanel />}
        </Suspense>
      )
    }
    
    // Роутинг с ленивой загрузкой страниц
    const HomePage = lazy(() => import('./pages/HomePage'))
    const BlogPage = lazy(() => import('./pages/BlogPage'))
    const ProfilePage = lazy(() => import('./pages/ProfilePage'))
    
    function Router() {
      return (
        <Suspense fallback={<PageLoader />}>
          <Routes>
            <Route path="/" element={<HomePage />} />
            <Route path="/blog" element={<BlogPage />} />
            <Route path="/profile" element={<ProfilePage />} />
          </Routes>
        </Suspense>
      )
    }

    React Compiler (React 19+)

    React Compiler автоматически добавляет мемоизацию — больше не нужно вручную писать useMemo и useCallback:

    npm install babel-plugin-react-compiler -D
    // babel.config.js
    module.exports = {
      plugins: [
        ['babel-plugin-react-compiler', {
          target: '18',  // совместимость с React 18
        }]
      ]
    }
    // До React Compiler (ручная мемоизация)
    function ProductList({ products, onBuy }) {
      const sorted = useMemo(
        () => [...products].sort((a, b) => a.price - b.price),
        [products]
      )
      const handleBuy = useCallback(
        (id) => onBuy(id),
        [onBuy]
      )
      return sorted.map(p => <Product key={p.id} product={p} onBuy={handleBuy} />)
    }
    
    // После React Compiler — он добавит мемоизацию автоматически!
    function ProductList({ products, onBuy }) {
      const sorted = [...products].sort((a, b) => a.price - b.price)
      return sorted.map(p => <Product key={p.id} product={p} onBuy={(id) => onBuy(id)} />)
    }

    Практические советы

    // 1. Виртуализация списков (react-virtual или react-window)
    import { useVirtualizer } from '@tanstack/react-virtual'
    
    function BigList({ items }) {
      const parentRef = useRef(null)
      const virtualizer = useVirtualizer({
        count: items.length,
        getScrollElement: () => parentRef.current,
        estimateSize: () => 50,
      })
    
      return (
        <div ref={parentRef} style={{ height: 400, overflow: 'auto' }}>
          <div style={{ height: virtualizer.getTotalSize() }}>
            {virtualizer.getVirtualItems().map(virtualRow => (
              <div
                key={virtualRow.index}
                style={{ transform: `translateY(${virtualRow.start}px)` }}
              >
                {items[virtualRow.index].name}
              </div>
            ))}
          </div>
        </div>
      )
    }
    
    // 2. Дебаунс для часто срабатывающих обработчиков
    function SearchInput() {
      const [query, setQuery] = useState('')
      const debouncedSearch = useMemo(
        () => debounce((q) => fetchResults(q), 300),
        []
      )
    
      return (
        <input
          value={query}
          onChange={e => {
            setQuery(e.target.value)
            debouncedSearch(e.target.value)
          }}
        />
      )
    }

    Примеры

    Профайлер рендеров на ванильном JS: отслеживание количества рендеров, времени выполнения и выявление лишних повторных рендеров

    // Симулируем React Profiler: отслеживаем рендеры компонентов,
    // измеряем время и находим ненужные повторные вызовы.
    
    // --- Профайлер ---
    
    function createProfiler() {
      const stats = new Map()  // componentName -> { renderCount, totalTime, renders[] }
    
      return {
        // Записать рендер компонента
        track(componentName, renderFn, props) {
          const start = performance.now()
          const result = renderFn(props)
          const duration = performance.now() - start
    
          if (!stats.has(componentName)) {
            stats.set(componentName, { renderCount: 0, totalTime: 0, renders: [] })
          }
    
          const s = stats.get(componentName)
          s.renderCount++
          s.totalTime += duration
          s.renders.push({ duration, props: JSON.stringify(props) })
    
          return result
        },
    
        // Получить статистику
        getStats() {
          const result = []
          for (const [name, s] of stats) {
            result.push({
              componentName: name,
              renderCount: s.renderCount,
              totalTime: Math.round(s.totalTime * 100) / 100,
              avgTime: Math.round((s.totalTime / s.renderCount) * 100) / 100,
            })
          }
          return result.sort((a, b) => b.totalTime - a.totalTime)
        },
    
        // Найти подозрительные повторные рендеры с теми же пропсами
        findUnnecessaryRenders() {
          const suspicious = []
          for (const [name, s] of stats) {
            if (s.renders.length < 2) continue
            for (let i = 1; i < s.renders.length; i++) {
              if (s.renders[i].props === s.renders[i - 1].props) {
                suspicious.push({
                  component: name,
                  renderIndex: i,
                  message: 'Одинаковые пропсы: ' + s.renders[i].props,
                })
              }
            }
          }
          return suspicious
        },
    
        reset() { stats.clear() },
      }
    }
    
    // --- Тестовые "компоненты" ---
    
    function Header({ title }) {
      // Имитируем тяжёлый компонент
      let sum = 0
      for (let i = 0; i < 10000; i++) sum += i
      return { tag: 'header', content: title, checksum: sum % 100 }
    }
    
    function Button({ label, variant }) {
      return { tag: 'button', label, variant }
    }
    
    function Avatar({ userId }) {
      let hash = 0
      for (let i = 0; i < 5000; i++) hash ^= i
      return { tag: 'img', src: '/avatars/' + userId, hash }
    }
    
    // --- Профилирование ---
    
    const profiler = createProfiler()
    
    // Нормальные рендеры
    profiler.track('Header', Header, { title: 'Главная' })
    profiler.track('Button', Button, { label: 'ОК', variant: 'primary' })
    profiler.track('Avatar', Avatar, { userId: 42 })
    
    // Симулируем ненужные ре-рендеры (те же пропсы!)
    profiler.track('Button', Button, { label: 'ОК', variant: 'primary' })
    profiler.track('Avatar', Avatar, { userId: 42 })
    
    // Легитимный ре-рендер с другими пропсами
    profiler.track('Button', Button, { label: 'Отмена', variant: 'secondary' })
    profiler.track('Header', Header, { title: 'О нас' })
    
    // --- Анализ ---
    
    console.log('=== Статистика рендеров ===')
    profiler.getStats().forEach(s => {
      console.log(s.componentName + ':')
      console.log('  Рендеров:', s.renderCount)
      console.log('  Всего:', s.totalTime + 'мс')
      console.log('  В среднем:', s.avgTime + 'мс')
    })
    
    console.log('
    === Подозрительные повторные рендеры ===')
    const suspicious = profiler.findUnnecessaryRenders()
    if (suspicious.length === 0) {
      console.log('Ненужных рендеров не обнаружено')
    } else {
      suspicious.forEach(s => {
        console.log('[!] ' + s.component + ' (рендер #' + s.renderIndex + ')')
        console.log('    ' + s.message)
        console.log('    Решение: обернуть в React.memo()')
      })
    }

    Продвинутая оптимизация: Profiler и React DevTools

    React Profiler API

    React Profiler — встроенный инструмент для измерения производительности рендеров:

    import { Profiler } from 'react'
    
    function onRenderCallback(
      id,             // "displayName" Profiler
      phase,          // "mount" или "update"
      actualDuration, // время рендеринга в мс
      baseDuration,   // расчётное время без мемоизации
      startTime,      // когда React начал рендеринг
      commitTime      // когда React зафиксировал рендеринг
    ) {
      console.log(`[${id}] ${phase}: ${actualDuration.toFixed(2)}мс`)
    }
    
    function App() {
      return (
        <Profiler id="Navigation" onRender={onRenderCallback}>
          <Navigation />
        </Profiler>
      )
    }

    React DevTools Profiler

    В браузерном расширении React DevTools вкладка Profiler показывает:

    Flame chart — визуализация дерева рендеринга:

  • Ширина = время рендеринга
  • Серый = не рендерился в этом коммите
  • Жёлтый/оранжевый = медленный рендер
  • Зелёный = быстрый рендер
  • Ранжирование — компоненты от самого медленного к быстрому.

    Как использовать:

    1. Открой DevTools → Profiler

    2. Нажми "Start profiling"

    3. Совершай действия в интерфейсе

    4. Нажми "Stop profiling"

    5. Исследуй flame chart

    why-did-you-render

    Библиотека для обнаружения ненужных ре-рендеров:

    // src/wdyr.ts (только для разработки)
    import React from 'react'
    
    if (process.env.NODE_ENV === 'development') {
      const whyDidYouRender = require('@welldone-software/why-did-you-render')
      whyDidYouRender(React, {
        trackAllPureComponents: true,
        // Или точечно для конкретного компонента:
        // logOwnerReasons: true,
      })
    }
    
    // Включить для конкретного компонента
    MyComponent.whyDidYouRender = true
    // why-did-you-render покажет:
    // [MyList] Re-rendered. Причина: изменились пропсы
    // Предыдущие: { items: Array(3) }
    // Текущие:    { items: Array(3) }  ← ОДИНАКОВЫЕ! Значение то же, но новая ссылка

    Анализ бандла: webpack-bundle-analyzer

    # Для Create React App
    npx source-map-explorer 'build/static/js/*.js'
    
    # Для Vite
    npm install rollup-plugin-visualizer -D
    // vite.config.ts
    import { visualizer } from 'rollup-plugin-visualizer'
    
    export default {
      plugins: [
        visualizer({
          filename: 'stats.html',
          open: true,
          gzipSize: true,
        })
      ]
    }

    Что искать в анализе бандла:

  • Большие библиотеки (moment.js ~300KB → date-fns ~20KB)
  • Дублирующийся код
  • Компоненты, которые попали в основной чанк, но используются редко
  • Code Splitting с React.lazy и Suspense

    import { lazy, Suspense } from 'react'
    
    // Ленивая загрузка: компонент загрузится только когда понадобится
    const HeavyChart = lazy(() => import('./HeavyChart'))
    const AdminPanel = lazy(() => import('./AdminPanel'))
    
    function App() {
      const isAdmin = useUser().role === 'admin'
    
      return (
        <Suspense fallback={<div>Загрузка...</div>}>
          <HeavyChart data={chartData} />
          {isAdmin && <AdminPanel />}
        </Suspense>
      )
    }
    
    // Роутинг с ленивой загрузкой страниц
    const HomePage = lazy(() => import('./pages/HomePage'))
    const BlogPage = lazy(() => import('./pages/BlogPage'))
    const ProfilePage = lazy(() => import('./pages/ProfilePage'))
    
    function Router() {
      return (
        <Suspense fallback={<PageLoader />}>
          <Routes>
            <Route path="/" element={<HomePage />} />
            <Route path="/blog" element={<BlogPage />} />
            <Route path="/profile" element={<ProfilePage />} />
          </Routes>
        </Suspense>
      )
    }

    React Compiler (React 19+)

    React Compiler автоматически добавляет мемоизацию — больше не нужно вручную писать useMemo и useCallback:

    npm install babel-plugin-react-compiler -D
    // babel.config.js
    module.exports = {
      plugins: [
        ['babel-plugin-react-compiler', {
          target: '18',  // совместимость с React 18
        }]
      ]
    }
    // До React Compiler (ручная мемоизация)
    function ProductList({ products, onBuy }) {
      const sorted = useMemo(
        () => [...products].sort((a, b) => a.price - b.price),
        [products]
      )
      const handleBuy = useCallback(
        (id) => onBuy(id),
        [onBuy]
      )
      return sorted.map(p => <Product key={p.id} product={p} onBuy={handleBuy} />)
    }
    
    // После React Compiler — он добавит мемоизацию автоматически!
    function ProductList({ products, onBuy }) {
      const sorted = [...products].sort((a, b) => a.price - b.price)
      return sorted.map(p => <Product key={p.id} product={p} onBuy={(id) => onBuy(id)} />)
    }

    Практические советы

    // 1. Виртуализация списков (react-virtual или react-window)
    import { useVirtualizer } from '@tanstack/react-virtual'
    
    function BigList({ items }) {
      const parentRef = useRef(null)
      const virtualizer = useVirtualizer({
        count: items.length,
        getScrollElement: () => parentRef.current,
        estimateSize: () => 50,
      })
    
      return (
        <div ref={parentRef} style={{ height: 400, overflow: 'auto' }}>
          <div style={{ height: virtualizer.getTotalSize() }}>
            {virtualizer.getVirtualItems().map(virtualRow => (
              <div
                key={virtualRow.index}
                style={{ transform: `translateY(${virtualRow.start}px)` }}
              >
                {items[virtualRow.index].name}
              </div>
            ))}
          </div>
        </div>
      )
    }
    
    // 2. Дебаунс для часто срабатывающих обработчиков
    function SearchInput() {
      const [query, setQuery] = useState('')
      const debouncedSearch = useMemo(
        () => debounce((q) => fetchResults(q), 300),
        []
      )
    
      return (
        <input
          value={query}
          onChange={e => {
            setQuery(e.target.value)
            debouncedSearch(e.target.value)
          }}
        />
      )
    }

    Примеры

    Профайлер рендеров на ванильном JS: отслеживание количества рендеров, времени выполнения и выявление лишних повторных рендеров

    // Симулируем React Profiler: отслеживаем рендеры компонентов,
    // измеряем время и находим ненужные повторные вызовы.
    
    // --- Профайлер ---
    
    function createProfiler() {
      const stats = new Map()  // componentName -> { renderCount, totalTime, renders[] }
    
      return {
        // Записать рендер компонента
        track(componentName, renderFn, props) {
          const start = performance.now()
          const result = renderFn(props)
          const duration = performance.now() - start
    
          if (!stats.has(componentName)) {
            stats.set(componentName, { renderCount: 0, totalTime: 0, renders: [] })
          }
    
          const s = stats.get(componentName)
          s.renderCount++
          s.totalTime += duration
          s.renders.push({ duration, props: JSON.stringify(props) })
    
          return result
        },
    
        // Получить статистику
        getStats() {
          const result = []
          for (const [name, s] of stats) {
            result.push({
              componentName: name,
              renderCount: s.renderCount,
              totalTime: Math.round(s.totalTime * 100) / 100,
              avgTime: Math.round((s.totalTime / s.renderCount) * 100) / 100,
            })
          }
          return result.sort((a, b) => b.totalTime - a.totalTime)
        },
    
        // Найти подозрительные повторные рендеры с теми же пропсами
        findUnnecessaryRenders() {
          const suspicious = []
          for (const [name, s] of stats) {
            if (s.renders.length < 2) continue
            for (let i = 1; i < s.renders.length; i++) {
              if (s.renders[i].props === s.renders[i - 1].props) {
                suspicious.push({
                  component: name,
                  renderIndex: i,
                  message: 'Одинаковые пропсы: ' + s.renders[i].props,
                })
              }
            }
          }
          return suspicious
        },
    
        reset() { stats.clear() },
      }
    }
    
    // --- Тестовые "компоненты" ---
    
    function Header({ title }) {
      // Имитируем тяжёлый компонент
      let sum = 0
      for (let i = 0; i < 10000; i++) sum += i
      return { tag: 'header', content: title, checksum: sum % 100 }
    }
    
    function Button({ label, variant }) {
      return { tag: 'button', label, variant }
    }
    
    function Avatar({ userId }) {
      let hash = 0
      for (let i = 0; i < 5000; i++) hash ^= i
      return { tag: 'img', src: '/avatars/' + userId, hash }
    }
    
    // --- Профилирование ---
    
    const profiler = createProfiler()
    
    // Нормальные рендеры
    profiler.track('Header', Header, { title: 'Главная' })
    profiler.track('Button', Button, { label: 'ОК', variant: 'primary' })
    profiler.track('Avatar', Avatar, { userId: 42 })
    
    // Симулируем ненужные ре-рендеры (те же пропсы!)
    profiler.track('Button', Button, { label: 'ОК', variant: 'primary' })
    profiler.track('Avatar', Avatar, { userId: 42 })
    
    // Легитимный ре-рендер с другими пропсами
    profiler.track('Button', Button, { label: 'Отмена', variant: 'secondary' })
    profiler.track('Header', Header, { title: 'О нас' })
    
    // --- Анализ ---
    
    console.log('=== Статистика рендеров ===')
    profiler.getStats().forEach(s => {
      console.log(s.componentName + ':')
      console.log('  Рендеров:', s.renderCount)
      console.log('  Всего:', s.totalTime + 'мс')
      console.log('  В среднем:', s.avgTime + 'мс')
    })
    
    console.log('
    === Подозрительные повторные рендеры ===')
    const suspicious = profiler.findUnnecessaryRenders()
    if (suspicious.length === 0) {
      console.log('Ненужных рендеров не обнаружено')
    } else {
      suspicious.forEach(s => {
        console.log('[!] ' + s.component + ' (рендер #' + s.renderIndex + ')')
        console.log('    ' + s.message)
        console.log('    Решение: обернуть в React.memo()')
      })
    }

    Задание

    Создай React компонент OptimizedList, демонстрирующий оптимизацию с memo, useMemo и useCallback. Компонент должен: иметь список элементов items в состоянии, использовать useMemo для фильтрации/сортировки списка, использовать useCallback для обработчика удаления, передавать обработчик в мемоизированный дочерний компонент ListItem (обёрнутый в memo).

    Подсказка

    В useMemo: filter.toLowerCase() для фильтрации, a.priority - b.priority для сортировки, зависимости [items, filter]. В useCallback: item.id !== id для фильтрации. При добавлении: [...prev, newItem]. onChange: e.target.value. В map: key={item.id}, item={item}, onDelete={handleDelete}.

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