Как стать автором
Поиск
Написать публикацию
Обновить

Коварные утечки памяти в React: как можно обжечься на useCallback и замыканиях

Время на прочтение9 мин
Количество просмотров9.8K
Всего голосов 14: ↑14 и ↓0+22
Комментарии24

Комментарии 24

Ээээ, так вы же нарушили одно из главных правил написания react компонентов - функциональный компонент должен быть pure function. А у вас там при каждом рендере создается обьект на 10 мегабайт хД. Думаю в этом корень проблемы, который даст о себе знать рано или поздно и при других обстоятельствах. Запаковать его в memo, передать в props, через useEffect что то там накалькулейтить. Но не в render функции. У вас тот же обьект будет создаватся 2 раза при strict render....

Это вы где про такое правило прочитали?) не совсем представляю, как в большом функциональном компоненте можно соблюсти этот принцип. Это вообще не возможно, так как этот принцип, если я не ошибаюсь, гласит об том, что pure function может использовать только те данные, которые она получает в качестве аргумента, не создавать сайд эффектов, не использовать из вне данные. Любой другой компонент подключенный в компонент, любой хук, который вы используете нарушает этот принцип, так как это что то, что извне попадает в вашу функцию)

https://react.dev/learn/keeping-components-pure - прочитал тут. props + state = input фукнции, кусок jsx = output. В вашем случае вы не ломаете чистоту функции хуками и другими компонентами, НО простая алокация и использование ее дальше по коду уже ломает, потому что это не state и не props. А для настоящих sideEffect есть useEffect, который прям так и называется, и который потом скидывает свой side-effect в state.

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

Стоп, а почему не

const handleEvent = useCallback(() => {
  // используем предыдущее состояние внутри setCount
  setCount(prevCount => prevCount + 1);
  // оставляем массив зависимостей пустым, что позволяет избежать
  // пересоздания ссылки на handleEvent
  }, []);

// profit?

? И всё, нет пересоздаваемых ссылок на функции, нет созависимых замыканий, сколько бы useCallback вы ни использовали

Немножко добавлю: здесь вообще не нужен useCallback, вы совершенно ничего не сэкономили таким образом, потому что не передаёте мемоизированную функцию вниз по дереву компонентов. Её мемоизация буквально ни на что не влияет. Да ещё и ссылки на setCountA и setCountB всегда останутся прежними, потому что реакт их в принципе не меняет между рендерами, только ссылки на сам стейт. Возможно мысль и здравая, но примеры ужасны

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

Я сначала подумал, что автор оригинала работает в рамблере :)

const bigData = useRef(new BigData())

const handleClickA = useCallback(() => setCountA(state => state + 1),[])
const handleClickB = useCallback(() => setCountB(state => state + 1),[])

Хорошо статья. Но проблема искусственная и решается просто мемоизацией const bigData = new BigObject();

Скорее скрывается, корень проблемы-то - в неограниченно растушей цепочке замыканий.

Какая растущая цепочка замыканий. Куча бреда в статье и в коментариях.

Создание 10мб объекта происходит при каждом рендере компонента. Именно его надо мемоизировать при помощи useMemo в первую очередь. Тогда и с useCallback не будет проблем. Мемоизационные хуки (внезапно) держат в памяти не только последний результат, а и несколько предыдущих (возможно и без ограничений вообще) и очищаются при удалении компонента из дерева. И это не про реакт, в так впринцыпе работает мемоизация.

Это

const bigData = new BigObject()

Должно выглядеть так

const bigData = useMemo(() => new BigObject(), []);

Или вынесено из компонента (например в стейт-менеджер).

Автор я понимаю что ты пришел из .Net, но тут проблема не в понимании реакта, а в понимании мемоизации. Посмотри как работает например код функции memoize из библиотеки lodash. Ты увидишь что происходит мемоизация (сохранения в памяти) нескольких результатов вызова с привязкой каждого к аргументам (зависимостям). Считай что это кеш, который конечно можно переполнить. И в твоём примере ты постоянно создаёшь новый экземпляр bigData и передаешь его как зависимость в useCallback в итоге твоя мемоизация вообще не работает и забивает память. В случае с мемоизированым bigData - если у нехо нет зависимости от пропсов (или чего-то ещё) то он будет создан один раз для экземпляра компонента. И да, того же можно добиться с useRef, но там дело не в больших объектах, а в мутабельности. При мутации объекта который хранится в useRef - реакт вообще не будет ничего рендерить. Есть задачи которые можно решить и тем и тем, но по принципу работы это разные вещи, и далеко не всегда взаимозаменяемые.

Какая растущая цепочка замыканий. Куча бреда в статье и в коментариях.

Вот эта:

И в твоём примере ты постоянно создаёшь новый экземпляр bigData и передаешь его как зависимость в useCallback

И где же он это делает-то?

И где же он это делает-то?

Вот эта:

Ещё раз. Где??? Если правильно написать код - лишние объекты не создаются. И никаких 100500 экземпляров биг даты в памяти. (делал ~20 снапшотов памяти, видел максимум 6 экземпляров биг даты). Ниже скрин в котором биг-дата создаётся не более 1 раза.

"цепочка замыканий" решается правильным использованием всех мемоизационных хуков (включая useMemo)
В тексте статьи то ли не хватает адекватного примера, то ли проблемы описаной в заголовке не существует. Лично я склоняюсь ко второму варианту.

А если уж надо при каких-то обстоятельствах пересоздавать биг-дату. Мемоизацию зависимостей биг-даты (всё тем жеuseMemo) и/или стейтменеджер никто не отменял.

P.S. Реакт делает компилятор который будет делать мемоизацию за разработчика.

In order to optimize applications, React Compiler automatically memoizes your code. You may be familiar today with memoization through APIs such as useMemo, useCallback, and React.memo. With these APIs you can tell React that certain parts of your application don’t need to recompute if their inputs haven’t changed, reducing work on updates. While powerful, it’s easy to forget to apply memoization or apply them incorrectly. This can lead to inefficient updates as React has to check parts of your UI that don’t have any meaningful changes.

Источник: https://react.dev/learn/react-compiler

На приведённом вами скриншоте я не вижу указания bigData в списке зависимостей useCallback.

Ниже скрин в котором биг-дата создаётся не более 1 раза.

Не вижу на этом скрине анализа кучи Javascript.

"цепочка замыканий" решается правильным использованием всех мемоизационных хуков (включая useMemo)

Не решается, в том-то и проблема.

Не, если в usememo обернуть, то он действительно не будет создаваться больше 1 раза, не важно засовывать ли его в другой usememo в качестве зависимости или нет.

Но цепочка замыканий-то расти не перестанет.

Не вижу на этом скрине анализа кучи Javascript.

Ну на Хабре нет возможности загрузить гифку на 30 мегабайт. Я изначально записал видео включая анализ кучи. (Потому и указал что максимум видел 6 объектов в памяти, что кстати не так уж и много.) Но как по мне тут и консоль лога достаточно что-бы понять что объект создан 1 раз. А раз был создан 1 экземпляр, то и в куче он будет внезапно 1.

В конце концов возьми и сам "проанализируй" (как по мне там и анализировать то нечего). И приведи пример кода который действительно докажет существование указанной проблемы. А пока-что статья даже близко не оправдывает заголовок.

На приведённом вами скриншоте я не вижу указания bigData в списке зависимостей useCallback.

Смотри код из статьи, там есть третий обработчик клика (без мемоизации, но он зависит от биг-даты), на мой скрин он не влез и по понятным причинам я делал акцент на своих вставках (статью читали?).

Полный код

import { useState, useCallback, useMemo } from "react";

class BigObject {
  constructor() {
    console.debug("new BigObject created");
  }
  public readonly data = new Uint8Array(1024 * 1024 * 10);
}

export const HabrExample = () => {
  const [countA, setCountA] = useState(0);
  const [countB, setCountB] = useState(0);
  

  // only 1 instance of BigObject will be created
  const bigData = useMemo(() => new BigObject(), []);
  
  // instance created on each render
  // 5-6 objects may be stored in memory
  // const bigData = new BigObject(); // 10 МБ данных

  const handleClickA = useCallback(() => {
    setCountA(countA + 1);
  }, [countA]);

  const handleClickB = useCallback(() => {
    setCountB(countB + 1);
  }, [countB]);

  // Этот код демонстрирует проблему
  const handleClickBoth = () => {
    handleClickA();
    handleClickB();
    console.log(bigData.data.length);
  };

  return (
    <div>
      <button onClick={handleClickA}>Increment A</button>
      <button onClick={handleClickB}>Increment B</button>
      <button onClick={handleClickBoth}>Increment Both</button>
      <p>
        A: {countA}, B: {countB}
      </p>
    </div>
  );
};

Но как по мне тут и консоль лога достаточно что-бы понять что объект создан 1 раз. А раз был создан 1 экземпляр, то и в куче он будет внезапно 1.

Да забудьте вы про объект bigData уже! Проблема вообще не в нём!

Смотри код из статьи, там есть третий обработчик клика (без мемоизации, но он зависит от биг-даты), на мой скрин он не влез и по понятным причинам я делал акцент на своих вставках (статью читали?).

Этот обработчик ни на что не влияет. Кстати, вы-то статью читали?

Ну да, обработчик с коментов автора статьи: " // Этот код демонстрирует проблему " - ни на что не влияет.... (Facepalm)

Лови "анализ кучи", запущен код из примера автора. Без дополнительных мемоизаций. Только дебаг лог в конструкторе биг-даты.

Накликано 35 обьектов биг-дата - 5 обьектов в куче
Накликано 35 обьектов биг-дата - 5 обьектов в куче
Накликано 110, в куче 3
Накликано 110, в куче 3

В памяти лежит максимум 5 объектов биг даты (даже 6 в этот раз поймать не смог) Под сборку мусора они попадают в итоге.

Уте́чка па́мяти (англ. memory leak) — процесс неконтролируемого уменьшения объёма свободной оперативной или виртуальной памяти компьютера, связанный с ошибками в работающих программах, вовремя не освобождающих память от ненужных данных, или с ошибками системных служб контроля памяти.

Где утечка памяти если, если память освобождется? Ну если с ростом числа созданных объектов был бы, пусть и не линейный, но рост количества объектов в куче - тогда да. Хоть 20 хоть 500 объектов накликайте - в куче будет максимум 5. Ну или 6 если получится "удачно" сделать снапшот памяти. Откуда они там берутся и почему не "удаляются" сразу? Кто захочет - разберется. И вот про это я бы статью прочитал. А так нет ни коварности, ни утечки памяти.

Что-то подсказывает мне, что кнопки "Increment A" и "Increment B" нажимались вами не по-очереди, иначе бы такой разницы между счётчиками не получилось.

Ну и опять вы не туда смотрите, цепочку из замыканий автор увидел в разделе Retainers, а у вас он даже на скриншот не влез.

А разве они не для этого предназначены?

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

Может это изначально порочный круг?

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

Зарегистрируйтесь на Хабре, чтобы оставить комментарий