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

image


Я работаю в 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.

iwfzla_6bvriw2nuhutnpxyyayo.png


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

Кстати, почитайте замечательную статью, в которой подробно разобрана эта тема: 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 (
    

{count}

); }


Как правило, сама по себе такая структура — не проблема. В вышеприведённом примере замыкания будут воссоздаваться на каждом этапе рендеринга 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 (
    

{count}

); }


В данном примере мы хотим избежать лишних этапов рендеринга 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 (
    
); }

Вы догадываетесь, что происходит?

Поскольку handleEvent() заключает в замыкание переменную count, именно в ней будет содержаться ссылка на объект контекста данного компонента. Кроме того, пусть мы и никогда не обращаемся к bigData в функции handleEvent(), в handleEvent() всё ещё будет содержаться ссылка на bigData. Это делается через объект контекста компонента.

Все замыкания совместно используют общий объект контекста, существовавший на тот момент, когда они создавались. Поскольку handleClick() замыкается на bigData на bigData будет ссылаться этот объект контекста. Таким образом, bigData не попадёт под сборку мусора до тех пор, пока стоит ссылка на handleEvent(). Эта ссылка будет действовать до тех пор, пока не изменится count и не будет воссоздана handleEvent().

iiulwh8_pbtixcxitxqpazghec8.png

Бесконечная утечка памяти при сочетании 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 (
    

A: {countA}, B: {countB}

); };


В данном примере мемоизированы два обработчика событий: handleClickA() и handleClickB(). Также здесь есть функция handleClickBoth(), которая и вызывает оба обработчика событий, и логирует длину bigData.

Догадываетесь, что произойдёт, если попеременно щёлкать кнопки «Increment A» и «Increment B»?

Давайте откроем «Инструменты разработчика» в браузере Chrome и посмотрим, что происходит в инспекторе памяти после того, как по пять раз нажать каждую из этих кнопок:

gku12ygop_srrpcw_qqmpj-o9ds.png


По-видимому, bigData вообще не попадает под сборку мусора. С каждым нажатием расход памяти только растёт. В нашем примере приложение держит ссылки на 11 экземпляров BigObject, каждый размером по 10 МБ. Один экземпляр создаётся для первичного рендеринга, ещё по одному — с каждым щелчком.

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

0. Первый шаг рендеринга:

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

sdjrkyvl-7x4brljc3230rvgxze.png


1. Щелчок по «Increment A»:

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


9ztuwi-e_iv4q8i7gmlrkkgyruy.png


2. Щелчок по «Increment B»:

  • При первом щелчке по «Increment B» будет воссоздана handleClickB(), поскольку мы меняем countB — давайте назовём новый экземпляр handleClickB()#1.
  • React не воссоздаёт handleClickA(), поскольку countA не меняется.
  • Следовательно, handleClickB()#1 будет удерживать ссылку на AppScope#2, which holds a reference to handleClickA()#1, в которой удерживается ссылка на AppScope#1, в которой удерживается ссылка на handleClickB()#0.


iq51-evudtmjdzihyom6brectog.png


3. Второй щелчок по «Increment A»:

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

keujo_ln-cp1nuai1pvnoe_7yfa.png


Суть проблемы


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

Грозит ли такая проблема именно вам?


Вот несколько факторов, при наличии которых вы сильнее рискуете столкнуться с подобными проблемами:

  1. У вас есть крупные компоненты, которые едва ли когда-либо воссоздаются, например, оболочка приложения, в которой запрограммировано значительное число деталей состояния.
  2. Вы пользуетесь useCallback, чтобы минимизировать операции повторного рендеринга.
  3. Из мемоизированных функций вы вызываете другие функции.
  4. Вам приходится обрабатывать крупные объекты — например, картинки или большие массивы.

Если вам не приходится обрабатывать какие-либо крупные объекты, то, пожалуй, ccылки на пару лишних строк или чисел проблемы не представляют. Большинство из этих перекрёстных ссылок между замыканиями самоустранятся, когда изменится достаточно много свойств. Просто учитывайте, что ваше приложение может загрести больше памяти, чем вы ожидали.

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


Дам вам несколько советов, которые, вероятно, помогут избежать подобных проблем:

Совет 1: области видимости ваших замыканий должны быть как можно меньше

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

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

Совет 2: Старайтесь не захватывать другие замыкания, в особенности мемоизированные.

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

Совет 3: Избегайте мемоизации, когда она не является необходимой.

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

Совет 4 (аварийный люк): при работе с большими объектами пользуйтесь useRef.

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

Заключение


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

Большое спасибо Дэвиду Глассеру за его статью A surprising JavaScript memory leak found at Meteor, написанную в 2013 году. Она стала для меня путеводной.

Habrahabr.ru прочитано 3244 раза