Привет, Хабр!

В прошлых моих статьях обсуждая useMemo, мы постоянно упоминали React.memo. Поэтому в этой статье я решил глубоко погрузиться в понятие, что из себя представляет React.memo и конечно же изучим его исходники. (Данная статья является расшифровкой двух видео: React.memo это вам не useMemo и Чем отличается SimpleMemoComponent от MemoComponent?)

HOC это вам не hook

Самое главное отличие между useMemo и memo. Это то что первый является hook-ом, а второй HOC-ом, а именно Higher-Order Component или же Компонент Высшего Порядка.

hook

Чтобы более наглядно объяснить, рассмотрим визуализацию.  Как вы знаете существует виртуальное дерево  состоящее из нод. И у ноды могут буть хуки. При первом рендере их соединяют в linked list, или в переводе связанный список, который называют currentHooks.

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

HOC

С другой стороны Компонент Высшего Порядка это и есть нода, т.е. между нодой родителя и мемоизированного компонента вставляется еще одна нода, которая сравнивает prevProps с currentProps и решает судьбу своих детей.

Таким образом это две принципиально разные сущности и обе по разному обрабатываются внутри React. И как следствие исходники memo будут сильно отличаться от исходников useMemo. Давайте перейдем в исходники и удостоверимся, так ли это на самом деле.

Исходники React.memo

Компонент высшего порядка memo находится в пакете react в файле ReactMemo.js. Если убрать все DEV блоки, в таком случае весь код сведется к паре строк

export function memo<Props>(
  type: React$ElementType,
  compare?: (oldProps: Props, newProps: Props) => boolean,
) {
  const elementType = {
    $$typeof: REACT_MEMO_TYPE,
    type,
    compare: compare === undefined ? null : compare,
  };

  return elementType;
}

Принимает два параметра. Первый type - это непосредственно компонент, который мы оборачиваем в memo. Компонент представляет из себя либо класс, либо функцию.

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

Создание объекта elementType - это все что делает HOC memo. Хорошо, мы поняли, что memo создает какой-то дополнительный объект, а как понять, что этот объект означает?

Рассмотрим как выглядит виртуальная нода

Для этого давайте рассмотрим следующий код:

const Header = () => {
  const title = <h1>test</h1>
  
  console.log('title = ', title);
 
  return title;
};

Как вы думаете что мы увидим в консоли?

Если хотите самостоятельно поразмышлять, тогда не торопитесь скролить вниз

...

..

.

Вот код который вы увидите в консоли:

{
  $$typeof: Symbol('react.element'),
  type: 'h1',
  props: {
    children: 'test',
  }
  
  // ...
}

Здесь мы видим что $$typeof равен Symbol('react.element') - это означает, что нода является html тегом. Переменная type хранит в себе имя тега, в нашем случае h1. Далее мы видим props внутри которых видим children c текстом test. Это тот самый текст который мы передали в h1.

Структура объекта очень похожа на ту, что мы видели в исходниках React.memo. Давайте тогда создадим MemoComponent и так же выведем console.log этого компонента.

const Header = () => {
  return <h1>test</h1>;
};

console.log(memo(Header));

В консоли мы увидим следующий объект:

{
  $$typeof: Symbol('react.memo'),
  type: () => { return <h1>test</h1> },
  compare: null,
  
  // ...
}

Как вы видите, здесь так же есть это странное поле $$typeof с двумя знаками доллара. И поле type присутствует. Именно так и выглядят ноды в виртуальном дереве. Это две абсолютно равноценные ноды. Т.к. у них разные $$typeof, соответственно и набор переменных может отличаться. В случае тега h1 это props, ref, key и другие. С другой стороны у мемоизированной ноды есть поле compare.

Если вам интересно какие еще типы нод существую вы можете перейти в пакет shared файл ReactSymbols.js. Где вы первым типом увидите react.element, который означает любой html тег. Так же здесь известные всем portal, fragment, context, forward_ref и другие. Об этом можно даже сделать отдельное видео, чтобы познакомиться со всеми типами.

Что дальше происходит с виртуальной memo нодой

Я решил не останавливаться в изучении React.memo на создании объекта elementType. И решил найти место, где происходит обработка этого объекта. И кажется, я нашел такое место. Оно находится в пакете react-reconciler в файле ReactFiber.new.js. Там есть такой метод createFiberFromTypeAndProps. Из названия можно предположить, что здесь мы создаем Fiber на основании type и props. Если вы не знаете, что такое Fiber, тогда упрощенно представляйте, что это нода в виртуальном дереве. Я постараюсь как-нибудь в будущем написать на эту тему отдельную статью.

export function createFiberFromTypeAndProps(
  type: any, // React$ElementType
  key: null | string,
  pendingProps: any,
  owner: null | Fiber,
  mode: TypeOfMode,
  lanes: Lanes,
): Fiber {
  // ...
}

Метод получает первым параметром type. Я решил добавить console.log(type), чтобы разобраться, что из себя представляет type. Оказалось, что это тот самый elementType объект ноды, который мы создали ранее в React.memo.

Из-за того что type является объектом, мы попадаем в следующий if, который начинается со switch конструкции, в которую мы передаем $$typeof, который равен как мы знаем REACT_MEMO_TYPE (ссылка на код)

function createFiberFromTypeAndProps( /* ... */ ) {
	// ...
  
  if (typeof type === 'object' && type !== null) {
  	switch (type.$$typeof) {
    	// ...
        
      case REACT_MEMO_TYPE:
        fiberTag = MemoComponent;
        break;
      
      // ...
    }
  }
  
  // ...
  
  const fiber = createFiber(fiberTag, pendingProps, key, mode);
  fiber.elementType = type;
  fiber.type = resolvedType;
  fiber.lanes = lanes;

  return fiber;
}

Сама case конструкция, все что она делает - это сохраняет в какой-то fiberTag значение MemoComponent. И ниже уже на основании этого fiberTag, вызывается метод createFiber и создается fiber нода.

Хорошо, теперь мы знаем, что существует какой-то fiberTab равный MemoComponent. Меня заинтересовало, а какие теги существуют еще. И я перешел по импорту в пакет react-reconciler файл ReactWorkTags.

export const FunctionComponent = 0;
export const ClassComponent = 1;
export const IndeterminateComponent = 2; // Before we know whether it is function or class
export const Fragment = 7;
export const ContextConsumer = 9;
export const ContextProvider = 10;
export const ForwardRef = 11;
export const SuspenseComponent = 13;
export const MemoComponent = 14;       // <- MEMO COMPONENT
export const SimpleMemoComponent = 15; // <- SIMPLE MEMO COMPONENT
export const LazyComponent = 16;
// ...

Что меня тут заинтересовало, я увидел здесь, как MemoComponent так и SimpleMemoComponent. Мне стало интересно, разобраться, а как реакт определяет simple этот MemoComponent или не simple.

Какой memo компонент называется simple?

Чтобы разобраться с понятием simple или не simple, я решил снова поискать, а где эти MemoComponent и SimpleMemoComponent обрабатываются и я нашел такое место в пакете react-reconciler в файле ReactFiberBeginWork. Сам метод называется beginWork, с текущим нашим уровнем погружения в React будет немного сложно объяснить, что это означает, я постараюсь сделать отдельную статью, о фазах работы React-а. А пока перейдем к switch конструкции принимающей tag как входной параметр. И ниже мы найдем case MemoComponent

function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
  // ...
  
  switch (workInProgress.tag) {
    // ...

    case MemoComponent: {
      const type = workInProgress.type;
      const unresolvedProps = workInProgress.pendingProps;
      // Resolve outer props first, then resolve inner props.
      let resolvedProps = resolveDefaultProps(type, unresolvedProps);
      resolvedProps = resolveDefaultProps(type.type, resolvedProps);
      return updateMemoComponent(
        current,
        workInProgress,
        type,
        resolvedProps,
        updateLanes,
        renderLanes,
      );
    }
    
    // ...
  }
  
  // ...
}

Внутри case MemoComponent есть небольшой DEV блок, который резолвит propTypes, нас этот блок не интересует, поэтому я не добавлял его в код выше. Тогда остается, все что делает этот метод, это вызывает два раза resolveDefaultProps и все это передается уже в updateMemoComponent.

Вспоминаем, что такое defaultProps

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

const Cat = ({ color }) => {
  return <p>cat color: {color}</p>
}

Cat.defaultProps = {
  color: 'red',
};

const Cats = () => {
  return (
    <>
      <Cat color="green" />
      <Cat color="blue" />
      <Cat />
    </>
  )
}

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

const Cat = ({ color = 'red' }) => {
  return <p>cat color: {color}</p>
}

const Cats = () => {
  return (
    <>
      <Cat color="green" />
      <Cat color="blue" />
      <Cat />
    </>
  )
}

Теперь я думаю вам более понятно, что именно резолвит метод resolveDefaultProps.

Так в чем же все таки разница между Memo и SimpleMemo?

Если мы еще ниже проскролим метод beginWork, тогда увидим case со значением SimpleMemoComponent. И здесь уже вызывается метод updateSimpleMemoComponent.

function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
  // ...
  
  switch (workInProgress.tag) {
    // ...

    case SimpleMemoComponent: {
      return updateSimpleMemoComponent(
        current,
        workInProgress,
        workInProgress.type,
        workInProgress.pendingProps,
        updateLanes,
        renderLanes,
      );
    }
    
    // ...
  }
  
  // ...
}

Это конечно не тоже самое, что updateMemoComponent, но есть подозрение, что вряд ли уже внутри updateSimpleComponent будет вызываться функция resolveDefaultProps. Поэтому первой догадкой отличий MemoComponent и SimpleMemoComponent может быть, то что если есть defaultProps, значит это не SimpleMemoComponent.

Но давайте меньше догадок и посмотрим метод updateMemoComponent. Сам метод находится в том же файле.

function updateMemoComponent(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: any,
  nextProps: any,
  updateLanes: Lanes,
  renderLanes: Lanes,
): null | Fiber {
  if (current === null) {
    const type = Component.type;
    if (
      isSimpleFunctionComponent(type) &&
      Component.compare === null &&
      // SimpleMemoComponent codepath doesn't resolve outer props either.
      Component.defaultProps === undefined
    ) {
      // ...
    }
  }
  
  // ...
}

В первой строке мы видим проверку на равенство current и null, а current равен null в случае первого рендера. Тогда выполняется следующий более интересный if, начнем рассматривать его с конца. Здесь видим проверку на defaultProps равному undefined, и даже есть комментарий, что simpleMemoComponent не резолвит defaultProps, так что наша предыдущая догадка была абсолютно правильной. Далее идет проверка функции compare на null, это значит, что мы не передали свою функцию shouldComponentUpdate в memo. И собственно функция проверяющая simple компонент, который мы обернули в memo или не simple.

Давайте посмотрим, что из себя представляет isSimpleFunctionComponent. Эта функция находится в пакете react-reconciler в файле ReactFiber.new.js

export function isSimpleFunctionComponent(type: any) {
  return (
    typeof type === 'function' &&
    !shouldConstruct(type) &&
    type.defaultProps === undefined
  );
}

Здесь идет проверка на то что, компонент у нас представлен в виде функции, а не класса и у него нет defaultProps, тогда этот компонент можно назвать простым.

Метод shouldConstruct определен немного выше. И все что он проверяет- это наличие флага isReactComponent

function shouldConstruct(Component: Function) {
  const prototype = Component.prototype;
  return !!(prototype && prototype.isReactComponent);
}

// ...

export function resolveLazyComponentTag(Component: Function): WorkTag {
  if (typeof Component === 'function') {
    return shouldConstruct(Component) // <- LOOK HERE
      ? ClassComponent
      : FunctionComponent; 
  } else if (Component !== undefined && Component !== null) {
    // ...
  }
}

Cудя по вызову shouldConstruct немного ниже мы можем сделать вывод, если флаг присутствует тогда это компонент написан в виде класса, в ином случае компонент написан в виде функции.

Компонент впервые становится Simple

Мы начали рассматривать метод updateMemoComponent, и на первом рендере, если ранее обсужденный if вернет true. Тогда, все что происходит - это перезаписывание tag в SimpleMemoComponent и вызов метода updateSimpleMemoComponent:

function updateMemoComponent(/* ... */): null | Fiber {
  if (current === null) {
    const type = Component.type;
    if (
      isSimpleFunctionComponent(type) &&
      Component.compare === null &&
      // SimpleMemoComponent codepath doesn't resolve outer props either.
      Component.defaultProps === undefined
    ) {
      let resolvedType = type;
      workInProgress.tag = SimpleMemoComponent; // <- update tag to SimpleMemo
      workInProgress.type = resolvedType;
 
      return updateSimpleMemoComponent(
        current,
        workInProgress,
        resolvedType,
        nextProps,
        updateLanes,
        renderLanes,
      );
    }
  }
  
  // ...
}

Именно в этом месте при первом рендере и решается, станет компонент Simple или не станет

Промежуточный итог:

Таким образом, мы можем сделать выводы по внутренней градации компонентов в React. Memo компонент называется простым, когда:

  • компонент, который обернут в memo написан в виде функции

  • компонент, который обернут в memo не имеет defaultProps

  • компонент, который получен после оборачивания memo, так же не имеет defaultProps

  • в memo вторым параметром не передали compare функцию

Ну допустим я использую SimpleMemo, а выгода то какая?

Для того чтобы понять выгоду между MemoComponent и SimpleMemoComponent. Давайте для начала доизучаем исходники методов updateMemoComponent и updateSimpleMemoComponent.

updateMemoComponent

Начнем с updateMemoComponent. Я его упрощу, чтобы было легче воспринять код:

function updateMemoComponent(/* ... */): null | Fiber {
  // ...
  
  let compare = Component.compare;
  compare = compare !== null ? compare : shallowEqual;
  if (compare(prevProps, nextProps) && current.ref === workInProgress.ref) {
    return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
  }

  const newChild = createWorkInProgress(currentChild, nextProps);
  newChild.ref = workInProgress.ref;
  newChild.return = workInProgress;
  workInProgress.child = newChild;
  return newChild;
}

Здесь мы достаем поле compare из ноды и если это поле не передано, тогда сохраняем функцию shalowEqual в compare. И остается лишь передать в compare prevProps и nextProps, а так же удостовериться, что сама DOM нода по какой-то причине не изменилась. В этом случае вызывается функция bailoutOnAlreadyFinishedWork, которая под капотом чаще всего просто клонирует бывшую ноду. В случае же если if не сработал, создается newChild, если описывать словами из нашей реальности, просто заново рендериться компонент.

updateSimpleMemoComponent

Рассмотрим упрощенную версию метода updateSimpleMemoComponent:

function updateSimpleMemoComponent(/* ... */): null | Fiber {
  // ...
  
  if (shallowEqual(prevProps, nextProps) && current.ref === workInProgress.ref) {
    return bailoutOnAlreadyFinishedWork(
      current,
      workInProgress,
      renderLanes,
    );
  }

  return updateFunctionComponent(
    current,
    workInProgress,
    Component,
    nextProps,
    renderLanes,
  );
}

Здесь мы сразу же вызываем shallowEqual, т.к. наличие функции compare не возможно в SimpleMemoComponent и если все хорошо, так же вызовется bailoutOnAlreadyFinishedWork. Но если что-то идет не так, тогда вызывается функция updateFunctionComponent.

Сравним результаты

Здесь есть, что сравнить, в случае вызова updateMemoComponent метод createWorkInProgress создает какую-то абстрактную ноду, которая может быть классом или функцией, или вдруг еще чем то. С другой стороны в updateSimpleMemoComponent метод updateFunctionComponent говорит, я знаю, что мне надо работать непосредственно с функцией и мне надо ее обновить. Т.е. даже из названий можно предположить что updateFunctionComponent скорей всего вызывается под капотом функции createWorkInProgress. И чтобы это проверить я так же провел небольшое исследование.

Рассмотрим 2 сценария обновления компонента. Первый с SimpleMemoComponent. При обновлении компонента, идет вызов beginWork у которого tag равен SimpleMemoComponent и действительно вызывается сначала updateSimpleMemoComponent и сразу же вызывается updateFunctionComponent. Благодаря тому, что React знает, что компонент является Simple, значит компонент, который мы обернули с помощью memo является функцией и React может сразу же запустить его обновление.

С другой стороны, в случае MemoComponent происходит вызов beginWork с тегом MemoComponent, который заканчивается где-то глубоко в return null. И ведь действительно React не знает, что дальше запускать updateClassComponent или updateFunctionComponent. И они решили не городить дополнительные условия, а еще раз запустить beginWork уже для компонента Child, с тегом FunctionComponent который вызовет updateFunctionComponent. Получается путь немного длиннее.

Я даже перепроверил свою теорию, и добавил console.log в beginWork. И действительно, он вызывается на 1 раз меньше в случае SimpleMemoComponent.

Концовка

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

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

И я еще раз напоминаю!

В этой статье, мы снова считаем спички, это скорей всего абсолютно никак не отразится на перфомансе вашего проекта!  Но лично для себя я решил не использовать defaultProps и классовые компоненты. Кто знает, возможно благодаря именно этому на диком западе я буду на долю миллисекунды быстрее чем мои оппоненты))