DIGITAL SECTOR

Мемоизация и рендеринг в React

Проблема

Если вы занимаетесь разработкой средних или крупных приложений, то наверняка слышали про хуки useMemo и useCallback. И, возможно, сталкивались с ситуацией, когда код вашего приложения напоминал «множество неясных useMemo и useCallback, которые усугубляли читаемость и усложняли отладку». Эти хуки иногда могут разрастись по вашему коду, словно не поддающиеся контролю сорняки, и вы начнете добавлять их без разбора — только потому, что они повсюду, и все вокруг их используют.

Вы можете прямо сейчас убрать большинство useMemo и useCallback из вашего приложения, и оно продолжит работать корректно, и, возможно, даже станет немного быстрее. Проблема заключается в том, что их использование оправдано только в нескольких конкретных случаях, но, к сожалению, мы часто применяем их в ситуациях, где они совсем не нужны.

Давайте начнем с основ. Сначала вспомним про хуки useState и useEffect.

useState — это хук, который отвечает за состояние компонента в функциональных компонентах.

useEffect — хук, позволяющий выполнять побочные эффекты в функциональных компонентах. Побочные эффекты могут быть связаны с загрузкой данных, изменением состояния компонента, обновлением DOM и т.д. Для этого в массиве зависимостей указываются параметры для повторного вызова хука.

Пример useEffect
 const [state, setState] = useState() // храним состояние компонента

  useEffect(() => {
              setState('some')
            }, [dependency]) // массив зависимостей 

Также хотелось бы упомянуть о props. props (сокращение от properties) в React — это объект, содержащий свойства компонента, переданные ему из родительского компонента. propsпозволяют передавать данные от родительского компонента к дочернему компоненту и использовать эти данные для отображения различных значений и состояний компонента.

В мире React компоненты — это, по сути, функции. И вот тут возникает небольшая проблема: каждый раз, когда состояние компонента обновляется, его внутренний код выполняется заново. Давайте рассмотрим пример с родительским компонентом, использующим useState и useEffect:

Каждый раз, когда состояние state обновляется или срабатывает useEffect, все дочерние компоненты этого родителя ререндерятся. Это означает, что код внутри дочерних компонентов снова выполняется, и, если у них есть сложные вычисления, это может негативно сказаться на пользовательском опыте из-за замедления работы приложения. Разрешить эту проблему поможет мемоизация.

Мемоизация — это процесс сохранения результатов выполнения функции с определенными аргументами и возврата уже сохраненного результата при повторном вызове функции с теми же аргументами. В Reactмемоизацию можно использовать с помощью функции React.memo для оптимизации компонентов.

Нужна мемоизация или нет?

Скорее всего нет, особенно если ваши компоненты не содержат сложной логики, ваши компоненты принимают одни и те же данные при рендере, и на UI нет визуальных багов.

Но если вы видите баги или у вас чувствительные данные (постоянно меняющийся курс с биржи), то мы можем измерить время, затраченное на его рендеринг. Для этого нужно включить тротлинг CPU и вставить код для записи времени в начало и конец функции renderItems():

                      
const MyComponent = ({ data }) => {
 const renderItems = () => {
 const startTime = performance.now() // Записываем время начала выполнения

 // Выполняем сложные вычисления для каждого элемента списка
 const items = data.map((item) => {
   // Сложные вычисления
  const result = performComplexCalculation(item)
  return result
     })

    const endTime = performance.now() // Записываем время завершения выполнения

    return items
  }

  return renderItems()
}
                      
                    

После измерения времени выполнения, мы можем определить, занимает ли рендеринг этого компонента значительное количество времени. Например, если время выполнения составляет более 50 миллисекунд, это может сигнализировать о необходимости оптимизации. Важно всегда проводить тесты производительности на слабых компьютерах, так как у ваших клиентов оборудование разной мощности. В таком случае рассмотрите возможность применения мемоизации с использованием useMemo или React.memo для улучшения производительности компонента.

Цена мемоизации

useMemo и useCallback при вызове:

  • Вызывает функцию mountMemo для расчета результата useMemo
  • Вызывает функцию updateMemo для обновления результата useMemo
  • При каждом рендере вновь вызывает функцию updateMemo для обновления результата
  • Для сравнения зависимостей React использует свои внутренние механизмы сравнения

Отсюда можно сделать следующие выводы:

  • Если простая логика, то лучше не использовать useMemo. Это будет быстрее.
  • Чем больше зависимостей у useMemo, тем больше времени потребуется на проверку изменений этих зависимостей и увеличению цены мемоизации.

Правила работы с мемоизацией в React

useMemo и useCallback при вызове:

  • React.memo — это функция высшего порядка (higher-order component, HOC), которая оборачивает компонент и проверяет, изменились ли его props. Если props остались неизменными, то React вернет ранее созданный визуальный вывод компонента, минуя его рендеринг, что может существенно повысить производительность приложения. Однако React все равно может его ререндерить. Ссылка на документацию
  • useMemo — это хук, который принимает функцию и массив зависимостей, и возвращает мемоизированное значение этой функции. Он позволяет избежать повторного выполнения сложных вычислений для одних и тех же props. Ссылка на документацию
  • useCallback — это хук, который принимает функцию и массив зависимостей, и возвращает мемоизированную версию этой функции. Он полезен, когда нам нужно передавать колбэк-функцию в дочерние компоненты, чтобы она не пересоздавалась каждый раз при рендеринге родительского компонента. Ссылка на документацию

Однако, использование этих инструментов требует разумного подхода, злоупотребление ими может привести к избыточной сложности и ухудшению производительности приложения. Вот несколько ключевых рекомендаций по их использованию:

  • Не используйте React.memo, useMemo и useCallback бездумно.

Использование этих инструментов не бесплатно с точки зрения производительности, помните про цену мемоизации. Поэтому применяйте их только там, где они действительно необходимы, например, для оптимизации дорогостоящих вычислений.

                      

  const MyComponent = ({a, b, ...numbers}) => {
  // не правильное использование useMemo
  // const value = useMemo(()=> a + b, [a,b])

  // useMemo избыточен
  // const validValue = a + b

  // правильное использование useMemo для массивов
  const value = useMemo(() => numbers.reduce((a,c) => a + c, 0),[numbers])

  return {value}
}


                      
                    

В данном примере происходит мемоизация операции сложения. Кажется, что здесь явно можно просто вычислить сумму и ее вывести, так как React очень быстро обрабатывает рендеры

Поймите ограничения React.memo.

React.memo работает только с простыми типами данных и значением ссылки. Есть сценарии, когда использование React.memo может быть неэффективным или даже не подходить вообще:

  • Часто изменяющиеся props: для того, чтобы мемоизация работала, надо оборачивать компонент вReact.memo, а это может даже увеличить накладные расходы из-за проверок на изменения.
  • Изменение сложных объектов: eсли ваши props представляют собой сложные объекты, и вы изменяете их часто, React.memo будет выполнять глубокое сравнение объектов, что может стать затратным с точки зрения производительности. В таких случаях имеет смысл использовать другие методы оптимизации, такие как useCallback для колбэков или мемоизация данных.
  • Компоненты с побочными эффектами: eсли ваш компонент выполняет побочные эффекты (например, работает с жизненным циклом или использует useEffect), использование React.memo может повлиять на поведение этих эффектов. Это связано с тем, что мемоизация компонента может привести к тому, что он не будет монтироваться или размонтироваться в ожидаемый момент.
  • Динамическое создание функций в props: eсли ваши props включают динамически создаваемые функции, такие как колбэки, использование React.memo может сделать его неэффективным, так как каждый раз будет создаваться новая функция, и компонент будет рендериться заново.

В этом примере при смене цвета всегда будет рендер MemoSquare, так как React.memo не запоминает всю историю props, например, как это делает функция memoize в lodash. Однако, если мы будем увеличивать счетчик, кликая на кнопку, то MemoSquare не будет перерендериваться, так как значение color остается неизменным.

Мемоизируйте функции, массивы и объекты.

Взгляните на предыдущий пример. В качестве props у Square примитив, потому при смене любого state, который не связан с Square, не будет происходить рендера.

Изменим предыдущий пример, вместо примитива добавим объект:

Чтобы исправить мемоизацию, нужно использовать аргумент compare у memo
import React, { useState } from 'react'

function App() {
            return (
              <>
      <MyComponent />
    </>
            )
          }

          const MyComponent = () => {
            const [color, setColor] = useState('red')
              const [counter, setCounter] = useState(0)

            return (
            <>
            <button
            onClick={() => setColor((prev) => (prev === 'red' ? 'blue' : 'red'))}
          >
          Change color
      </button>
      <button onClick={() => setCounter((prev) => prev + 1)}>
        Increment result . Result is {counter}
        </button>
      <MemoSquare params={{ color }} />
    </>
  )
        }

        const Square = ({ params }) => {
          console.log('Square RENDER')
          return (
          <div style={{ width: 50, height: 50, backgroundColor: params.color }} />
  )
          }
          const MemoSquare = React.memo(Square)

export default App
или обернуть props в useMemo
const Square = ({ params }) => {
          return (
            <div style={{ width: 50, height: 50, backgroundColor: params.color }} />
  )
              }

              const MemoSquare = React.memo(Square)

const MyComponent = ({ a, b }) => {
                const [color, setColor] = useState('red')
                  // пример с мемоизацией props
                const params = useMemo(()=>({color}), [color])

                return (
                <>
                <button onClick={() => setColor((prev) => prev === 'red' ? 'blue' : 'red')}>
              Change color
      <button />
      // у MemoSquare все равно будут рендеры , так как params это объект
      // который всегда будет получать новую ссылку
      <MemoSquare params={params}/>
    </>
  )
              }

Помните, что React.memo сравнивает предыдущие значения по ссылкам. Если функции, массивы или объекты могут измениться, мемоизируйте их с помощью useCallback и useMemo, чтобы избежать ненужных рендеров

Не перегружайте родительский компонент.

Рендер на уровне родительского компонента затрагивает все его дочерние компоненты. Если логика зависит от разных данных, лучше разместить ее внутри соответствующих дочерних компонентов.

// BAD
          const Parent = () => {
          // логика получения данных
          const { items } = useGetSomeData()
          // какие-то вычисления
          const [state, setState] = useState()
          const [result, setResult] = useState(0)

          useEffect(() => {
          console.log('RENDER')
        }, [state])

  // логика связанная с фильтрацией
  const router = useRouter()
  const page = router.query.page

  useEffect(() => {
            if (page) {
            router.push({
            url: router.asPath,
            query: page,
          })
          }
          }, [router.query])

  return (
    <div>
      {state}
      <button onClick={() => setState((prev) => prev + 1)}>Click me</button>
      <Filters router={router} />
      <ExpensiveCalc result={result} setResult={setResult} />
        {items.map(({ id, title }) => (
          <span key={id}>{title}</span>
          ))}
        </div>
  )
        }

Рассмотрите альтернативные способы оптимизации.

Помимо мемоизации существуют другие методы оптимизации: такие как создание чистых компонентов (Pure Components) и разделение логики по компонентам.

Следующий пример покажет, как можно сделать мемоизацию, хотя это избыточно:

Способ 1.
// Мемоизация компонента
const ExpensiveCalc = ({result, onClick}) => {
            return <button onClick={onClick}>Increment result => {result}</button>
        }
        const MemoExpensiveCalc = React.memo(ExpensiveCalc)

        const Parent = () => {
        // логика получения данных
        const { items } = useGetSomeData()
        // какие-то вычисления
        const [state, setState] = useState()
        const [result, setResult] = useState(0)
        useEffect(() => {
        console.log('RENDER')
      }, [state])
        // логика связанная с фильтрация
        const router = useRouter()
        const page = router.query.page

        useEffect(() => {
        if (page) {
        router.push({
        url: router.asPath,
        query: page,
      })
      }
      }, [router.query])

        // мемоизация props для ExpensiveCalc
        const onClick = useCallback((params) => setResult(params), [])
        const memoResult = useMemo(() => result, [result])

        return (
        <div>
          {state}
          <button onClick={() => setState((prev) => prev + 1)}>Click me</button>
      <Filters router={router} />
      <MemoExpensiveCalc result={memoResult} onClick={onClick} />
            {items.map(({ id, title }) => (
              <span key={id}>{title}</span>
              ))}
            </div>
  )
            }

Мы мемоизировали props и сам компонент, сократили количество рендеров и одного из детей, но родитель при этом также перегружен. Это сложно читаемо. Можно сделать следующим образом:

const ExpensiveCalc = () => {
          const [result, setResult] = useState(0)
            return (
          <button onClick={() => setResult(prev => prev + 1)}>
          Increment result => {result}
          </button>
  )
          }

          const Filters = () => {
            const router = useRouter()
            const page = router.query.page

            const onChangePage = (page: number) => {
            router.push({
            url: router.asPath,
            query: page + 1,
          })
          }

          return (
    <Pagination onPageChange={({ selected }) => onChangePage(selected)}>
      <div>1</div>
      <div>2</div>
      <div>3</div>
      <div>4</div>
      <div>5</div>
      </Pagination>
      )
    }

      const Items = () => {
        const { items } = useGetSomeData()
        return (
        <>
      {items.map(({ id, title }) => (
        <span key={id}>{title}</span>
      ))}
      </>
  )
          }

          const MemoItems = React.memo(Items)

const Parent = () => {
            // какие-то вычисления
            const [state, setState] = useState()
            useEffect(() => {
            console.log('RENDER')
          }, [state])

            return (
            <div>
          {state}
          <button onClick={() => setState((prev) => prev + 1)}>Click me</button>
      <Filters />
      <ExpensiveCalc />
      <MemoItems />
    </div>
  )
            }

Рассмотрите альтернативные способы оптимизации.

При таком подходе у родителя сократится количество рендеров, и вся логика будет инкапсулирована внутри детей.

Способ 2.
const Parent = ({ children }) => {
          const [state, setState] = useState(0)
          useEffect(() => {
          console.log('RENDER')
        }, [state])

          return (
          <div>
        {state}
          <button onClick={() => setState((prev) => prev + 1)}>Click me</button>
            {children}
            </div>
  )
            }

            const App = () => {
              return (
                <Parent>
      <SomeChildren />
    </Parent>
              )
            }

Мы разделили компонент приложения на две части. Части, которые зависят от цвета, вместе с самой переменной состояния цвета, перешли в Parent.

Части, которые не заботятся о state, остались в компоненте приложения и передаются в Parent, как контент JSX, также известный как children. Когда state меняется, Parent повторно отрендерит. Но у него все еще есть тот жеchildren, который он получил из приложения в прошлый раз, поэтому React не посещает это поддерево.

И, в результате <SomeChildren /> не перерендерится.

Определите, что важнее бизнесу: соблюдение сроков или производительность?

При разработке приложений важно учитывать бизнес-требования. Иногда соблюдение сроков является приоритетом, и необходимо сосредоточиться на быстрой разработке. В других случаях производительность может быть критическим фактором, и оптимизация рендеринга становится важной задачей. Важно понимать, что в каждом конкретном случае может быть своя стратегия оптимизации.

Это основные принципы, связанные с правильным использованием мемоизации и оптимизацией в React. Учитывайте их при разработке ваших приложений, и они помогут вам эффективнее управлять производительностью и поддержкой вашего кода.

Заключение

  • они передаются как атрибуты, напрямую или через цепочку зависимостей элементам DOM
  • они передаются как props, напрямую или через цепочку зависимостей компоненту, который не мемоизирован
  • они передавались в качестве props, напрямую или через цепочку зависимостей, компоненту, у которого хотя бы один реквизит не мемоизирован

useMemo и useCallback надо использовать, когда:

  • на клиенте идут сложные вычисления (факториал, сортировка или работа с очень большими массивами данных)
  • тест perfomance.now() показывает, что выгода использования мемоизации равна более 10%

React постоянно развивается и ведет работу над улучшением Developer Experience (DX) и User Experience (UX). Одним из направлений развития является уход от покрытия всего и вся с помощьюuseMemo и useCallback. Хотя эти инструменты могут улучшить производительность, они могут усложнить разработку и поддержку кода. Поэтому, будущие версии React могут предоставить альтернативные подходы к оптимизации рендеринга.

Материалы для дополнительного изучения:

Согласен