React hooks, как не выстрелить себе в ноги. Часть 3.1: мемоизация, memo

от автора

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


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *