Статья про мемоизацию оказалась объёмной и включает в себя разбор 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, и познакомит вас с ними поближе.
ссылка на оригинал статьи https://habr.com/ru/company/otus/blog/669962/
Добавить комментарий