Утечки памяти от потери ссылок на окна/iframe

Что такое: утечка памяти в JavaScript?

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

Задача сборщика мусора – идентифицировать и исправлять объекты, которые больше не доступны из приложения. Это работает, даже если объекты ссылаются сами на себя или циклически ссылаются друг на друга. Как только не остаётся ссылок, через которые приложение могло бы получить доступ к группе объектов, оно может быть обработано сборщиком мусора.

let A = {};
// ссылка на локальную переменную
console.log(A);

// B.A вторая ссылка на A
let B = {A};

// сбросить ссылку на локальную переменную
A = null;

// B всё ещё может ссылаться на A
console.log(B.A);

// сброс ссылки B на A
B.A = null;

// Никаких ссылок на А не осталось.
// Это может быть обработано сборщиком мусора.

Особенно тяжёлый случай утечки памяти возникает, когда приложение ссылается на объекты, имеющие собственный жизненный цикл, например, элементы DOM, всплывающие окна или фреймы. Эти типы объектов могут стать неиспользуемыми без ведома приложения. Таким образом, в коде приложения могут оставаться ссылки на объект, который в обычной ситуации мог бы быть обработан сборщиком мусора.

Что такое потерянное окно?

В примере ниже приложение для просмотра слайд-шоу включает кнопки для открытия и закрытия всплывающего окна с заметками докладчика. Представим, что пользователь нажимает кнопку «Показать заметки», а затем закрывает всплывающее окно напрямую, вместо того, чтобы нажать кнопку «Скрыть заметки». В этом случае переменная notesWindow продолжит хранить ссылку на всплывающее окно, и к нему можно получить доступ, даже если оно больше не используется и не существует.

<button id="show">Показать заметки</button>
<button id="hide">Скрыть заметки</button>
<script type="module">
  let notesWindow;
  document.getElementById('show').onclick = () => {
    notesWindow = window.open('/presenter-notes.html');
  };
  document.getElementById('hide').onclick = () => {
    if (notesWindow) notesWindow.close();
  };
</script>

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

Когда на странице вызывается метод window.open() для создания нового окна или вкладки браузера, он возвращает ссылку на объект Window, представляющий это окно или вкладку. Даже после того, как такое окно будет закрыто, объект Window, который вернул метод window.open(), всё ещё может использоваться для доступа к информации о нём.
Это один из вариантов потери окна: поскольку код JavaScript всё ещё потенциально может получать доступ к свойствам объекта Window закрытого окна, этот объект будет оставаться в памяти. Если в окне было много JavaScript-объектов или iframe, эту память не получится освободить до тех пор, пока остаются JavaScript-ссылки на свойства окна.

Использование Chrome DevTools, для демонстрации сохранения документа после закрытия окна.

Аналогичная проблема может возникнуть при использовании <iframe>, по сути – встроенное окно, содержащее документ, а его свойство contentWindow обеспечивает доступ к объекту Window, как и значение, возвращаемое функцией window.open().
Код в JavaScript может сохранять ссылку на contentWindow или contentDocument из iframe, даже когда он покинет DOM или изменится его URL-адрес, и это ограничивает сборку мусора документа, поскольку свойства исходного содержимого iframe всё ещё используются.

Демонстрация сохранения обработчиком событий предыдущего документа из iframe после его перехода на другой URL.

В случае, когда ссылка на document из другого окна или iframe сохраняется в JavaScript, этот документ будет храниться в памяти, даже если содержавшее его окно или iframe переходит по новому URL. Это может быть особенно неприятно, когда код, содержащий эту ссылку, не обнаруживает, что окно/фрейм сменило URL и в нём уже другое содержимое.

Как потерянные окна вызывают утечку памяти

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

<button id="notes">Показать заметки докладчика</button>
<script type="module">
  let notesWindow;
  function showNotes() {
    notesWindow = window.open('/presenter-notes.html');
    notesWindow.document.addEventListener('click', nextSlide);
  }
  document.getElementById('notes').onclick = showNotes;

  let slide = 1;
  function nextSlide() {
    slide += 1;
    notesWindow.document.title = `Slide  ${slide}`;
  }
  document.body.onclick = nextSlide;
</script>

Закроем созданное выше с помощью метода showNotes() окно браузера. Здесь отсутствует обработчик события закрытия окна, поэтому ничто не может сообщить коду, что он должен очистить любые ссылки на документ. Функция nextSlide() всё ещё «живая», потому что она привязана как обработчик кликов на исходной главной странице. А тот факт, что nextSlide содержит ссылку на notesWindow, означает, что на закрытое окно по-прежнему ссылаются и сбор мусора невозможна.

Иллюстрация того, как ссылки на окно нарушают сборку мусора после его закрытия.

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

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

    index.html:

    <script>
      let currentFiles;
      function load(files) {
        // это сохраняет popup:
        currentFiles = files;
      }
      window.open('upload.html');
    </script>
    

    upload.html:

    <input type="file" id="file" />
    <script>
      file.onchange = () => {
        parent.load(file.files);
      };
    </script>
    

Обнаружение утечек памяти, вызванных отключением окон

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

Отладку проблем с памятью стоит начинать со снимка кучи – heap snapshot. Он предоставляет возможность просмотра памяти, используемой приложением в момент измерения, а также всех объектов, которые были созданы, но ещё не обработаны сборщиком мусора. Снимки кучи содержат полезную информацию об объектах: их размер, а также список переменных и замыканий, которые на них ссылаются.

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

Чтобы записать снимок кучи, перейдите на вкладку Memory в Chrome DevTools и выберите Heap Snapshot в списке доступных типов профилирования. После завершения записи в Summary отображаются текущие объекты в памяти, сгруппированные по конструктору.

Демонстрация получения снимка кучи в Chrome DevTools.

Анализ дампов кучи – сложный процесс и может быть непросто найти нужную информацию в рамках отладки. Облегчить страдания можно с помощью автономного инструмента Heap Cleaner, c которым можно выделить определённый узел, например отдельное окно. Запуск Heap Cleaner при трассировке удаляет ненужную информацию из графа хранения, это упрощает чтение трассировки.

Программное измерение памяти

Моментальные снимки кучи обеспечивают высокий уровень детализации и отлично подходят для определения места утечки, но создание heap snapshot – это ручной процесс. Ещё один способ проверить утечку памяти – получить текущий размер JavaScript-кучи из API performance.memory:

Проверка размера JS-кучи в DevTools при создании, закрытии и отсутствии ссылок на всплывающее окно.
Проверка размера JS-кучи в DevTools при создании, закрытии и отсутствии ссылок на всплывающее окно.

API performance.memory предоставляет информацию только о размере JavaScript-кучи, это означает, что она не включает память, используемую документом и ресурсами всплывающего окна. Чтобы получить полную картину, следует использовать новый API performance.measureMemory() в Chrome.

Варианты предотвращения утечек памяти из отдельного окна

Два наиболее распространенных случая утечки памяти, вызванной отключением окна:

  • когда родительский документ сохраняет ссылки на закрытое всплывающее окно или уничтоженный iframe
  • когда изменения навигации (смена URL) в окне или iframe не отменяют обработчиков событий

Пример: закрытие всплывающего окна

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

<button id="open">Открыть попап</button>
<button id="close">Закрыть попап</button>
<script>
  let popup;
  open.onclick = () => {
    popup = window.open('/login.html');
  };
  close.onclick = () => {
    popup.close();
  };
</script>

На первый взгляд кажется, что этот код позволяет избежать распространенных ошибок: не сохраняются ссылки на документ из всплывающего окна и в самом окне не регистрируются обработчики событий. Однако после нажатия кнопки «Открыть попап» переменная popup теперь ссылается на открытое окно и становится доступной из метода-обработчика нажатия кнопки «Закрыть попап». Пока в переменной popup не изменено значение или обработчик клика не удален, ссылка из этого обработчика на popup не позволит обработать это окно сборщиком мусором.

Решение: надо уничтожать ссылки

Переменные, которые ссылаются на другое окно или его документ, приводят к сохранению его в памяти. Поскольку объекты в JavaScript являются ссылками, присвоение нового значения переменным удалит предыдущую ссылку на исходный объект. Чтобы «уничтожить» ссылку на объект, можно присвоить переменной значение null.

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

<button id="open">Открыть попап</button>
<button id="close">Закрыть попап</button>
<script>
  let popup;
  open.onclick = () => {
    popup = window.open('/login.html');
  };
  close.onclick = () => {
    popup.close();
    popup = null; // уничтожение ссылки
  };
</script>

Этот способ работает, но оставляет нерешенной одну проблему, специфичную для окон, созданных с помощью метода open(), когда пользователь, вместо того, чтобы щелкнуть предоставляемую в коде кнопку «Закрыть попап», закроет окно любым другим способом? Или если пользователь станет просматривать другие веб-сайты в этом окне? Сперва казалось достаточным уничтожать при нажатии кнопки закрытия ссылку в переменной popup. Однако утечка памяти всё ещё будет оставаться, если пользователь будет пренебрегать кнопкой, предоставленной для закрытия окна в коде. Для решения этой проблемы требуется обнаружение таких случаев, чтобы избавляться от устаревших ссылок, когда они появляются.

Решение: контроль и утилизация

Часто JavaScript, отвечающий за открытие окон или создание фреймов, не имеет полного контроля над их жизненным циклом. Всплывающее окно может быть закрыто пользователем, а смена URL может привести к отсоединению документа, ранее содержащегося в окне или фрейме. В обоих случаях браузер запускает событие pagehide, чтобы сообщить – документ выгружается.

Событие pagehide можно использовать для обнаружения закрытия окон и отключения текущего документа. Надо помнить об одной важной вещи: все вновь созданные окна и фреймы содержат пустой документ, а затем асинхронно переходят по заданному URL-адресу, если он указан. В результате событие pagehide первый раз запускается сразу после создания окна или фрейма, непосредственно перед загрузкой целевого документа и нужно игнорировать это первое событие pagehide.
Для этого существует ряд методов, самый простой из которых – игнорировать события pagehide, происходящие из исходного URL-адреса about: blank. Вот так это может выглядеть на примере всплывающего окна:

let popup;
open.onclick = () => {
  popup = window.open('/login.html');
  // отлавливаем событие pagehide
  popup.addEventListener('pagehide', () => {
    // игнорируем pagehide для документа "about:blank":
    if (!popup.location.host) return;
    // уничтожаем ссылку на всплывающее окно
    popup = null;
  });
};

Важно отметить, что такой метод будет работать только для окон и фреймов, у которых с родительской страницей, на которой выполняется этот код, одинаковый источник. При загрузке содержимого из другого источника location.host и событие pagehide будут недоступны по соображениям безопасности.
Иногда можно отслеживать свойства window.closed или frame.isConnected. При обнаружении изменений свойств, которые указывают на закрытие окна или уничтожение фрейма, можно удалять все ссылки на него:

let popup = window.open('https://example.com');
let timer = setInterval(() => {
  if (popup.closed) {
    // окно закрылось,
    // можно удалять ссылку на него
    popup = null;
    clearInterval(timer);
  }
}, 1000);

Решение: использование WeakRef

JavaScript недавно получил поддержку новой реализации ссылки на объекты – WeakRef, которая способствует сбору мусора. WeakRef, созданный для объекта, не является непосредственно ссылкой, это скорее отдельный объект и он предоставляет специальный метод .deref(), который вернёт ссылку на объект, если он не был собран сборщиком мусора. С WeakRef можно получать доступ к текущему значению окна или документа, не мешая при этом собирать мусор.
В отличие от хранения ссылки на окно с необходимостью перехватывать событие pagehide или изменение свойства window.closed, доступ к окну предоставляется по мере необходимости. Когда окно закрыто, оно может быть обработано сборщиком мусора и после этого метод .deref() будет возвращать undefined.

<button id="open">Открыть попап</button>
<button id="close">Закрыть попап</button>
<script>
  let popup;
  open.onclick = () => {
    popup = new WeakRef(window.open('/login.html'));
  };
  close.onclick = () => {
    if (popup.deref()) {
      win.close();
    }
  };
</script>

Есть одна вещь, которую следует учитывать при использовании WeakRef для доступа к окнам или документам – в течение короткого периода времени после закрытия окна или удаления iframe ссылка может быть доступной. Это связано с тем, что WeakRef продолжает возвращать значение до тех пор, пока связанный с ним объект не будет обработан сборщиком мусора, а это в JavaScript происходит асинхронно и, как правило, во время простоя. К счастью, при проверке наличия отдельных окон во вкладке Memory Chrome DevTools создание снимка кучи фактически запускает сборку мусора и удаляет слабые ссылки на окна. Ещё можно использовать API FinalizationRegistry:

let popup = new WeakRef(window.open('/login.html'));

// Polling deref():
let timer = setInterval(() => {
  if (popup.deref() === undefined) {
    console.log('popup was garbage-collected');
    clearInterval(timer);
  }
}, 20);

// FinalizationRegistry API:
let finalizers = new FinalizationRegistry(() => {
  console.log('popup was garbage-collected');
});
finalizers.register(popup.deref());

Решение: общаться с помощью postMessage

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

Есть альтернативный подход, позволяющий избегать устаревающих ссылок между окнами и документами – взаимодействие между документами с помощью postMessage().

let updateNotes;
function showNotes() {
  // сохраним ссылку на попап в closure от внешних ссылок
  let win = window.open('/presenter-view.html');
  win.addEventListener('pagehide', () => {
    // игнорируем начальный "about:blank"
    if (!win || !win.location.host) return;
    win = null;
  });
  // другие методы должны взаимодействовать
  // со всплывающим окном через этот API
  updateNotes = (data) => {
    if (!win) return;
    win.postMessage(data, location.origin);
  };
  // слушаем сообщения из окна с заметками
  addEventListener('message', (event) => {
    if (event.source !== win) return;
    if (event.data[0] === 'nextSlide') nextSlide();
  });
}
let slide = 1;
function nextSlide() {
  slide += 1;
  // если всплывающее окно открыто,
  // скажем ему обновиться, не ссылаясь на него
  if (updateNotes) {
    updateNotes(['setSlide', slide]);
  }
}
document.body.onclick = nextSlide;

Хотя здесь по-прежнему требуется, чтобы окна общались между собой, ни одно из них не хранит ссылку на текущий документ из другого окна. Такой подход со взаимодействием через передачу сообщений позволяет хранить ссылки на окна в одном месте, т.о. когда окна закрываются или перемещаются, только одна ссылка должна быть отключена. В приведенном выше примере только showNotes() хранит ссылку на окно заметок и использует событие pagehide, чтобы убедиться, что ссылка уничтожена.

Решение: избегать ссылки с помощью noopener

В случаях, когда открывается всплывающее окно, с которым странице нет необходимости взаимодействовать, следует избегать получения ссылки на него. Это особенно полезно при создании окон или фреймов, которые будут загружать контент с другого сайта. В этих случаях метод window.open() принимает параметр 'noopener', который будет работать так же, как атрибут rel="noopener" в HTML-ссылках:

window.open('https://example.com/share', null, 'noopener');

Параметр 'noopener' заставляет метод window.open() возвращать значение null, это делает невозможным случайное сохранение ссылки на всплывающее окно. Заодно предотвращается получение всплывающим окном ссылки на его родительское окно, поскольку свойство window.opener будет иметь значение null.

Навигация по разделу