paint-brush
Мемоизация в React: мощный инструмент или скрытая ловушка?к@socialdiscoverygroup
690 чтения
690 чтения

Мемоизация в React: мощный инструмент или скрытая ловушка?

к Social Discovery Group15m2024/07/01
Read on Terminal Reader

Слишком долго; Читать

Распространенный подход в разработке приложений React — все покрыть запоминанием. Команда Social Discovery Group обнаружила, как чрезмерное использование мемоизации в приложениях React может привести к проблемам с производительностью. Узнайте, где это дает сбой и как избежать этих скрытых ловушек в вашем развитии.
featured image - Мемоизация в React: мощный инструмент или скрытая ловушка?
Social Discovery Group HackerNoon profile picture
0-item


Распространенный подход в разработке приложений React — все покрыть мемоизацией. Многие разработчики широко применяют эту технику оптимизации, помещая компоненты в мемоизацию, чтобы предотвратить ненужный повторный рендеринг. На первый взгляд это кажется надежной стратегией повышения производительности. Однако команда Social Discovery Group обнаружила, что при неправильном использовании мемоизация может неожиданно сломаться, что приведет к проблемам с производительностью. В этой статье мы рассмотрим неожиданные места, где запоминание может дать сбой, и как избежать этих скрытых ловушек в ваших приложениях React.


Что такое мемоизация?

Мемоизация — это метод оптимизации в программировании, который предполагает сохранение результатов дорогостоящих операций и их повторное использование при повторном обнаружении тех же входных данных. Суть мемоизации заключается в том, чтобы избежать избыточных вычислений для одних и тех же входных данных. Это описание действительно верно для традиционной мемоизации. В примере кода вы можете видеть, что все вычисления кэшируются.


 const cache = { } function calculate (a) { if (Object.hasOwn(cache, a)) { return cache[a] } cache[a] = a * a return cache[a] }


Мемоизация предлагает несколько преимуществ, включая повышение производительности, экономию ресурсов и кэширование результатов. Однако мемоизация React работает до тех пор, пока не появятся новые реквизиты, а это означает, что сохраняется только результат последнего вызова.


 const prev = { value: null, result: null } function calculate(a) { if (prev.value === a) { return prev.result } prev.value = a prev.result = a * a return prev.result }


Инструменты мемоизации в React

Библиотека React предоставляет нам несколько инструментов для запоминания. Это HOC React.memo, хуки useCallback, useMemo и useEvent, а также React.PureComponent и метод жизненного цикла компонентов класса mustComponentUpdate. Давайте рассмотрим первые три инструмента запоминания и изучим их назначение и использование в React.


Реагировать.memo

Этот компонент высшего порядка (HOC) принимает компонент в качестве первого аргумента и необязательную функцию сравнения в качестве второго. Функция сравнения позволяет вручную сравнивать предыдущие и текущие реквизиты. Если функция сравнения не указана, React по умолчанию использует поверхностное равенство. Очень важно понимать, что поверхностное равенство выполняет только поверхностное сравнение. Следовательно, если реквизиты содержат ссылочные типы с непостоянными ссылками, React инициирует повторный рендеринг компонента.


 const Button = memo((props) => { return ( <button onClick={props.onClick}> {props.title} </button> ) }, (prevProps, props) => { return props.title === prevProps.title })


React.useCallback

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


 const callback = useCallback(() => { // do something }, [a, b, c])


React.useMemo

Хук useMemo позволяет кэшировать результат вычислений между рендерами. Обычно useMemo используется для кэширования дорогостоящих вычислений, а также для хранения ссылки на объект при его передаче другим компонентам, завернутым в memo HOC, или в качестве зависимости в хуках.


 const value = useMemo(() => { return [1, 2, 3, 4, 5].filter(it => it % 2 === 0) }, [])


Полная мемоизация проекта

В командах разработчиков React широко распространенной практикой является комплексная мемоизация. Этот подход обычно включает в себя:

  • Обертывание всех компонентов в React.memo
  • Использование useCallback для всех функций, передаваемых другим компонентам.
  • Кэширование вычислений и ссылочных типов с помощью useMemo


Однако разработчики не всегда понимают тонкости этой стратегии и то, насколько легко можно поставить под угрозу запоминание. Компоненты, обернутые в memo HOC, нередко неожиданно перерисовываются. В Social Discovery Group мы слышали заявления коллег: «Запоминание всего не намного дороже, чем отсутствие запоминания вообще».


Мы заметили, что не все полностью понимают важный аспект мемоизации: она работает оптимально без дополнительных настроек только тогда, когда примитивы передаются в мемоизированный компонент.


  1. В таких случаях компонент будет повторно отображаться только в том случае, если значения свойств действительно изменились. Примитивы в реквизитах — это хорошо.

  2. Второй момент — когда мы передаем ссылочные типы в реквизите. Важно помнить и понимать, что в React нет никакой магии — это библиотека JavaScript, работающая по правилам JavaScript. Ссылочные типы в реквизитах (функциях, объектах, массивах) опасны.


    Например:


 const a = { c: 1 } const b = { c: 1 } a === b // false First call: MemoComponent(a) Second call: MemoComponent(b) const MemoComponent = memo(({object}) => { return <div /> }, (prevProps, props) => (prevProps.object === props.object)) // false


Если вы создаете объект, другой объект с теми же свойствами и значениями не будет равен первому, поскольку у них разные ссылки.


Если при последующем вызове компонента мы передаем то, что кажется тем же объектом, но на самом деле это другой объект (поскольку его ссылка другая), поверхностное сравнение, которое использует React, распознает эти объекты как разные. Это вызовет повторный рендеринг компонента, завернутого в memo, тем самым нарушив запоминание этого компонента.


Чтобы обеспечить безопасную работу с мемоизированными компонентами, важно использовать комбинацию memo, useCallback и useMemo. Таким образом, все ссылочные типы будут иметь постоянные ссылки.

Командная работа: memo, useCallback, useMemo

Давай разорвем записку, ладно?

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


Встраивание в мемоизацию

Начнем с классической ошибки, когда при каждом последующем рендеринге родительского компонента мемоизированный компонент MemoComponent будет постоянно перерисовываться, поскольку ссылка на объект, передаваемый в параметрах, всегда будет новой.


 const Parent = () => { return ( <MemoComponent params={[1, 2 ,3]} /> ) }


Чтобы решить эту проблему, достаточно использовать ранее упомянутый хук useMemo. Теперь ссылка на наш объект всегда будет постоянной.


 const Parent = () => { const params = useMemo(() => { return [1, 2 ,3] ), []) return ( <MemoComponent params={params} /> ) }


Альтернативно вы можете переместить это в константу вне компонента, если массив всегда содержит статические данные.


 const params = [1, 2 ,3] const Parent = () => { return ( <MemoComponent params={params} /> ) }


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


 const Parent = () => { return ( <MemoComponent onClick={() => {}} /> ) }


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


 const Parent = () => { const handleClick = useCallback(() => console.log('click') }, []) return ( <MemoComponent onClick={handleClick} /> ) }


Примечание принято.

Кроме того, в useCallback ничто не мешает вам передать функцию, возвращающую другую функцию. Однако важно помнить, что при таком подходе функция someFunction будет вызываться при каждом рендеринге. Крайне важно избегать сложных вычислений внутри someFunction.


 function someFunction() { // expensive calculations (?) ... return () => {} } .............................. const Parent = () => { const cachedFunction = useCallback(someFunction(), []) return ( <MemoComponent onClick={cachedFunction} /> ) }


Распространение реквизита

Следующая распространенная ситуация – разбрасывание реквизита. Представьте, что у вас есть цепочка компонентов. Как часто вы задумываетесь о том, как далеко может переместиться свойство данных, переданное из InitialComponent, потенциально ненужное для некоторых компонентов в этой цепочке? В этом примере это свойство нарушит мемоизацию в компоненте ChildMemo, поскольку при каждом рендеринге InitialComponent его значение всегда будет меняться. В реальном проекте, где цепочка мемоизированных компонентов может быть длинной, вся мемоизация будет нарушена, потому что им передаются ненужные реквизиты с постоянно меняющимися значениями:


 const Child = () => {} const ChildMemo = React.memo(Child) const Component = (props) => { return <ChildMemo {...props} /> } const InitialComponent = (props) => { // The only component that has state and can trigger a re-render return ( <Component {...props} data={Math.random()} /> ) }


Чтобы обезопасить себя, убедитесь, что запоминаемому компоненту передаются только необходимые значения. Вместо этого:


 const Component = (props) => { return <ChildMemo {...props} /> }


Используйте (передавайте только необходимые реквизиты):


 const Component = (props) => { return ( <ChildMemo firstProp={prop.firstProp} secondProp={props.secondProp} /> ) )


Памятка и дети

Давайте рассмотрим следующий пример. Знакомая ситуация — когда мы пишем компонент, который принимает JSX в качестве дочернего.


 const ChildMemo = React.memo(Child) const Component = () => { return ( <ChildMemo> <div>Text</div> </ChildMemo> ) }


На первый взгляд это кажется безобидным, но на самом деле это не так. Давайте подробнее рассмотрим код, в котором мы передаем JSX в качестве дочернего элемента запоминаемому компоненту. Этот синтаксис — не что иное, как синтаксический сахар для передачи этого div как свойства с именем Children.


Дети ничем не отличаются от любого другого реквизита, который мы передаем компоненту. В нашем случае мы передаем JSX, а JSX, в свою очередь, является синтаксическим сахаром для метода createElement, поэтому, по сути, мы передаем обычный объект типа div. И здесь действует обычное правило для мемоизированного компонента: если в реквизите передается немемоизированный объект, компонент будет перерисован при рендеринге его родителя, так как каждый раз ссылка на этот объект будет новой.



Решение такой проблемы обсуждалось несколькими тезисами ранее, в блоке, посвященном передаче немемоизированного объекта. Итак, здесь передаваемый контент может быть мемоизирован с помощью useMemo, и передача его в качестве дочернего компонента в ChildMemo не нарушит мемоизацию этого компонента.


 const Component = () => { const childrenContent = useMemo( () => <div>Text</div>, [], ) return ( <ChildMemo> {childrenContent} </ChildMemo> ) }


ParentMemo и ChildMemo

Давайте рассмотрим более интересный пример.


 const ParentMemo = React.memo(Parent) const ChildMemo = React.memo(Child) const App = () => { return ( <ParentMemo> <ChildMemo /> </ParentMemo> ) }


На первый взгляд это кажется безобидным: у нас есть два компонента, оба из которых запоминаются. Однако в этом примере ParentMemo будет вести себя так, как будто он не заключен в memo, поскольку его дочерний элемент ChildMemo не запоминается. Результатом работы компонента ChildMemo будет JSX, а JSX — это просто синтаксический сахар для React.createElement, который возвращает объект. Итак, после выполнения метода React.createElement ParentMemo и ChildMemo станут обычными объектами JavaScript, и эти объекты никак не запоминаются.

Родительская памятка

РебенокMemo

{`` type: {`` ...`` $$typeof: Symbol(react.memo),`` type: {`` name: "Parent"`` }`` },`` ...``}

{`` type: {`` ...`` $$typeof: Symbol(react.memo),`` type: {`` name: "Child"`` }`` },`` ...``}


В результате мы передаем в реквизит немемоизированный объект, тем самым нарушая мемоизацию Родительского компонента.


 const ParentMemo = React.memo(Parent) const ChildMemo = React.memo(Child) const App = () => { return ( <ParentMemo children={<ChildMemo />} /> ) }


Чтобы решить эту проблему, достаточно запомнить переданный дочерний элемент, гарантируя, что его ссылка остается постоянной во время рендеринга родительского компонента App.


 const App = () => { const child = useMemo(() => { return <ChildMemo /> }, []); return ( <ParentMemo> {child} </ParentMemo> ) }


Непримитивы из пользовательских хуков

Еще одна опасная и неявная область — пользовательские хуки. Пользовательские перехватчики помогают нам извлекать логику из наших компонентов, делая код более читабельным и скрывая сложную логику. Однако они также скрывают от нас, имеют ли их данные и функции постоянные ссылки. В моем примере реализация функции отправки скрыта в пользовательском хуке useForm, и при каждом рендеринге родительского компонента хуки будут выполняться повторно.


 const Parent = () => { const { submit } = useForm() return <ComponentMemo onChange={submit} /> };


Можем ли мы понять из кода, безопасно ли передавать метод submit в качестве свойства мемоизированному компоненту ComponentMemo? Конечно, нет. А в худшем случае реализация кастомного хука может выглядеть так:


 const Parent = () => { const { submit } = useForm() return <ComponentMemo onChange={submit} /> }; const useForm = () => { const submit = () => {} return { submit } }


Передавая метод submit в мемоизированный компонент, мы нарушим мемоизацию, поскольку ссылка на метод submit будет новой при каждом рендеринге родительского компонента. Чтобы решить эту проблему, вы можете использовать хук useCallback. Но главное, что я хотел подчеркнуть, это то, что вы не должны слепо использовать данные из пользовательских перехватчиков для передачи их в мемоизированные компоненты, если вы не хотите нарушить реализованную вами мемоизацию.


 const Parent = () => { const { submit } = useForm() return <ComponentMemo onChange={submit} /> }; const useForm = () => { const submit = useCallback(() => {}, []) return { submit } }


Когда мемоизация избыточна, даже если мемоизацией покрыто все?

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


 export function App() { const [state, setState] = useState('') const handleChange = (e) => { setState(e.target.value) } return ( <Form> <Input value={state} onChange={handleChange}/> </Form> ) } export const Input = memo((props) => (<input {...props} />))


В этом примере возникает соблазн обернуть метод handleChange в useCallback, поскольку передача handleChange в его текущей форме нарушит запоминание компонента ввода, поскольку ссылка на handleChange всегда будет новой. Однако при изменении состояния компонент ввода в любом случае будет перерисован, поскольку ему будет передано новое значение в свойстве value. Таким образом, тот факт, что мы не обернули handleChange в useCallback, не помешает компоненту ввода постоянно перерисовываться. В этом случае использование useCallback было бы излишним. Далее я хотел бы привести несколько примеров реального кода, который можно увидеть во время проверки кода.


 const activeQuestionNumber = useMemo(() => { return activeQuestionIndex + 1 }, [activeQuestionIndex]) const userAnswerImage = useMemo(() => { return `/i/call/quiz/${quizQuestionAnswer.userAnswer}.png` }, [quizQuestionAnswer.userAnswer])


Учитывая, насколько проста операция сложения двух чисел или объединения строк и что в этих примерах на выходе мы получаем примитивы, очевидно, что использование useMemo здесь не имеет смысла. Подобные примеры здесь.


 const cta = useMemo(() => { return activeOverlayName === 'photos' ? 'gallery' : 'profile' }, [activeOverlayName]) const attendeeId = useMemo(() => { return userId === senderId ? recipientId : senderId }, [userId, recipientId, senderId])


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


Выводы

  1. Оптимизация – не всегда выгодна. *Оптимизация производительности не бесплатна, и стоимость этой оптимизации не всегда может быть соизмерима с преимуществами, которые вы от нее получите.
    *
  2. Измерьте результаты оптимизации. *Если вы не проводите измерения, вы не сможете узнать, улучшила ли ваша оптимизация что-либо или нет. И самое главное, без замеров не узнаешь, не ухудшили ли ситуацию.
    *
  3. Измерьте эффективность запоминания. *Использовать полную мемоизацию или нет, можно понять только измерив ее эффективность в вашем конкретном случае. Мемоизация не бесплатна при кэшировании или запоминании вычислений, и это может повлиять на то, как быстро ваше приложение запускается в первый раз и как быстро пользователи смогут начать его использовать. Например, если вы хотите запомнить какое-то сложное вычисление, результат которого необходимо отправить на сервер при нажатии кнопки, следует ли запоминать его при запуске приложения? А может и нет, потому что есть вероятность, что пользователь никогда не нажмет эту кнопку, и выполнение этого сложного расчета может вообще оказаться ненужным.
    *
  4. Сначала подумай, а потом запоминай. *Мемоизация реквизитов, передаваемых компоненту, имеет смысл только в том случае, если он обернут в `memo`, или если полученные реквизиты используются в зависимостях хуков, а также если эти реквизиты передаются другим мемоизированным компонентам.
    *
  5. Помните основные принципы JavaScript. Работая с React или любой другой библиотекой и фреймворком, важно не забывать, что все это реализовано на JavaScript и работает по правилам этого языка.


Какие инструменты можно использовать для измерения?

Мы можем порекомендовать как минимум 4 таких инструмента для измерения производительности кода вашего приложения.


React.Профилер

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


Инструменты разработчика React

Инструменты разработчика React — это расширение для браузера, которое позволяет проверять иерархию компонентов, отслеживать изменения состояний и реквизитов, а также анализировать производительность приложения.


Трекер рендеринга React

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


Сборник рассказов с дополнением «Сборник рассказов-дополнение-производительность».

Также в Storybook можно установить интересный плагин под названием сборник рассказов-аддон-производительность из Атласиан. С помощью этого плагина вы можете запускать тесты для получения информации о скорости первоначального рендеринга, повторного рендеринга и рендеринга на стороне сервера. Эти тесты можно запускать как для нескольких копий, так и для нескольких запусков одновременно, что сводит к минимуму неточности тестирования.



** Автор: Сергей Левкович, старший инженер-программист Social Discovery Group.