Утечка памяти в React: Как useCallback и замыкания могут подставить вас

Утечка памяти в React: Как useCallback и замыкания могут подставить вас

Утечка памяти в React: Как useCallback и замыкания могут подставить вас
Утечка памяти в React: Как useCallback и замыкания могут подставить вас
3/9/2024
В статье рассматривается утечка памяти, которую сложно сразу заметить и определить, как она происходит. Связана она с замыканиями JavaScript и хука useCallback в React. Разберемся, что происходит, но в начале вспомним, что представляют собой замыкания (closures).

Краткая справка о замыканиях

Замыкания — это одна из основных, фундаментальных концепций в JavaScript. Они позволяют функциям запоминать переменные, которые были в области видимости при создании функции. Вот простой пример:
function createCounter() {
const unused = 0; // Эта переменная не используется во внутренней функции
let count = 0; // Эта переменная используется во внутренней функции
return function () {
count++;
console.log(count);
};
}

const counter = createCounter();
counter(); // 1
counter(); // 2
В этом примере функция createCounter возвращает новую функцию, которая имеет доступ к переменной count. Это становится возможным потому, что переменная count находится в области видимости функции createCounter, когда создается внутренняя функция.
Замыкания в JavaScript реализуются с помощью контекстного объекта, который хранит ссылки на переменные, находящиеся в области видимости при первоначальном создании функции. Какие переменные сохраняются в объекте контекста — это деталь реализации движка JavaScript, которая подвержена различным оптимизациям. Например, в V8, движке JavaScript, используемом в Chrome, неиспользуемые переменные могут не сохраняться в объекте контекста.
Поскольку замыкания могут быть вложены в друг друга, самые глубокие внутренние замыкания будут содержать ссылки (через так называемую цепочку областей видимости окружения) на любое внешнее окружение функции, к которому им нужно получить доступ. Например:
function first() {
const firstVar = 1;
function second() {
// Это замыкание видит из внешнего окружения переменную firstVar
const secondVar = 2;
function third() {
// Это замыкание видит из внешнего окружения переменные firstVar и secondVar
console.log(firstVar, secondVar);
}
return third;
}
return second();
}

const fn = first(); // Выполнение функции first выполнит second и вернет функцию third
fn(); // logs 1, 2
В этом примере функция third() имеет доступ к переменной firstVar через цепочку областей видимости.
Таким образом, пока в приложении хранится ссылка на функцию, ни одна из переменных в области видимости замыкания не может быть очищена сборщиком мусора. И благодаря цепочке областей видимости, даже переменные из внешних областей видимости функций останутся в памяти.

Замыкания и React

Функционал React состоит из замыканий. Мы это наблюдаем в функциональных компонентах, хуках и обработчиках событий. Всякий раз, когда создается новую функцию, которая обращается к переменной из области видимости компонента, например, к состоянию или пропсам, создается замыкание.
import { useState, useEffect } from "react";

function App({ id }) {
const [count, setCount] = useState(0);

const handleClick = () => {
setCount(count + 1); // Это замыкание над переменной count
};

useEffect(() => {
console.log(id); // это замыкание над переменной id, переданной в пропсах(prop)
}, [id]);

return (
<div>
<p>{count}</p>
<button onClick={handleClick}>Increment</button>
</div>
);
}
В большинстве случаев это не является проблемой. В приведенном выше примере замыкания будут создаваться заново при каждом рендере App, а старые будут уничтожаться сборщиком мусора. Это приведет к некоторым дополнительным операциям по выделению памяти и ее очищению, но сами по себе эти операции обычно очень быстрые.
Однако, когда наше приложение разрастается и вы начинаете использовать такие техники мемоизации, как useMemo и useCallback, чтобы избежать ненужных повторных рендеров, появляются моменты, на которые следует обратить внимание.

Замыкания и useCallback

С помощью хуков мемоизации мы за дополнительное использование памяти улучшаем скорость рендеринга. useCallback будет хранить ссылку на функцию до тех пор, пока зависимости не изменятся. Давайте рассмотрим пример:
import React, { useState, useCallback } from "react";

function App() {
const [count, setCount] = useState(0);

const handleEvent = useCallback(() => {
setCount(count + 1);
}, [count]);

return (
<div>
<p>{count}</p>
<ExpensiveChildComponent onMyEvent={handleEvent} />
</div>
);
}
В данном примере мы хотим избежать повторного рендеринга ExpensiveChildComponent. Допустим, рендеринг этого компонента очень затратный по ресурсам. Для этого нам надо сделать передаваемую ссылку на функцию handleEvent() постоянной, неизменяемой. Мы мемоизируем handleEvent() с помощью useCallback,теперь функция будет инициализирована заново только, когда изменится переменная count. Затем мы можем обернуть ExpensiveChildComponent в React.memo(), чтобы избежать его повторных рендеров при каждом рендере родительского компонента, App. Пока все хорошо.
Давайте добавим к этому примеру небольшую изюминку:
import { useState, useCallback } from "react";

class BigObject {
public readonly data = new Uint8Array(1024 * 1024 * 10); // создаем архив размером 10MB
}

function App() {
const [count, setCount] = useState(0);
const bigData = new BigObject();

const handleEvent = useCallback(() => {
setCount(count + 1);
}, [count]);

const handleClick = () => {
console.log(bigData.data.length);
};

return (
<div>
<button onClick={handleClick} />
<ExpensiveChildComponent2 onMyEvent={handleEvent} />
</div>
);
}
Поскольку функция handleEvent() создает замыкание над переменной count, она будет хранить ссылку на контекстный объект компонента. И хотя мы никогда не обращаемся к bigData в функции handleEvent(), handleEvent() все равно будет хранить ссылку на bigData через контекстный объект компонента.
Все замыкания имеют общий объект контекста с момента их создания. Поскольку замыкание handleClick() находится над bigData, bigData будет ссылаться на этот контекстный объект (общий для handleClick и handleEvent). Это означает, что bigData никогда не будет собрана сборщиком мусора, пока на нее ссылается handleEvent(). А handleEvent заморожена в useCallback. Эта ссылка/связь будет действовать до тех пор, пока count не изменится и функция handleEvent() не будет создана заново.

Бесконечная утечка памяти с useCallback + замыкания + большие объекты

Давайте доведем последний пример до крайности. И хотя пример может показаться надуманным, он достаточно хорошо демонстрирует общую суть проблемы.
import { useState, useCallback } from "react";

class BigObject {
public readonly data = new Uint8Array(1024 * 1024 * 10);
}

export const App = () => {
const [countA, setCountA] = useState(0);
const [countB, setCountB] = useState(0);
const bigData = new BigObject(); //
создаем архив размером 10MB

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>
);
};
В этом примере у нас есть два мемоизированных обработчика событий handleClickA() и handleClickB(). У нас также есть функция handleClickBoth(), которая вызывает оба обработчика событий и выводит в консоль длину bigData.
Можете ли вы догадаться, что произойдет, если мы попеременно будем нажимать кнопки «Increment A» и «Increment B»?
Давайте посмотрим на профиль памяти в Chrome DevTools после нажатия каждой из этих кнопок по 5 раз:
Похоже, что bigData никогда не очищается сборщиком мусора. Использование памяти растет и растет с каждым кликом. В нашем случае приложение хранит ссылки на 11 экземпляров BigObject, каждый размером по 10 МБ. Один для начального рендеринга и по одному для каждого клика.
Дерево удержаний дает нам представление о том, что происходит. Создается повторяющаяся цепочка ссылок. Давайте пройдемся по ней шаг за шагом.

0. First render:

При первом рендеринге App создается область (scope) замыкания, в которой хранятся ссылки на все переменные, поскольку все они используются как минимум в одном замыкании. Сюда входят bigData, handleClickA() и handleClickB(). Мы ссылаемся на них в handleClickBoth(). Назовем область видимости замыкания AppScope#0.

1. Нажмем на «Increment A»:

— Первое нажатие на «Increment A» приведет к пересозданию handleClickA(), поскольку мы изменили countA - назовем новое создание функции handleClickA()#1.
— handleClickB()#0 не будет пересоздан, поскольку countB не изменился.
— это означает, что handleClickB()#0 по-прежнему будет содержать ссылку на предыдущий AppScope#0.
— Новый handleClickA()#1 будет содержать ссылку на AppScope#1, который содержит ссылку на handleClickB()#0.

2. Нажмем на «Increment B»:

— Первое нажатие на «Increment B» приведет к пересозданию handleClickB(), поскольку мы изменили countB, создав handleClickB()#1.
— React не будет пересоздавать handleClickA(), поскольку countA не изменился.
— Таким образом, handleClickB()#1 будет содержать ссылку на область видимости AppScope#2, которое содержит ссылку на функцию handleClickA()#1, которая содержит ссылку на AppScope#1, которое содержит ссылку на handleClickB()#0.

1. Второй вызов «Increment A»:

Как видите, мы можем создать бесконечную цепочку замыканий, которые ссылаются друг на друга и никогда не будут очищены сборщиком мусора, при этом они таскают за собой отдельный объект bigData размером 10 МБ, поскольку он создается заново при каждом рендере.

Общая проблема в двух словах

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

Вот несколько факторов, которые повышают вероятность того, что вы столкнетесь с этой проблемой:

– У вас есть большие компоненты, которые практически никогда не пересоздаются, например, главная оболочка приложения, в которую вы передаете большое количество состояний (state).
– Вы расчитываете, что useCallback поможет вам минимизировать повторные рендеры.
– Вы вызываете другие функции из своих мемоизированных функций.
– Вы работаете с большими объектами, такими как image data или с большими массивами.
Если вам не нужно работать с большими объектами, ссылка на пару дополнительных строк или чисел может не представлять проблемы. Большинство таких перекрестных ссылок исчезнет после изменения свойств. Просто имейте в виду, что ваше приложение может занимать больше памяти, чем вы ожидаете.

Как избежать утечек памяти, используя замыкания и useCallback?

Совет 1: Делайте область замыкания, как можно меньше.
В JavaScript очень трудно заметить все захватываемые переменные. Лучший способ избежать захвата слишком большого количества переменных - уменьшить размер функции вокруг замыкания.
Писать компоненты меньшего размера. Это уменьшит количество переменных, которые оказываются в области видимости при создании нового замыкания.
Писать пользовательские хуки. Потому что тогда любой каллбэк может закрыться только в области действия функции-хука.
Совет 2: Избегайте захвата других замыканий, особенно мемоизированных.
Несмотря на то, что это кажется очевидным, в React легко попасть в эту ловушку. Если вы пишете небольшие функции, которые вызывают друг друга, то как только вы добавите первый useCallback, начнется цепная реакция, в результате которой все вызываемые функции в области видимости компонента будут мемоизированы.
Совет 3: Избегайте мемоизации, когда в ней нет необходимости.
useCallback и useMemo - отличные инструменты, позволяющие избежать ненужных повторных рендеров, но за них приходится платить. Используйте их только в том случае, если вы заметили проблемы с производительностью из-за рендеринга.
Совет 4: Используйте useRef для больших объектов.
Это может означать, что вам придется самостоятельно обрабатывать жизненный цикл объекта и очищать его своевременно. Не самый лучший вариант, но это лучше, чем утечка памяти.

Заключение

Замыкания — часто используемый паттерн в React. Они позволяют нашим функциям запоминать пропсы и состояния, которые были в области видимости при последнем рендеринге компонента. Но это может привести к неожиданным утечкам памяти в сочетании с такими техниками мемоизации, как useCallback, особенно при работе с большими объектами. Чтобы избежать таких утечек памяти, держите области действия замыканий, как можно меньше, избегайте мемоизации, когда в ней нет необходимости, и, по возможности, используйте useRef для больших объектов.
Данная статья — перевод «Sneaky React Memory Leaks: How useCallback and closures can bite you». Автору большое спасибо.

PS

Когда замыкания создаются во время рендеринга компонента, они будут захватываться EnvironmentRecord своей родительской функции. Таким образом, каждый handle... будет захватываться EnvironmentRecord одного и того же вызова App(). Все замыкания используют одну и ту же запись EnvironmentRecord.
В V8 Chrome все переменные, используемые в любом замыкании, будут храниться в этой родительской записи EnvironmentRecord. Поскольку handleClickA и handleClickB используются в handleBoth, ссылки на обе функции также будут сохранены. Все переменные, которые не используются ни в одном замыкании, будут очищены после завершения вызова функции App().
Таким образом, если вы удалите использование handleClickA и handleClickB из замыкания handleBoth, они больше не будут сохраняться. Таким образом, ошибка, описанная в статье, будет устранена.
Комментарии: