Если вы читаете эту статью, у вас могут возникнуть вопросы по этой теме. Многие из нас знают об useCallback и useMemo и считают необходимым использовать эти хуки в коде. Однако мы часто делаем это неправильно. В значительной степени я считаю, что это проблема документации React. Отсутствует четкий ответ на вопрос: «Когда следует использовать эти хуки?» Но не волнуйтесь. Во всем разберемся и напишем отличный, производительный и читабельный код.

Часть 1: Краткий обзор

Если посмотреть официальную документацию, React использует виртуальный DOM, который позже трансформируется в HTML-разметку. И виртуальные, и реальные DOM представляют собой древовидные структуры данных.

React хранит виртуальное DOM-дерево (читается как объект) в памяти компьютера и при каждом изменении данных (useState, useSelector…) сравнивает новое состояние этого дерева с текущим, обновляя только то, что требует изменений (которые узлы виртуального дерева должны быть перерендерены). Вот почему нас постоянно предупреждают использовать в списках ключ, а не индекс в качестве ключа.

Внимание! Никогда не делайте этого:

items.map((item, index) => <SomeComponent key={index} name={item.name} />);

Поскольку виртуальное дерево DOM представляет собой древовидную структуру данных, любая операция преобразования дерева имеет кубическую сложность O(N³). Однако команда React добилась линейной сложности O(N) для алгоритма виртуального дерева DOM благодаря своему Алгоритму согласования и эвристике.

Подводя итог: использование индексов в качестве значений для свойства «ключ» — плохая идея из-за специфического поведения библиотеки React.

Часть 2: Согласование реакции

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

Пример №1

Раньше дерево компонентов было таким:

<div>
   <span>
       <SomeButtonComponent type="button">Click me</SomeButtonComponent>
   </span>
</div>

Дерево компонентов теперь:

<span><!-- we've changed div to this span -->
   <span>
       <SomeButtonComponent type="button">Click me</SomeButtonComponent>
   </span>
</span>

В этом примере мы заменили <div> на <span>, и React стал новым узлом дерева, который будет полностью изменен вместе со своими дочерними элементами, что означает, что будут вызываться все крючки жизненного цикла компонента.

Пример #2

const RootComponent = ({foo}: Props) => {
  if (foo === 1) {
    return (
      <div>
        <div>
          <SomeComponentWithSelectors />
        </div>
      </div>
    );
  } 
  
  if (foo === 2) {
    return (
      <div>
        <div>
          <AnotherOneComponentWithSelectorsToo />
          <SomeComponentWithSelectors />
        </div>
      </div>
    );
  }

  return (
    <div>
      <SomeComponentWithSelectors/>
    </div>
  );
}

Так как количество элементов меняется в зависимости от значения свойства foo, React будет выполнять рендеринг, исходя из количественного различия компонентов или их типов.

Короткий вывод: обратите внимание на вложенность элементов и их типы.

Часть 3: Селекторы и Redux

Поскольку современный мир разработки давно отошел от классов в пользу функций, для работы с Redux используется хук useSelector. Обсуждение будет сосредоточено на функциональных компонентах и ​​хуке useSelector.

Каждый раз, когда происходит отправка действия, изменяющего состояние Redux Store, будет вызываться соответствующий useSelector, работающий с данными, измененными действием. Важно понимать, что если useSelector вернет объект или массив (все, что передано по ссылке), ссылка будет проверена. Если ссылки различаются, будет выполнен повторный рендеринг.

Один из неочевидных факторов, влияющих на повторный рендеринг компонента.

Неправильный путь

export const selectTagsByProductId = (state, id) => state.product[id].tags || [];

Правильно

const EMPTY_ARRAY = Immutable([]);

export const selectTagsByProductId = (state, id) => state.product[id].tags || EMPTY

Учитывая, что несколько действий возвращаются в разные моменты времени, это может спровоцировать повторные обращения к useSelector и, как следствие, повторный рендеринг. Поэтому Redux в своей официальной документации рекомендует следующие методы оптимизации:

  1. Возвращает скалярные типы данных (строка, число, логическое значение, неопределенный);
  2. Используйте библиотеки типа reselect или similar;
  3. Используйте функцию smallEqual из пакета «react-redux» в качестве второго аргумента.

Типичный пример использования повторного выбора

const selectUserTagById = (state, userId) => state.user[userId].tag;

const selectPictures = state => state.picture;

const selectRelatedPictureIds = createSelector(
[selectUserTagById, selectPictures],
(tag, pictures) => (
    Object.values(pictures).filter(picture => picture.tags.includes(tag)).map(picture => picture.id)
  )
)

Как reselect понимает, когда брать из кеша, а когда пересчитывать:

  1. Если аргументы селектора не изменились (shallowEqual);
  2. Если результаты inputSelectors не изменились (shallowEqual).

Часть 4: Мемоизация

В заключительной части давайте обсудим стандартные средства оптимизации рендеринга с помощью React.

React.памятка

const MemoizedComponent = React.memo(props => <SomeComponent {...props} />, optionalShallowEqualFn);

Как видно из примера, React.memo кэширует компонент и перерисовывает его только в том случае, если свойства были изменены. Важно понимать, что ссылочные элементы (массивы, объекты, функции) будут влиять на рендеринг, когда для компонента каждый раз приходит «новое» значение. В этом случае никакой пользы от мемоизации не будет.

React.useCallback

const onClick = useCallback(callbackFunc, dependencyList);

Все просто: возвращает новую функцию, если что-то в dependencyList изменилось. В противном случае он создает ссылку на функцию в памяти, которая вернет true из-за сравнения prevProps и nextProps.

Однако не спешите использовать useCallback. Он нужен только для проверки ссылок. Если вам нужно передать функцию собственному элементу DOM (например, ‹button›, ‹div›, ‹input› и т. д.), не используйте useCallback! Вам это не нужно. Нативные элементы не сравнивают ссылки!

React.useMemo

const filteredArray = useMemo(() => someExpensiveOperation(), dependencyList);

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

Заключение

Использование этих хуков обычно полезно, когда Functions, Objects и Arrays передаются в оптимизированные компоненты, которые полагаются на равенство ссылок для предотвращения ненужных рендерингов.

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

Бонус

Я подготовил для вас небольшой демонстрационный пример. Попробуйте поиграть с ним. Измените компонент <AppNoMemoExample/> на компонент <AppMemoExample/>, и вы увидите, как кнопка отображается при каждом изменении счетчика.