Perdita di memoria da finestre scollegate

Trova e correggi le perdite di memoria complesse 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, le perdite di memoria si verificano quando gli oggetti non sono più necessari, ma vengono comunque richiamati da funzioni o altri oggetti. Questi riferimenti impediscono che gli oggetti non necessari vengano recuperati dal 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 si fanno riferimento tra loro in modo ciclico. Una volta che non ci sono più riferimenti tramite i quali un'applicazione potrebbe accedere a un gruppo di oggetti, è possibile eseguire la raccolta dei rifiuti.

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.

Una classe di perdite di memoria particolarmente insidiosa si verifica quando un'applicazione fa riferimento a oggetti che hanno il proprio ciclo di vita, come elementi DOM o finestre popup. È possibile che questi tipi di oggetti rimangano inutilizzati senza che l'applicazione lo sappia, il che significa che il codice dell'applicazione potrebbe avere gli unici riferimenti rimanenti a un oggetto che altrimenti potrebbe essere sottoposto a garbage collection.

Che cos'è una finestra staccata?

Nell'esempio seguente, un'applicazione di visualizzazione di presentazioni include pulsanti per aprire e chiudere un popup di 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 staccata. La finestra popup è stata chiusa, ma il nostro codice contiene un riferimento che impedisce al browser di distruggerla e recuperare la 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 è uscito, l'oggetto Window restituito da window.open() può essere ancora utilizzato per accedere alle informazioni su di essa. Questo è un tipo di finestra scollegata: poiché il codice JavaScript può ancora potenzialmente accedere alle proprietà dell'oggetto Window chiuso, deve essere mantenuto in memoria. Se la finestra includeva molti oggetti o iframe JavaScript, la memoria non può essere recuperata finché non rimangono 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 si utilizzano 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 il suo URL cambia, il che impedisce la raccolta dei rifiuti del documento poiché è ancora possibile accedere alle sue proprietà.

Demo di come un gestore eventi può conservare il documento di un iframe, anche dopo aver eseguito la navigazione nell'iframe a un URL diverso.

Nei casi in cui un riferimento a document all'interno di una finestra o di un iframe viene mantenuto da JavaScript, il documento verrà mantenuto in memoria anche se la finestra o l'iframe contenente si apre su un nuovo URL. Questo può essere particolarmente problematico quando il codice JavaScript che contiene il riferimento non rileva che la finestra/il frame ha eseguito la navigazione a un nuovo URL, poiché non sa quando diventa l'ultimo riferimento che mantiene un documento in memoria.

In che modo le finestre scollegate causano perdite di memoria

Quando si utilizzano finestre e iframe nello stesso dominio della pagina principale, è normale ascoltare eventi o accedere alle proprietà oltre i confini del documento. Ad esempio, esaminiamo di nuovo una variante dell'esempio di visualizzatore di presentazioni all'inizio di questa guida. Il visualizzatore apre una seconda finestra per la visualizzazione delle note del relatore. La finestra delle note del relatore è in ascolto per gli eventiclick come segnale per passare alla diapositiva successiva. Se l'utente chiude questa finestra delle note, il codice JavaScript in esecuzione nella finestra principale originale ha ancora accesso completo al documento delle note dell'altoparlante:

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

Immaginiamo di chiudere la finestra del browser creata da showNotes() sopra. Non esiste un gestore di eventi che ascolti per rilevare la chiusura della finestra, pertanto non viene comunicato al codice di eliminare i riferimenti al documento. La funzione nextSlide() è ancora "attiva" perché è associata come gestore dei clic nella nostra pagina principale e il fatto che nextSlide contenga un riferimento a notesWindow significa che la finestra è ancora indicata e non può essere sottoposta a garbage collection.

Illustrazione di come i riferimenti a una finestra ne impediscano la raccolta dei rifiuti una volta chiusa.

Esistono diversi altri scenari in cui i riferimenti vengono conservati per errore e impediscono alle finestre scollegate di essere idonee per la raccolta dei rifiuti:

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

  • Un documento che richiede molta memoria caricato in una finestra o in un iframe può essere mantenuto in memoria per errore molto tempo dopo aver eseguito la navigazione a un nuovo URL. Questo accade spesso perché la pagina principale conserva i riferimenti al documento per consentire la rimozione dell'ascoltatore.

  • Quando passi un oggetto JavaScript a un'altra finestra o a un iframe, la catena del prototipo dell'oggetto include riferimenti all'ambiente in cui è stato creato, inclusa la finestra che lo ha creato. Ciò significa che è importante evitare di conservare riferimenti a oggetti di altre finestre quanto evitare di conservare 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 perdite di memoria può essere complicato. Spesso è difficile creare riproduzioni isolate di questi problemi, in particolare quando sono coinvolti più documenti o finestre. Per complicare ulteriormente la situazione, l'ispezione di potenziali riferimenti con perdite può finire per creare riferimenti aggiuntivi che impediscono la raccolta dei rifiuti degli oggetti ispezionati. A tal fine, è utile iniziare con strumenti che evitano specificamente di introdurre questa possibilità.

Un ottimo punto di partenza per eseguire il debug dei problemi di memoria è acquisire uno snapshot dell'heap. Ciò fornisce una visualizzazione istantanea della memoria attualmente utilizzata da un'applicazione, ovvero di tutti gli oggetti che sono stati creati ma non ancora sottoposti a garbage collection. Gli snapshot dell'heap contengono informazioni utili sugli oggetti, incluse le dimensioni e un elenco delle variabili e delle chiusure che fanno riferimento a questi oggetti.

Uno screenshot di uno snapshot dell&#39;heap in Chrome DevTools che mostra i riferimenti che mantengono un oggetto di grandi dimensioni.
Un'istantanea dell'heap che mostra i riferimenti che mantengono un oggetto di grandi dimensioni.

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

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

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

Misurare la memoria in modo programmatico

Le istantanee heap forniscono un elevato livello di dettaglio e sono ottime per capire dove si verificano le perdite, ma acquisire un'istantanea heap è una procedura manuale. Un altro modo per verificare la presenza di perdite di memoria è ottenere la dimensione dell'heap JavaScript attualmente utilizzata dall'API performance.memory:

Uno screenshot di una sezione dell&#39;interfaccia utente di Chrome DevTools.
Verifica la dimensione dell'heap JS utilizzata in DevTools quando un popup viene creato, chiuso e senza riferimenti.

L'API performance.memory fornisce informazioni solo sulle dimensioni dell'heap di JavaScript, il che significa che non include la memoria utilizzata dal documento e dalle risorse del popup. Per avere un quadro completo, dobbiamo utilizzare la nuova API performance.measureUserAgentSpecificMemory() attualmente in fase di sperimentazione in Chrome.

Soluzioni per evitare perdite dalle finestre staccate

I due casi più comuni in cui le finestre scollegate causano perdite di memoria si verificano quando 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 comporta la mancata registrazione dei gestori degli 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 memorizzato 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, dopo aver fatto clic sul pulsante Apri popup, la variabile popup fa ora riferimento alla finestra aperta ed è accessibile dall'ambito del gestore dei clic del pulsante Chiudi popup. A meno che popup non venga riassegnato o che il gestore dei clic non venga rimosso, il riferimento a popup incluso nel gestore indica che non può essere sottoposto al garbage collection.

Soluzione: riferimenti non impostati

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

Se applichiamo questo principio all'esempio di popup precedente, possiamo modificare l'handler del pulsante di chiusura in modo che "reimposta" il riferimento alla finestra popup:

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

Questo è utile, ma rivela un ulteriore problema specifico delle finestre create utilizzando open(): che cosa succede se l'utente chiude la finestra anziché fare clic sul nostro pulsante di chiusura personalizzato? Inoltre, cosa succede se l'utente inizia a visitare altri siti web nella finestra che abbiamo aperto? Sebbene inizialmente sembrasse sufficiente eliminare il riferimento popup quando si fa clic sul pulsante di chiusura, si verifica ancora una perdita di memoria quando gli utenti non utilizzano quel determinato pulsante per chiudere la finestra. Per risolvere il problema è necessario rilevare questi casi per annullare i riferimenti inutilizzati 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 il controllo esclusivo sul loro ciclo di vita. Le finestre popup possono essere chiuse dall'utente oppure la navigazione a un nuovo documento può causare il distacco del documento precedentemente contenuto da una finestra o un riquadro. In entrambi i casi, il browser attiva un evento pagehide per segnalare che il documento viene scaricato.

L'evento pagehide può essere utilizzato per rilevare le finestre chiuse e la navigazione al di fuori del documento corrente. Tuttavia, c'è un'importante limitazione: 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 inizialepagehide viene attivato poco dopo la creazione della finestra o del frame, appena prima del caricamento del documento di destinazione. Poiché il codice di pulizia dei riferimenti deve essere eseguito quando il documento target viene sganciato, dobbiamo ignorare questo primo evento pagehide. Esistono diverse tecniche per farlo, la più semplice delle quali è ignorare gli eventi pagehide provenienti dall'URLabout:blank del documento iniziale. Ecco come apparirà 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 le finestre e i frame che hanno la stessa origine effettiva della pagina principale in cui viene eseguito il nostro codice. Quando carichi contenuti da un'origine diversa, sia l'evento location.host sia l'evento pagehide non sono disponibili per motivi di sicurezza. Anche se in genere è meglio evitare di conservare i riferimenti ad altre origini, nei rari casi in cui ciò sia necessario è possibile monitorare le proprietà window.closed o frame.isConnected. Quando queste proprietà cambiano per indicare una finestra chiusa o un iframe rimosso, è buona norma annullare i riferimenti.

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

Soluzione: utilizza WeakRef

Di recente, JavaScript ha ottenuto il supporto di un nuovo modo per fare riferimento agli oggetti che consente di eseguire la raccolta del garbage, chiamato WeakRef. Un 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 a condizione che non sia stato sottoposto a garbage collection. Con WeakRef è possibile accedere al valore corrente di una finestra o di un documento, consentendo al contempo il relativo recupero. Anziché conservare un riferimento alla finestra che deve essere deselezionato manualmente in risposta a eventi come pagehide o proprietà come window.closed, l'accesso alla finestra viene ottenuto in base alle esigenze. Quando la finestra è chiusa, può essere eseguita la raccolta dei rifiuti, il che fa sì che il metodo .deref() inizi 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 accade perché WeakRef continua a restituire un valore fino a quando l'oggetto associato non viene sottoposto al garbage collection, il che avviene in modo asincrono in JavaScript e in genere durante il tempo di inattività. Fortunatamente, quando controlli la presenza di finestre scollegate nel riquadro Memoria di Chrome DevTools, l'acquisizione di un istantanea dell'heap attiva effettivamente la raccolta dei rifiuti e gestisce la finestra con riferimento debole. È 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 tramite postMessage

Il rilevamento della chiusura delle finestre o dello svuotamento di un documento durante la navigazione ci consente di rimuovere i gestori e annullare i riferimenti in modo che le finestre scollegate possano essere sottoposte al garbage collection. Tuttavia, queste modifiche sono correzioni specifiche per un problema a volte più fondamentale: il collegamento diretto tra le pagine.

È disponibile un approccio alternativo più olistico che evita riferimenti obsoleti tra finestre e documenti: stabilire la separazione limitando la comunicazione tra documenti a postMessage(). Tornando all'esempio originale delle note del presentatore, funzioni come nextSlide() aggiornavano direttamente la finestra delle note facendovi riferimento e manipolando i relativi contenuti. Invece, 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;

Anche se questo richiede ancora che le finestre facciano riferimento l'una all'altra, nessuna delle due conserva un riferimento al documento corrente da un'altra finestra. Un approccio di trasmissione di messaggi incoraggia anche i progetti in cui i riferimenti alle finestre vengono conservati in un unico posto, il che significa che è sufficiente impostare su un valore vuoto un solo riferimento quando le finestre vengono chiuse o si passa ad altre pagine. Nell'esempio riportato sopra, solo showNotes() conserva un riferimento alla finestra delle note e utilizza l'evento pagehide per assicurarsi che il riferimento venga ripulito.

Soluzione: evita i riferimenti che utilizzano noopener

Se viene aperta una finestra popup con cui la tua pagina non deve comunicare o che non deve controllare, potresti riuscire a evitare di ottenere un riferimento alla finestra. Questo è particolarmente utile quando crei finestre o iframe che caricano contenuti da un altro sito. In questi casi, window.open() accetta un'opzione "noopener" che funziona esattamente 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 memorizzare accidentalmente un riferimento al popup. Inoltre, impedisce alla finestra popup di ottenere un riferimento alla finestra principale, poiché la proprietà window.opener sarà null.

Feedback

Ci auguriamo che alcuni dei suggerimenti riportati in questo articolo ti aiutino a trovare e correggere le perdite di memoria. Se hai un'altra tecnica per il debug delle finestre scollegate o se questo articolo ti ha aiutato a scoprire perdite nella tua app, non esitare a contattarmi. Puoi trovarmi su Twitter all'indirizzo @_developit.