Я работаю в Ramblr, это ИИ-стартап, где мы строим на React сложные приложения для аннотирования видео. Недавно мне попалась сложная утечка памяти, которая возникает при одновременном использовании замыканий JavaScript и хука
useCallback
в React. Поскольку я вырос на .NET, мне потребовалось немало времени, чтобы разобраться в происходящем. Поэтому я решил написать этот пост и рассказать вам, чему меня научила эта ситуация. Сначала я кратко напомню вам, как устроены замыкания, но можете смело пропустить этот раздел, если вы уже хорошо знаете, как устроен этот механизм в JavaScript.
Краткое напоминание о том, как работают замыкания
Замыкания — фундаментальная концепция 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(); // Этот код вернёт третью функцию
fn(); // логирует 1, 2
В данном примере у функции
third()
через цепочку областей видимости есть доступ к переменной firstVar
.Итак, до тех пор, пока в приложении содержится ссылка на функцию, ни одна из переменных в области видимости замыкания не подпадает под сборку мусора. Поскольку здесь мы имеем дело с цепочкой областей видимости, даже области видимости внешних функций будут оставаться в памяти.
Кстати, почитайте замечательную статью, в которой подробно разобрана эта тема: Grokking V8 closures for fun (and profit?). Пусть эта статья и была написана в 2012 году, она по-прежнему актуальна и даёт отличный обзор, позволяющий понять, как в V8 действуют замыкания.
Замыкания и 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
}, [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 of data
}
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
будет ссылаться этот объект контекста. Таким образом, bigData
не попадёт под сборку мусора до тех пор, пока стоит ссылка на handleEvent()
. Эта ссылка будет действовать до тех пор, пока не изменится 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(); // 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>
);
};
В данном примере мемоизированы два обработчика событий:
handleClickA()
и handleClickB()
. Также здесь есть функция handleClickBoth()
, которая и вызывает оба обработчика событий, и логирует длину bigData
.Догадываетесь, что произойдёт, если попеременно щёлкать кнопки “Increment A” и “Increment B”?
Давайте откроем «Инструменты разработчика» в браузере Chrome и посмотрим, что происходит в инспекторе памяти после того, как по пять раз нажать каждую из этих кнопок:
По-видимому,
bigData
вообще не попадает под сборку мусора. С каждым нажатием расход памяти только растёт. В нашем примере приложение держит ссылки на 11 экземпляров BigObject
, каждый размером по 10 МБ. Один экземпляр создаётся для первичного рендеринга, ещё по одному — с каждым щелчком.По дереву удерживаемых объектов можно понять, что происходит. По-видимому, мы создаём цепочку повторяющихся ссылок. Давайте пошагово разберём эту ситуацию.
0. Первый шаг рендеринга:
При первичном рендеринге
App
создаётся область видимости замыкания, в которой содержатся ссылки на все переменные, поскольку все они у нас используются не менее чем в одном замыкании. Это касается 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
, which holds a reference tohandleClickA()#1
, в которой удерживается ссылка наAppScope#1
, в которой удерживается ссылка наhandleClickB()#0
.
3. Второй щелчок по “Increment A”:
Таким образом, может получиться в бесконечную цепочку замыканий, которые ссылаются друг на друга и никогда не попадают под сборку мусора. Всё это время в системе подвисает отдельный объект
bigData
на 10 МБ, который воссоздаётся на каждом шаге рендеринга.Суть проблемы
Суть проблемы заключается в том, что различные
useCallback
, подключающиеся к одному компоненту, могут ссылаться друг на друга, а также на иные ресурсозатратные данные через области видимости замыканий. Замыкания содержатся в памяти до тех пор, пока не будут воссозданы хуки useCallback
. Если к компоненту подключится более одного useCallback
, то становится крайне сложно судить, что именно содержится в памяти, и когда эта память будет высвобождена. Чем больше у вас обратных вызовов, тем вероятнее, что вы столкнётесь с проблемой.Грозит ли такая проблема именно вам?
Вот несколько факторов, при наличии которых вы сильнее рискуете столкнуться с подобными проблемами:
- У вас есть крупные компоненты, которые едва ли когда-либо воссоздаются, например, оболочка приложения, в которой запрограммировано значительное число деталей состояния.
- Вы пользуетесь
useCallback
, чтобы минимизировать операции повторного рендеринга. - Из мемоизированных функций вы вызываете другие функции.
- Вам приходится обрабатывать крупные объекты — например, картинки или большие массивы.
Как избежать утечек памяти при работе с замыканиями и useCallback
?
Дам вам несколько советов, которые, вероятно, помогут избежать подобных проблем:
Совет 1: области видимости ваших замыканий должны быть как можно меньше
В JavaScript очень сложно отследить все захватываемые переменные. Лучший способ, помогающий не удерживать слишком много переменных — уменьшить саму функцию вокруг замыкания. Это значит:
- Писать более мелкие компоненты. Так вы сократите количество переменных, которые окажутся в области видимости в момент создания нового замыкания.
- Писать собственные хуки. Ведь в таком случае любой обратный вызов сможет замкнуться только на области видимости функции-хука. Зачастую это означает, что в него будут заключены только аргументы этой функции.
Пусть этот совет и может показаться очевидным, с React легко попасть в такую ловушку. Если вы писали мелкие функции, вызывающие друг друга, то, стоит вам добавить в этот код первый же
useCallback
, как начинается цепная реакция: в области видимости компонента, подлежащего мемоизации, каждая из функций начинает вызывать остальные.Совет 3: Избегайте мемоизации, когда она не является необходимой.
useCallback
и useMemo
отлично помогают избавиться от лишних этапов повторного рендеринга, но за это приходится платить. Пользуйтесь ими, только если из-за рендеринга наблюдаются явные проблемы с производительностью.Совет 4 (аварийный люк): при работе с большими объектами пользуйтесь
useRef
.Возможно, в таком случае вам потребуется самостоятельно обрабатывать жизненный цикл объекта и самим позаботиться о связанной с ним очистке. Не самый хороший вариант, но лучше, чем утечки памяти.
Заключение
Замыкания — это паттерн, широко используемый в React. С их помощью мы добиваемся, чтобы другие функции запоминали, каковы были пропсы и состояния в области видимости, когда данный компонент отображался в предыдущий раз. Но в сочетании с инструментами мемоизации, например, с
useCallback
, такая техника может приводить к неожиданным утечкам памяти, в особенности при работе с большими объектами. Чтобы избежать таких утечек, старайтесь, чтобы область видимости каждого замыкания оставалась как можно меньше, избегайте мемоизации, когда она не является необходимой. При работе с большими объектами попробуйте использовать useRef
в качестве резервного варианта.Большое спасибо Дэвиду Глассеру за его статью A surprising JavaScript memory leak found at Meteor, написанную в 2013 году. Она стала для меня путеводной.
Комментарии (12)
winkyBrain
26.06.2024 14:29+1Стоп, а почему не
const handleEvent = useCallback(() => { // используем предыдущее состояние внутри setCount setCount(prevCount => prevCount + 1); // оставляем массив зависимостей пустым, что позволяет избежать // пересоздания ссылки на handleEvent }, []); // profit?
? И всё, нет пересоздаваемых ссылок на функции, нет созависимых замыканий, сколько бы useCallback вы ни использовали
Немножко добавлю: здесь вообще не нужен useCallback, вы совершенно ничего не сэкономили таким образом, потому что не передаёте мемоизированную функцию вниз по дереву компонентов. Её мемоизация буквально ни на что не влияет. Да ещё и ссылки на setCountA и setCountB всегда останутся прежними, потому что реакт их в принципе не меняет между рендерами, только ссылки на сам стейт. Возможно мысль и здравая, но примеры ужасны
ViktorVovk
26.06.2024 14:29+1Похоже это очевидно) не думаю, что автор допустил ошибку, а скорее привел пример когда в компоненте есть мемоизированные функции с зависимостями. Проще всего, в качестве примера было написать такие функции, что автор и сделал. Вы слишком буквально отнеслись к примеру.
zkcc
26.06.2024 14:29const bigData = useRef(new BigData())
const handleClickA = useCallback(() => setCountA(state => state + 1),[])
const handleClickB = useCallback(() => setCountB(state => state + 1),[])
danielzagumennyi
26.06.2024 14:29Хорошо статья. Но проблема искусственная и решается просто мемоизацией
const bigData = new BigObject();
mayorovp
26.06.2024 14:29Скорее скрывается, корень проблемы-то - в неограниченно растушей цепочке замыканий.
neko911
26.06.2024 14:29Какая растущая цепочка замыканий. Куча бреда в статье и в коментариях.
Создание 10мб объекта происходит при каждом рендере компонента. Именно его надо мемоизировать при помощи
useMemo
в первую очередь. Тогда и сuseCallback
не будет проблем. Мемоизационные хуки (внезапно) держат в памяти не только последний результат, а и несколько предыдущих (возможно и без ограничений вообще) и очищаются при удалении компонента из дерева. И это не про реакт, в так впринцыпе работает мемоизация.Это
const bigData = new BigObject()
Должно выглядеть так
const bigData = useMemo(() => new BigObject(), []);
Или вынесено из компонента (например в стейт-менеджер).
Автор я понимаю что ты пришел из .Net, но тут проблема не в понимании реакта, а в понимании мемоизации. Посмотри как работает например код функции
memoize
из библиотекиlodash
. Ты увидишь что происходит мемоизация (сохранения в памяти) нескольких результатов вызова с привязкой каждого к аргументам (зависимостям). Считай что это кеш, который конечно можно переполнить. И в твоём примере ты постоянно создаёшь новый экземплярbigData
и передаешь его как зависимость вuseCallback
в итоге твоя мемоизация вообще не работает и забивает память. В случае с мемоизированымbigData
- если у нехо нет зависимости от пропсов (или чего-то ещё) то он будет создан один раз для экземпляра компонента. И да, того же можно добиться сuseRef
, но там дело не в больших объектах, а в мутабельности. При мутации объекта который хранится вuseRef
- реакт вообще не будет ничего рендерить. Есть задачи которые можно решить и тем и тем, но по принципу работы это разные вещи, и далеко не всегда взаимозаменяемые.mayorovp
26.06.2024 14:29Какая растущая цепочка замыканий. Куча бреда в статье и в коментариях.
Вот эта:
И в твоём примере ты постоянно создаёшь новый экземпляр bigData и передаешь его как зависимость в useCallback
И где же он это делает-то?
loltrol
Ээээ, так вы же нарушили одно из главных правил написания react компонентов - функциональный компонент должен быть pure function. А у вас там при каждом рендере создается обьект на 10 мегабайт хД. Думаю в этом корень проблемы, который даст о себе знать рано или поздно и при других обстоятельствах. Запаковать его в memo, передать в props, через useEffect что то там накалькулейтить. Но не в render функции. У вас тот же обьект будет создаватся 2 раза при strict render....
ViktorVovk
Это вы где про такое правило прочитали?) не совсем представляю, как в большом функциональном компоненте можно соблюсти этот принцип. Это вообще не возможно, так как этот принцип, если я не ошибаюсь, гласит об том, что pure function может использовать только те данные, которые она получает в качестве аргумента, не создавать сайд эффектов, не использовать из вне данные. Любой другой компонент подключенный в компонент, любой хук, который вы используете нарушает этот принцип, так как это что то, что извне попадает в вашу функцию)
loltrol
https://react.dev/learn/keeping-components-pure - прочитал тут. props + state = input фукнции, кусок jsx = output. В вашем случае вы не ломаете чистоту функции хуками и другими компонентами, НО простая алокация и использование ее дальше по коду уже ломает, потому что это не state и не props. А для настоящих sideEffect есть useEffect, который прям так и называется, и который потом скидывает свой side-effect в state.
Kergan88
Выделение памяти, равно как и задержка выполнения и нагрев процессора при работе не считаются сайдэффектами. Потому что если считать - то чистых функций не существует. Любая функция при вызове аллоцирует как минимум адрес возврат (если только вызов не хвостовой) и делает джамп.