Статья про мемоизацию оказалась объёмной и включает в себя разбор hoc memo, хуки useMemo и useCallback, затрагивает тему useRef. Было принято решение разбить статью на 2 части, в первой части разберем когда нужно и когда ненужно использовать memo, какое у него api, какие проблемы решает. Во второй части разберем хуки useMemo, useCallback, а также некоторые проблемы этих хуков, которые можно решить с помощью useRef.

В прошлых статьях мы разбирали как работать с useState и с useEffect. Знаем: код компонента будет выполняться каждый раз при его обновлении. Отсюда возникает проблема - данные и сложные вычисления будут теряться, также будет происходить лишнее обновление дочерних компонентов. Эти проблемы решает хук useMemo и обертка над ним useCallback, но оба работают в связке с memo hoc.

Как работать с memo

memo - это high order component или компонент высшего порядка.

Компонент высшего порядка - это функция, которая принимает компонент и возвращает его улучшенную версию.

В данном случае, memo - это функция, которая принимает react компонент, а возвращает react компонент, который будет обновляться только если его предыдущие пропсы не равны новым пропсам.

В примере ниже компонент MemoChild будет смонтирован/размонтирован в момент монтирования/размонтирования родителя, но не будет обновляться в момент обновления родителя.

import React, { useState, FC, memo } from "react";

export const MemoChild = memo(() => {
  return (
    <div>
    	Я никогда не буду обновляться
    </div>
	);
});

export const Child: FC = () => {
  return (
    <div>
    	Я буду обновляться всегда, когда обновляется родитель
		</div>
	);
};

export const Parent: FC = () => {
  const [state, setState] = useState<boolean>(true);
  return (
    <div>
    	<Child />
			<MemoChild />
      <button onClick={() => setState(v => !v)}>click</button>
		</div>
	);
};

MemoChild не принимает никаких пропсов, поэтому не будет обновляться при обновлении родителя. memo обновит компонент только когда предыдущие пропсы не равны текущим.

На языке typescript memo выглядит так:

function memo<P extends object>(
        Component: SFC<P>,
        propsAreEqual?: (prevProps: Readonly<PropsWithChildren<P>>, nextProps: Readonly<PropsWithChildren<P>>) => boolean
    ): NamedExoticComponent<P>;

Обратите внимание, memo принимает 2 аргумента: компонент и функцию propsAreEqual (пропсы равны?). Также является дженериком и принимает тип пропсов компонента
P extends object.

Зачем нужна propsAreEqual? Взглядите на код ниже и скажите, будет ли обновляться MemoChild при обновлении родителя?

import React, { useState, FC, memo } from "react";

type MemoChildProps = {
	test: { some: string };
}

export const MemoChild = memo<MemoChildProps>(() => {
  return (
    <div>
    	По идее я никогда не буду обновляться
    </div>
	);
});

export const Parent: FC = () => {
  const [state, setState] = useState<boolean>(true);
  return (
    <div>
			<MemoChild test={{ some: 'Я некий ссылочный тип данных' }} />
      <button onClick={() => setState(v => !v)}>click</button>
		</div>
	);
};

Компонент MemoChild будет обновляться при каждом обновлении родителя. memo под капотом проверяет пропсы с помощью строгого равно, в нашем случае:
prevProps.test === nextProps.test. Доверить memo сравнивать примитивы (строки, числа, булево и т.д.) можно, но ссылочные типы, такие как объект, массив, функция будут проверяться некорректно.

  • 'some string' === 'some string' -> true;

  • {} === {} -> false;

  • [] === [] -> false;

  • () => {} === () => {} -> false;

Один из способов решения проблемы - использовать второй аргумент memo, а именно propsAreEqual. Другой способ - использовать useMemo и useCallback, но об этом позже.

import React, { useState, FC, memo } from "react";

type MemoChildProps = {
	test: { some: string };
}

export const MemoChild = memo<MemoChildProps>(() => {
  return (
    <div>
    	Теперь я точно никогда не буду обновляться
    </div>
	);
},
// основано на предыдущем примере
(prevProps, nextProps) => prevProps.test.some === nextProps.test.some
);

export const Parent: FC = () => {
  const [state, setState] = useState<boolean>(true);
  return (
    <div>
			<MemoChild test={{ some: 'Я некий ссылочный тип данных' }} />
      <button onClick={() => setState(v => !v)}>click</button>
		</div>
	);
};

В примере выше используем прямое сравнение известных свойств (свойство some у объекта). Однако часто мы не знает точной структуры объектов, поэтому лучше использовать универсальные решения. Я использую библиотеку fast-deep-equal, можно использовать любую другую или самописную.

export const MemoChild = memo<MemoChildProps>(() => {
  return (
    <div>
    	Теперь я точно никогда не буду обновляться
    </div>
	);
},
(prevProps, nextProps) => deepEqual(prevProps, nextProps)
);

// или

export const MemoChild = memo<MemoChildProps>(() => {
  return (
    <div>
    	Теперь я точно никогда не буду обновляться
    </div>
	);
},
deepEqual
);

Однако здесь есть одна проблема, как думаете какая? Но прежде чем рассказать о ней, нужно еще немного поговорить о memo.

memo vs shouldComponentUpdate

memo часто сравнивают с shouldComponentUpdate, оба предотвращают лишнее обновление компонентов, но чтобы предотвратить обновление один возвращает true, другой false, как запомнить?

Поможет переводчик:

  • shouldComponentUpdate - "должен ли компонент обновиться?", если скажем да (вернем true) - обновится.

  • propsAreEqual - "пропсы равны?", если скажем да (вернем true) - не обновится, пропсы ведь равны.  Правда propsAreEqual это утверждение, а не вопрос и я бы назвал: arePropsEqual, но суть не меняется.

Как может выглядеть memo под капотом (логика)

Мы познакомились с основным api memo. Ниже приведен возможный код memo, это поможет лучше понять как с ним правильно работать.

function memo = (Component, propsAreEqual = shallowEqual) => {
	let prevComponent;
  let prevProps;
  
  return (nextProps) => {
    // если пропсы равны, возвращаем предыдущий вариант компонента
  	if (propsAreEqual(prevProps, nextProps)) {
    	prevProps = nextProps;
      return prevComponent;
    }
    prevComponent = <Component {...nextProps} />;
    prevProps = nextProps;
    return prevComponent;
  }
}

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

Опасность propsAreEqual

Вспомните предыдущий пример, в котором в качестве propsAreEqual использовали deepEqual. Если мемоизированный компонент принимает children, может быть переполнен стек вызовов, потому что children - зачастую объект с глубоким уровнем вложенности, представляет собой все дерево дочерних компонентов react.

import React, { useState, FC, memo } from "react";
import deepEqual from "fast-deep-equal";

export const MemoChild = memo(() => {
	return (
		<div>
			Я принимаю children и могу из-за этого переполнить стек вызовов
		</div>
  );
}, deepEqual);

export const Parent: FC = () => {
	const [state, setState] = useState<boolean>(true);
	return (
		<div>
			<MemoChild>
				<OtherComponent />
			</MemoChild>
			<button onClick={() => setState(v => !v)}>click</button>
		</div>
	);
};

Можно подкорректировать решение:

import React, { useState, FC, memo } from "react";
import deepEqual from "fast-deep-equal";

export const MemoChild = memo(() => {
	return (
		<div>
			Я принимаю children и могу из-за этого переполнить стек вызовов
		</div>
  );
},
({ children: prevChildren, ...prevProps }, { children: nextChildren, ...nextProps}) => {
  if (prevChildren !== nextChildren) return false;
  return deepEqual(prevProps, nextProps);
}
);

export const Parent: FC = () => {
	const [state, setState] = useState<boolean>(true);
	return (
		<div>
			<MemoChild>
				<OtherComponent />
			</MemoChild>
			<button onClick={() => setState(v => !v)}>click</button>
		</div>
	);
};

Раз не можем проверить children глубоко, проверим поверхностно. Но у этого решения есть еще проблема, помимо громоздкого кода. Любой react компонент превращается в объект и при каждом обновлении родителя, его дети - это новые объекты, то есть <OtherComponent /> === <OtherComponent /> -> false.

И мы плавно подошли к вопросу когда memo не имеет смысла, а значит почему это поведение не будет поведением по умолчанию.

Когда memo не имеет смыла

Если компонент принимает children, вероятно не имеет смысла его мемоизировать. Я говорю "вероятно", потому что есть один способ сохранить мемоизацию - можно дочерние компоненты мемоизировать с помощью useMemo. Это не самое чистое и довольно хрупкое решение, тем не менее мы его разберем в следующей лекции.

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

import React, { useState, FC, memo } from "react";
import deepEqual from "fast-deep-equal";

export const MemoChild = memo(() => {
	return (
		<div>
			Я буду обновляться всегда и отнимать ресурсы компьютера
		</div>
  );
});

export const Child: FC = () => {
	return (
		<div>
			Я буду обновляться всегда, это лучше чем не рабочая мемоизация 
		</div>
  );
};

export const Parent: FC = () => {
	const [state, setState] = useState<boolean>(true);
	return (
		<div>
			<MemoChild>
				<OtherComponent />
			</MemoChild>
			<Child>
				<OtherComponent />
			</Child>
			<button onClick={() => setState(v => !v)}>click</button>
		</div>
	);
};

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

Заключение

В этой статье разобрали, как работать с memo, когда нужно и когда не нужно использовать.

В следующей статье разберемся с reference и всеми инструментами для работы с ними: useRef, createRef, forwardRef, useImperativeHandle. Использование рефов - необходимое условие для эффективной работы с useMemo и useCallback.

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

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


  1. Alexandroppolus
    07.06.2022 18:20
    -2

    del


  1. alfaslash
    08.06.2022 09:51

    Один из способов решения проблемы - использовать второй аргумент memo, а именно propsAreEqual. Другой способ - использовать useMemo и useCallback, но об этом позже.

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

    Когда memo не имеет смыла

    Здесь не рассмотрен более важный вопрос о необходимости оборачивания пропсов виде функций в useCallback.