Утечки памяти отдельного окна

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

Бартек Новерски
Bartek Nowierski

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

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

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

let A = {};
console.log(A); // local variable reference

let B = {A}; // B.A is a second reference to A

A = null; // unset local variable reference

console.log(B.A); // A can still be referenced by B

B.A = null; // unset B's reference to A

// No references to A are left. It can be garbage collected.

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

Что такое отдельно стоящее окно?

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

<button id="show">Show Notes</button>
<button id="hide">Hide Notes</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> . Iframes ведут себя как вложенные окна, содержащие документы, а их свойство contentWindow обеспечивает доступ к содержащемуся объекту Window , во многом аналогично значению, возвращаемому window.open() . Код JavaScript может сохранять ссылку на contentWindow или contentDocument , даже если iframe удаляется из DOM или изменяется его URL-адрес, что предотвращает сбор мусора в документе, поскольку к его свойствам все еще можно получить доступ.

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

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

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

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

<button id="notes">Show Presenter 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 цепочка прототипов объекта включает ссылки на среду, в которой он был создан, включая окно, в котором он был создан. Это означает, что избегать хранения ссылок на объекты из других окон так же важно, как и избегать хранения ссылок на сами окна.

    индекс.html:

    <script>
      let currentFiles;
      function load(files) {
        // this retains the popup:
        currentFiles = files;
      }
      window.open('upload.html');
    </script>
    

    загрузить.html:

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

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

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

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

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

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

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

Анализ дампов кучи может оказаться непростой задачей, и найти нужную информацию в рамках отладки может быть довольно сложно. Чтобы помочь в этом, инженеры Chromium yossik@ и peledni@ разработали автономный инструмент Heap Cleaner , который может помочь выделить определенный узел, например отдельное окно. Запуск Heap Cleaner в трассировке удаляет другую ненужную информацию из графа хранения, что делает трассировку более чистой и ее намного легче читать.

Измерьте память программно

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

Скриншот раздела пользовательского интерфейса Chrome DevTools.
Проверка используемого размера кучи JS в DevTools при создании, закрытии и отсутствии ссылок на всплывающее окно.

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

Решения по предотвращению протечек из отдельных окон

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

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

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

<button id="open">Open Popup</button>
<button id="close">Close Popup</button>
<script>
  let popup;
  open.onclick = () => {
    popup = window.open('/login.html');
  };
  close.onclick = () => {
    popup.close();
  };
</script>

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

Решение: отключить ссылки

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

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

let popup;
open.onclick = () => {
  popup = window.open('/login.html');
};
close.onclick = () => {
  popup.close();
  popup = null;
};

Это помогает, но выявляет еще одну проблему, характерную для окон, созданных с помощью open() : что, если пользователь закроет окно вместо того, чтобы нажать нашу пользовательскую кнопку закрытия? Более того, что, если пользователь начнет переходить на другие веб-сайты в открытом нами окне? Хотя изначально казалось достаточным отключить ссылку popup при нажатии кнопки закрытия, утечка памяти все еще происходит, когда пользователи не используют эту конкретную кнопку для закрытия окна. Для решения этой проблемы необходимо обнаруживать такие случаи, чтобы отключать устаревшие ссылки, когда они происходят.

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

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

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

let popup;
open.onclick = () => {
  popup = window.open('/login.html');

  // listen for the popup being closed/exited:
  popup.addEventListener('pagehide', () => {
    // ignore initial event fired on "about:blank":
    if (!popup.location.host) return;

    // remove our reference to the popup window:
    popup = null;
  });
};

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

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">Open Popup</button>
<button id="close">Close Popup</button>
<script>
  let popup;
  open.onclick = () => {
    popup = new WeakRef(window.open('/login.html'));
  };
  close.onclick = () => {
    const win = popup.deref();
    if (win) win.close();
  };
</script>

Одна интересная деталь, которую следует учитывать при использовании WeakRef для доступа к окнам или документам, заключается в том, что ссылка обычно остается доступной в течение короткого периода времени после закрытия окна или удаления iframe. Это связано с тем, что WeakRef продолжает возвращать значение до тех пор, пока связанный с ним объект не будет удален сборщиком мусора, что происходит асинхронно в JavaScript и обычно во время простоя. К счастью, при проверке наличия отдельных окон на панели памяти Chrome DevTools создание снимка кучи фактически запускает сборку мусора и удаляет окно со слабой ссылкой. Также можно проверить, что объект, на который ссылается WeakRef был удален из JavaScript, либо определяя, когда deref() возвращает undefined , либо используя новый 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() . Возвращаясь к нашему исходному примеру заметок докладчика, такие функции, как nextSlide() обновляют окно заметок напрямую, ссылаясь на него и манипулируя его содержимым. Вместо этого основная страница может передавать необходимую информацию в окно заметок асинхронно и косвенно через postMessage() .

let updateNotes;
function showNotes() {
  // keep the popup reference in a closure to prevent outside references:
  let win = window.open('/presenter-view.html');
  win.addEventListener('pagehide', () => {
    if (!win || !win.location.host) return; // ignore initial "about:blank"
    win = null;
  });
  // other functions must interact with the popup through this API:
  updateNotes = (data) => {
    if (!win) return;
    win.postMessage(data, location.origin);
  };
  // listen for messages from the notes window:
  addEventListener('message', (event) => {
    if (event.source !== win) return;
    if (event.data[0] === 'nextSlide') nextSlide();
  });
}
let slide = 1;
function nextSlide() {
  slide += 1;
  // if the popup is open, tell it to update without referencing it:
  if (updateNotes) {
    updateNotes(['setSlide', slide]);
  }
}
document.body.onclick = nextSlide;

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

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

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

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

Опция "noopener" заставляет window.open() возвращать значение null , что делает невозможным случайное сохранение ссылки на всплывающее окно. Это также предотвращает получение всплывающим окном ссылки на родительское окно, поскольку свойство window.opener будет иметь значение null .

Обратная связь

Надеемся, что некоторые предложения из этой статьи помогут найти и устранить утечки памяти. Если у вас есть другой метод отладки отдельных окон или эта статья помогла обнаружить утечки в вашем приложении, я буду рад узнать! Вы можете найти меня в Твиттере @_developit .