Perdita di memoria da finestre scollegate

Trova e correggi complicate perdite di memoria causate da finestre scollegate.

Bartek Nowierski
Bartek Nowierski

Che cos'è una perdita di memoria in JavaScript?

Una perdita di memoria è un aumento involontario della quantità di memoria utilizzata da un'applicazione nel tempo. In JavaScript, si verificano perdite di memoria quando gli oggetti non sono più necessari, ma vi fanno ancora riferimento da funzioni o altri oggetti. Questi riferimenti impediscono il recupero degli oggetti non necessari da parte del garbage collector.

Il compito del garbage collector è identificare e recuperare gli oggetti che non sono più raggiungibili dall'applicazione. Questo funziona anche quando gli oggetti fanno riferimento a se stessi o ciclicamente fanno riferimento a vicenda. Una volta che non ci sono altri riferimenti tramite i quali un'applicazione potrebbe accedere a un gruppo di oggetti, può essere garbage collection.

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.

Quando un'applicazione fa riferimento a oggetti che hanno un proprio ciclo di vita, come elementi DOM o finestre popup, si verifica una classe particolarmente complessa di perdita di memoria. È possibile che questi tipi di oggetti non vengano utilizzati all'insaputa dell'applicazione, il che significa che il codice dell'applicazione potrebbe avere gli unici riferimenti rimanenti a un oggetto che altrimenti potrebbe essere garbage collection.

Che cos'è una finestra scollegata?

Nell'esempio seguente, un'applicazione di visualizzazione della presentazione include pulsanti per aprire e chiudere un popup delle note del presentatore. Immagina che un utente faccia clic su Mostra note, quindi chiuda direttamente la finestra popup invece di fare clic sul pulsante Nascondi note. La variabile notesWindow contiene ancora un riferimento al popup a cui è possibile accedere, anche se il popup non è più in uso.

<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>

Questo è un esempio di finestra scollegata. La finestra popup è stata chiusa, ma il nostro codice contiene un riferimento che impedisce al browser di eliminarla e recuperare quella memoria.

Quando una pagina chiama window.open() per creare una nuova finestra o scheda del browser, viene restituito un oggetto Window che rappresenta la finestra o la scheda. Anche dopo che una finestra di questo tipo è stata chiusa o l'utente l'ha spostata, l'oggetto Window restituito da window.open() può ancora essere utilizzato per accedere alle informazioni che la riguardano. Questo è un tipo di finestra scollegata: poiché il codice JavaScript può ancora accedere alle proprietà dell'oggetto Window chiuso, deve essere conservato in memoria. Se la finestra includeva molti oggetti o iframe JavaScript, non sarà possibile recuperare quella memoria finché non ci saranno più riferimenti JavaScript alle proprietà della finestra.

Utilizzo di Chrome DevTools per dimostrare come è possibile conservare un documento dopo la chiusura di una finestra.

Lo stesso problema può verificarsi anche quando utilizzi gli elementi <iframe>. Gli iframe si comportano come finestre nidificate che contengono documenti e la loro proprietà contentWindow fornisce l'accesso all'oggetto Window contenuto, in modo molto simile al valore restituito da window.open(). Il codice JavaScript può mantenere un riferimento a contentWindow o contentDocument di un iframe anche se l'iframe viene rimosso dal DOM o se vengono apportate modifiche all'URL, impedendo che il documento venga garbage collection, poiché è ancora possibile accedere alle sue proprietà.

Dimostrazione di come un gestore di eventi può conservare il documento di un iframe, anche dopo aver spostato l'iframe in un altro URL.

Nei casi in cui un riferimento a document all'interno di una finestra o di un iframe viene conservato da JavaScript, il documento verrà conservato in memoria anche se la finestra o l'iframe contenitore apre un nuovo URL. Questo può essere particolarmente problematico quando JavaScript che mantiene il riferimento non rileva che la finestra/il frame ha raggiunto un nuovo URL, dato che non sa quando diventa l'ultimo riferimento con la conservazione di un documento in memoria.

In che modo le finestre scollegate causano perdite di memoria

Quando lavori con finestre e iframe sullo stesso dominio della pagina principale, è normale rimanere in ascolto degli eventi o accedere alle proprietà oltre i confini del documento. Ad esempio, rivediamo una variante dell'esempio dello spettatore della presentazione all'inizio di questa guida. Lo spettatore apre una seconda finestra per visualizzare le note del relatore. La finestra delle note del relatore ascolta gli eventiclick come spunto per passare alla slide successiva. Se l'utente chiude questa finestra delle note, il codice JavaScript in esecuzione nella finestra principale originale avrà comunque accesso completo al documento delle note del relatore:

<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>

Immagina di chiudere la finestra del browser creata da showNotes() sopra. Nessun gestore di eventi in ascolto per rilevare che la finestra è stata chiusa, quindi il nostro codice non deve ripulire eventuali riferimenti al documento. La funzione nextSlide() è ancora "pubblicata" perché è associata come gestore di clic nella nostra pagina principale e il fatto che nextSlide contenga un riferimento a notesWindow significa che viene ancora fatto riferimento alla finestra e non può essere garbage collection.

Illustrazione di come i riferimenti a una finestra ne impediscono la raccolta dei dati dopo la chiusura.

Esistono una serie di altri scenari in cui i riferimenti vengono conservati per errore che impediscono alle finestre scollegate di essere idonee per la garbage collection:

  • I gestori di eventi possono essere registrati nel documento iniziale di un iframe prima che il frame passi all'URL previsto, con il risultato che i riferimenti accidentali al documento e l'iframe rimangono memorizzati dopo la pulizia di altri riferimenti.

  • Un documento che utilizza molta memoria e caricato in una finestra o in un iframe può essere conservato accidentalmente in memoria molto tempo dopo l'accesso a un nuovo URL. Ciò è spesso causato dal fatto che la pagina principale conserva i riferimenti al documento per consentire la rimozione degli ascoltatori.

  • Durante il passaggio di un oggetto JavaScript a un'altra finestra o iframe, la catena di prototipi dell'oggetto include riferimenti all'ambiente in cui è stato creato, inclusa la finestra in cui è stato creato. Ciò significa che è altrettanto importante evitare di bloccare i riferimenti agli oggetti di altre finestre, così come evitare di conservare i riferimenti alle finestre stesse.

    index.html:

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

    upload.html:

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

Rilevamento di perdite di memoria causate da finestre scollegate

Individuare le fughe di memoria può essere difficile. Spesso è difficile creare riproduzioni isolate di questi problemi, soprattutto quando sono coinvolti più documenti o finestre. Per semplificare le cose, l'ispezione di potenziali riferimenti divulgati può finire per creare riferimenti aggiuntivi che impediscono la garbage collection degli oggetti ispezionati. A tal fine, è utile iniziare con strumenti che evitano specificamente di introdurre questa possibilità.

Un ottimo punto di partenza per iniziare a eseguire il debug dei problemi di memoria è creare uno snapshot heap. In questo modo viene fornita una visualizzazione point-in-time della memoria attualmente utilizzata da un'applicazione, ovvero di tutti gli oggetti creati ma non ancora garbage collection. Gli snapshot heap contengono informazioni utili sugli oggetti, tra cui le dimensioni e un elenco delle variabili e delle chiusure a cui fanno riferimento.

Uno screenshot di un&#39;istantanea heap in Chrome DevTools che mostra i riferimenti che conservano un oggetto di grandi dimensioni.
Uno snapshot heap che mostra i riferimenti che conservano un oggetto di grandi dimensioni.

Per registrare un'istantanea heap, vai alla scheda Memoria in Chrome DevTools e seleziona Istantanea heap nell'elenco dei tipi di profilazione disponibili. Al termine della registrazione, la visualizzazione Riepilogo mostra gli oggetti correnti in memoria, raggruppati per costruttore.

Dimostrazione dell'acquisizione di uno snapshot heap in Chrome DevTools.

L'analisi dei dump dell'heap può essere un'attività scoraggiante e può essere piuttosto difficile trovare le informazioni giuste durante il debug. Per aiutarti, gli ingegneri di Chromium yossik@ e peledni@ hanno sviluppato uno strumento Heap Cleaner autonomo che può aiutarti a evidenziare un nodo specifico come una finestra scollegata. L'esecuzione di Heap Cleaner su una traccia rimuove altre informazioni non necessarie dal grafico di conservazione, rendendo la traccia più chiara e molto più facile da leggere.

Misura la memoria in modo programmatico

Gli snapshot heap forniscono un livello di dettaglio elevato ed sono eccellenti per capire dove si verificano le perdite, ma l'acquisizione di un'istantanea heap è un processo manuale. Un altro modo per verificare la presenza di perdite di memoria è ottenere le dimensioni heap JavaScript attualmente utilizzate dall'API performance.memory:

Uno screenshot di una sezione dell&#39;interfaccia utente di Chrome DevTools.
Controllo delle dimensioni heap JS utilizzate in DevTools quando viene creato, chiuso e senza riferimenti un popup.

L'API performance.memory fornisce solo informazioni sulla dimensione heap JavaScript, il che significa che non include la memoria utilizzata dalle risorse e dal documento del popup. Per avere un quadro completo, dovremmo utilizzare la nuova API performance.measureUserAgentSpecificMemory() attualmente in prova in Chrome.

Soluzioni per evitare infiltrazioni di finestre scollegate

I due casi più comuni in cui le finestre scollegate causano perdite di memoria sono i casi in cui il documento principale conserva i riferimenti a un popup chiuso o a un iframe rimosso e quando la navigazione imprevista di una finestra o di un iframe determina la mancata registrazione dei gestori di eventi.

Esempio: chiusura di un popup

Nell'esempio seguente, vengono utilizzati due pulsanti per aprire e chiudere una finestra popup. Affinché il pulsante Chiudi popup funzioni, un riferimento alla finestra popup aperta viene archiviato in una variabile:

<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>

A prima vista, sembra che il codice riportato sopra eviti gli errori comuni: non vengono conservati riferimenti al documento del popup e non vengono registrati gestori di eventi nella finestra popup. Tuttavia, una volta fatto clic sul pulsante Apri popup, la variabile popup ora fa riferimento alla finestra aperta e questa variabile è accessibile dall'ambito del gestore dei clic sul pulsante Chiudi popup. A meno che popup non venga riassegnato o che il gestore dei clic non venga rimosso, il riferimento racchiuso dal gestore a popup significa che non può essere garbage collector.

Soluzione: annulla l'impostazione dei riferimenti

Le variabili che fanno riferimento a un'altra finestra o al relativo documento ne determinano la conservazione in memoria. Poiché gli oggetti in JavaScript sono sempre riferimenti, l'assegnazione di un nuovo valore alle variabili rimuove il riferimento all'oggetto originale. Per "annullare l'impostazione" dei riferimenti a un oggetto, possiamo riassegnare queste variabili al valore null.

Applicando questo comando all'esempio di popup precedente, possiamo modificare il gestore del pulsante di chiusura per renderlo "non impostato" come riferimento alla finestra popup:

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

Ciò è utile, ma rivela un ulteriore problema specifico delle finestre create con open(): che cosa succede se l'utente chiude la finestra invece di fare clic sul nostro pulsante di chiusura personalizzato? E ancora, cosa succede se l'utente inizia a visitare altri siti web nella finestra che abbiamo aperto? Anche se inizialmente sembrava sufficiente per annullare l'impostazione del riferimento popup quando si fa clic sul pulsante di chiusura, si verifica comunque una perdita di memoria quando gli utenti non utilizzano quel pulsante specifico per chiudere la finestra. Per risolvere il problema occorre rilevare questi casi per annullare la configurazione dei riferimenti persistenti quando si verificano.

Soluzione: monitora e smaltisci

In molte situazioni, il codice JavaScript responsabile dell'apertura di finestre o della creazione di frame non ha un controllo esclusivo sul loro ciclo di vita. I popup possono essere chiusi dall'utente oppure l'esplorazione di un nuovo documento può causare la rimozione del documento precedentemente contenuto in una finestra o in un frame. In entrambi i casi, il browser attiva un evento pagehide per segnalare che è in corso l'unload del documento.

L'evento pagehide può essere utilizzato per rilevare finestre chiuse e uscire dal documento corrente. Tuttavia, c'è un'avvertenza importante: tutte le finestre e gli iframe appena creati contengono un documento vuoto, quindi passano in modo asincrono all'URL specificato, se fornito. Di conseguenza, un evento pagehide iniziale viene attivato poco dopo aver creato la finestra o il frame, appena prima del caricamento del documento di destinazione. Poiché il nostro codice di pulizia dei riferimenti deve essere eseguito quando viene eseguito l'unload del documento target, dobbiamo ignorare questo primo evento pagehide. Esistono diverse tecniche per farlo, la più semplice delle quali è ignorare gli eventi pagehide che hanno origine dall'URL about:blank del documento iniziale. Ecco come apparirebbe nel nostro esempio di popup:

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;
  });
};

È importante notare che questa tecnica funziona solo per finestre e frame che hanno la stessa origine effettiva della pagina padre su cui viene eseguito il nostro codice. Durante il caricamento di contenuti da un'origine diversa, sia l'evento location.host sia l'evento pagehide non sono disponibili per motivi di sicurezza. Sebbene di solito sia meglio evitare di conservare i riferimenti ad altre origini, nei rari casi in cui è necessario è possibile monitorare le proprietà window.closed o frame.isConnected. Quando queste proprietà cambiano per indicare una finestra chiusa o un iframe rimosso, è consigliabile annullare l'impostazione di eventuali riferimenti alla finestra.

let popup = window.open('https://example.com');
let timer = setInterval(() => {
  if (popup.closed) {
    popup = null;
    clearInterval(timer);
  }
}, 1000);

Soluzione: utilizzare WeakRef

JavaScript ha recentemente ottenuto supporto per un nuovo modo per fare riferimento agli oggetti che consente l'esecuzione della garbage collection, chiamato WeakRef. Un elemento WeakRef creato per un oggetto non è un riferimento diretto, ma un oggetto separato che fornisce un metodo .deref() speciale che restituisce un riferimento all'oggetto purché non sia stato raccolto. Con WeakRef, è possibile accedere al valore attuale di una finestra o di un documento, pur consentendo la raccolta dei dati. Anziché conservare un riferimento alla finestra, che deve essere annullato manualmente in risposta a eventi come pagehide o proprietà come window.closed, si ottiene l'accesso alla finestra in base alle esigenze. Quando la finestra è chiusa, può essere garbage collection, perciò il metodo .deref() inizia a restituire 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>

Un dettaglio interessante da considerare quando si utilizza WeakRef per accedere a finestre o documenti è che il riferimento rimane generalmente disponibile per un breve periodo di tempo dopo la chiusura della finestra o la rimozione dell'iframe. Questo perché WeakRef continua a restituire un valore fino a quando l'oggetto associato non viene garbage collection, il che avviene in modo asincrono in JavaScript e generalmente durante il tempo di inattività. Fortunatamente, quando controlli le finestre scollegate nel riquadro Memoria di Chrome DevTools, l'acquisizione di uno snapshot heap attiva in realtà la garbage collection e elimina la finestra con riferimenti deboli. È anche possibile verificare che un oggetto a cui viene fatto riferimento tramite WeakRef sia stato eliminato da JavaScript, rilevando quando deref() restituisce undefined o utilizzando la nuova 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());

Soluzione: comunica oltre postMessage

Rilevare quando le finestre sono chiuse o quando la navigazione annulla il caricamento di un documento ci offre un modo per rimuovere i gestori e annullare l'impostazione dei riferimenti, in modo che le finestre scollegate possano essere oggetto di garbage collection. Tuttavia, queste modifiche rappresentano correzioni specifiche per ciò che a volte può rappresentare un problema più importante: l'accoppiamento diretto tra le pagine.

È disponibile un approccio alternativo più olistico che evita riferimenti inattivi tra finestre e documenti: stabilire la separazione limitando la comunicazione tra documenti a postMessage(). Ripensando all'esempio delle note per il presentatore originali, funzioni come nextSlide() aggiornavano direttamente la finestra delle note facendovi riferimento e manipolando i suoi contenuti. Al contrario, la pagina principale potrebbe passare le informazioni necessarie alla finestra delle note in modo asincrono e indiretto tramite 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;

Sebbene ciò richieda comunque che le finestre facciano riferimento l'una all'altra, nessuna delle due conserva un riferimento al documento corrente da un'altra finestra. Un approccio basato sulla trasmissione di messaggi incoraggia inoltre i progetti in cui i riferimenti alle finestre sono conservati in un unico posto, il che significa che è necessario annullare l'impostazione di un solo riferimento quando le finestre sono chiuse o escono. Nell'esempio precedente, solo showNotes() conserva un riferimento alla finestra delle note e utilizza l'evento pagehide per garantire che il riferimento venga ripulito.

Soluzione: evita i riferimenti utilizzando noopener

Nei casi in cui si apre una finestra popup che non richiede la comunicazione o il controllo per la pagina, potresti evitare di ottenere un riferimento alla finestra. Ciò è particolarmente utile quando si creano finestre o iframe che caricano i contenuti da un altro sito. In questi casi, window.open() accetta un'opzione "noopener" che funziona come l'attributo rel="noopener" per i link HTML:

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

L'opzione "noopener" fa sì che window.open() restituisca null, rendendo impossibile l'archiviazione accidentale di un riferimento al popup. Impedisce inoltre alla finestra popup di ottenere un riferimento alla finestra principale, poiché la proprietà window.opener sarà null.

Feedback

Speriamo che alcuni dei suggerimenti riportati in questo articolo ti aiutino a trovare e correggere le perdite di memoria. Mi piacerebbe sapere se hai un'altra tecnica per il debug delle finestre scollegate o se questo articolo ti ha aiutato a scoprire perdite nella tua app. Mi trovi su Twitter @_developit.