Всем привет! Если вы используете React для создания UI, то уверена, что вы слышали о таких понятиях, как PureComponent, memo, useCallback и прочих возможностях, которые нам предоставляют создатели библиотеки для оптимизации наших приложений. Разработчики React уже позаботились о том, чтобы обновление DOM было предсказуемым и производительным: преобразования деревьев React-элементов выполняются максимально эффективно с помощью алгоритма согласования (reconciliation). Однако при большом количестве компонентов, глубокой вложенности или неправильной архитектуре количество отрисовок или вызовов функций может заметно увеличиться. Для оптимизации использования ресурсов мы применяем различные приёмы, позволяющие нам, к примеру, избавиться от лишних отрисовок с одинаковыми входными значениями props.

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

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

Хуки как чёрный ящик: неправильное использование useCallback


Зачастую разработчики представляют себе хуки компонентов как ящик, который принимает на вход определённые параметры и выполняет некую магию.

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

Рассмотрим пример обычного компонента с колбэком, чтобы убедиться, что это не так. Колбэк создаётся и записывается в переменную при каждой отрисовке компонента:

const Component = () => {
  const onClick = () => console.log('Clicked');

    return <div onClick={ onClick }>Компонент</div;
);

Обернём функцию в useCallback:

const Component = () => {
  const onClick = useCallback(() => {
    console.log('Clicked');
  }, []);

  return <div onClick={ onClick }>Компонент</div>;
);

Многие могут подумать, что в таком случае мы оптимизируем компонент, ведь один раз создали и передали функцию в хук useCallback, который принимает обычную функцию и возвращает мемоизированную. На самом же деле useCallback, как и любая другая функция и любой хук, вызывается при каждой отрисовке компонента. В данном случае вызов useCallback() аналогичен отдельному созданию нашей функции и передаче её как аргумента:

const Component = () => {
    const handleClick = () => console.log('Clicked');
    const onClick = useCallback(handleClick, []);

    return <div onClick={ onClick }>Компонент</div>;
);

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

Ошибка в передаче мемоизированного колбэка


Рассмотрим ещё один пример с использованием хука useCallback.

const ChildComponent = ({ id, onClick }) => {
  return (<div onClick={ () => onClick(id) }>Some nested component..</div>);
};

const MainComponent = () => {
  const [selectedId, setSelectedId] = React.useState('1');
  
  const onClick = useCallback((id) => {
    setSelected(id);
  }, []);

  return (
    <div>
      <ChildComponent id="1" onClick={ onClick } />
            <ChildComponent id="2" onClick={ onClick } />
    </div>
  );
};

Мы создали компонент, принимающий колбэк onClick. В MainComponent используется хук useCallback, благодаря которому мы всегда получаем одну и ту же мемоизированную функцию onClick, которую передаём дочерним компонентам. В данном случае использование хука тоже никак не оптимизирует работу приложения, так как рендер дочерних компонентов всё также будет выполняться после каждого обновления состояния MainComponent (установки selectedId).

Чтобы исправить это, необходимо обернуть ChildComponent в функцию React.memo():

const ChildComponent = ({ id, onClick }) => {
  return (<div onClick={ () => onClick(id) }>Some nested component..</div>);
};

const MemoizedChildComponent = React.memo(ChildComponent);

const MainComponent = () => {
  const [selectedId, setSelectedId] = React.useState('1');
  
  const onClick = useCallback((id) => {
    setSelected(id);
  }, []);

  return (
    <div>
      <MemoizedChildComponent id="1" onClick={onClick} />
      <MemoizedChildComponent id="2" onClick={onClick} />
    </div>
  ); 
};

Использование memo() по-умолчанию без параметров позволяет поверхностно сравнить предыдущие и новые props на наличие изменений, а использование useCallback гарантирует передачу одной и той же функции в мемоизированный дочерний компонент, и лишних отрисовок удастся избежать.Этот подход особенно актуален, если ChildComponent — компонент со сложной структурой и другими вложенными компонентами, отрисовка которого ощутимо влияет на производительность.

Приёмы оптимизации без использования memo()


Правильная композиция компонентов также позволит избежать лишних отрисовок дочерних компонентов без дополнительных манипуляций. Рассмотрим несколько примеров:

  1. Приём «Спуск состояния вниз». Если изменение состояния родительского компонента вызывает лишнюю отрисовку дочерних компонентов, то стоит вынести состояние и соответствующую вёрстку в ещё один отдельный дочерний компонент. В таком случае изменение состояния в одном компоненте не вызовет лишней отрисовки в соседнем.
  2. Приём «Поднятие состояния вверх». В случае, когда состояние не получится извлечь в отдельный компонент, возможно изменение структуры компонентов с использованием Children. В таком случае будет отрисован только компонента, который действительно изменяется, а компоненты, прокидываемые в Children, будут оставаться без изменений.

Код примеров и подробное описание обоих приёмов можно изучить в статье Дэна Абрамова «Before you memo()».

Передача лишних свойств в мемоизированные компоненты


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

Рассмотрим на примере небольшого приложения с несколькими карточками тарифов и возможностью выбрать один из них:

const TariffCard = ({ id, selectedTariff, onClick }) => {
  const selected = selectedTariff === id;

  return (
    <div
      onClick={ () => onClick(id) }
      className={ selected ? 'selected' : 'not-selected' }
     >
      Tariff { id }
    </div>
  );
};

const MemoizedTariffCard = React.memo(TariffCard);

const tariffs = ['tariff_1', 'tariff_2', 'tariff_3'];

const App = () => {
  const [selectedTariff, setSelectedTariff] = React.useState('tariff_1');
  
  const onClick = React.useCallback((id) => {
    setSelectedTariff(id);
  }, []);

  return (
    <div>
      { tariffs.map((tariffId) => (
        <MemoizedTariffCard
          id={ tariffId }
          selectedTariff={ selectedTariff }
          onClick={ onClick }
        />
      ))}
    </div>
  );
};

При клике на один из тарифов и изменении выбранного тарифа в состоянии компонента будут перерисованы все карточки:


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

const TariffCard = ({ id, selected, onClick }) => {
  return (
    <div
      onClick={ () => onClick(id) }
      className={ selected ? 'selected' : 'not-selected' }
    >
      Tariff { id }
    </div>
  );
};

const MemoizedTariffCard = React.memo(TariffCard);

const tariffs = ['1', '2', '3'];

const App = () => {
  const [selectedTariff, setSelectedTariff] = React.useState('1');
  
  const onClick = React.useCallback((id) => {
    setSelectedTariff(id);
  }, []);

  return (
    <div>
      { tariffs.map((tariffId) => (
        <MemoizedTariffCard
          id={ tariffId }
          selected={ selectedTariff === tariffId }
          onClick={ onClick }
        />
      ))}
    </div>
  );
};

После небольших изменений при нажатии на карточку, отрисовка происходит только в тех компонентах, у которых props действительно изменились:


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

Спасибо за внимание, делитесь своими предложениями в комментариях!

Комментарии (7)


  1. LaoAx
    22.03.2022 20:35

    Спасибо за статью.

    А сможете добавить пару слов о том как пофиксить случай из самого первого примера?

    Который перед словами

    Помимо создания функции при каждом вызове компонента теперь дополнительно создаётся массив зависимостей...


    1. iShoma
      23.03.2022 02:04
      +2

      Для данного примера

      const handleClick = () => console.log('Clicked');
      
      const Component = () => {
          return <div onClick={ handleClick }>...</div>;
      );

      Но этот пример не совсем из реальной жизни. Под оптимизацией React компонентов чаще всего понимают оптимизацию лишних ререндеров, а не оптимизацию по памяти и побочных расчетов, которые не дадут заметное ускорение производительности (сотни наносекунд можно даже не увидеть в профайлере). И в общем случае useCallback спасет от перендера функционального компонента.

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


    1. puchnina_nastya Автор
      24.03.2022 17:03
      +1

      Здравствуйте! Рада, если статья оказалась Вам полезна.

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

      Можно вынести функцию за пределы компонента, если она не зависит от props и state, либо оставить её, как есть. Про влияние создания функций при каждом рендере на производительность можно прочитать ответ в официальной документации React: https://ru.reactjs.org/docs/hooks-faq.html#are-hooks-slow-because-of-creating-functions-in-render


  1. MVN63
    23.03.2022 08:03
    +1

    Спасибо за примерчики.
    IMHO, пора обобщить опыт и написать грамотное руководство по "кошерным" паттернам React с подробными примерами.


    1. Alexander_Front-end
      24.03.2022 09:49

      Хорошая идея, я бы на такое глянул


  1. drsgh
    23.03.2022 09:38

    Сеттеры стейта - уже мемоизованные функции (согласно документации), поэтому их не нужно обарачивать в useCallback.


    1. puchnina_nastya Автор
      24.03.2022 16:40

      Добрый день! Спасибо за комментарий) Да, так и есть: Реакт гарантирует, что она не изменится.

      В данном случае стоит заметить, что в примерах в useCallback передается не сама функция-сеттер, а еще одна стрелочная функция, которая содержит этот сеттер. Это пример, в реальности эта функция могла вызывать не только сеттер, но и отправлять метрику на click, например, или вызывать еще один колбэк.