Статья про мемоизацию оказалась объёмной и включает в себя разбор 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, и познакомит вас с ними поближе.