Kebocoran memori jendela yang terpisah

Menemukan dan memperbaiki kebocoran memori yang rumit yang disebabkan oleh jendela yang terlepas.

Bartek Nowierski
Bartek Nowierski

Apa yang dimaksud dengan kebocoran memori di JavaScript?

Kebocoran memori adalah peningkatan jumlah memori yang tidak disengaja yang digunakan oleh aplikasi dari waktu ke waktu. Di JavaScript, kebocoran memori terjadi saat objek tidak lagi diperlukan, tetapi masih direferensikan oleh fungsi atau objek lainnya. Referensi ini mencegah objek yang tidak diperlukan diambil kembali oleh pembersih sampah memori.

Tugas pembersih sampah memori adalah mengidentifikasi dan mengklaim kembali objek yang tidak dapat dijangkau lagi dari aplikasi. Hal ini berfungsi bahkan saat objek mereferensikan dirinya sendiri, atau saling mereferensikan secara siklus–setelah tidak ada referensi yang tersisa yang dapat digunakan aplikasi untuk mengakses sekelompok objek, objek tersebut dapat dibersihkan sampah memorinya.

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.

Kelas kebocoran memori yang sangat rumit terjadi saat aplikasi mereferensikan objek yang memiliki siklus prosesnya sendiri, seperti elemen DOM atau jendela pop-up. Jenis objek ini dapat menjadi tidak digunakan tanpa sepengetahuan aplikasi, yang berarti kode aplikasi mungkin memiliki satu-satunya referensi yang tersisa ke objek yang dapat di-garbage collection.

Apa yang dimaksud dengan jendela terpisah?

Pada contoh berikut, aplikasi penampil slide menyertakan tombol untuk membuka dan menutup pop-up catatan presenter. Bayangkan pengguna mengklik Show Notes, lalu langsung menutup jendela pop-up, bukan mengklik tombol Hide Notes–variabel notesWindow masih menyimpan referensi ke pop-up yang dapat diakses, meskipun pop-up tidak lagi digunakan.

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

Ini adalah contoh jendela yang terpisah. Jendela pop-up ditutup, tetapi kode kami memiliki referensi ke jendela tersebut yang mencegah browser menghancurkannya dan mengambil kembali memori tersebut.

Saat halaman memanggil window.open() untuk membuat jendela atau tab browser baru, objek Window akan ditampilkan yang mewakili jendela atau tab. Bahkan setelah jendela tersebut ditutup atau pengguna menutupnya, objek Window yang ditampilkan dari window.open() masih dapat digunakan untuk mengakses informasi tentangnya. Ini adalah salah satu jenis jendela terpisah: karena kode JavaScript masih berpotensi mengakses properti pada objek Window yang tertutup, kode tersebut harus disimpan dalam memori. Jika jendela menyertakan banyak objek JavaScript atau iframe, memori tersebut tidak dapat diambil kembali hingga tidak ada lagi referensi JavaScript yang tersisa ke properti jendela.

Menggunakan Chrome DevTools untuk menunjukkan cara mempertahankan dokumen setelah jendela ditutup.

Masalah yang sama juga dapat terjadi saat menggunakan elemen <iframe>. Iframe berperilaku seperti jendela bertingkat yang berisi dokumen, dan properti contentWindow-nya memberikan akses ke objek Window yang ada, seperti nilai yang ditampilkan oleh window.open(). Kode JavaScript dapat menyimpan referensi ke contentWindow atau contentDocument iframe meskipun iframe dihapus dari DOM atau URL-nya berubah, yang mencegah dokumen dikumpulkan sampahnya karena propertinya masih dapat diakses.

Demonstrasi cara pengendali peristiwa dapat mempertahankan dokumen iframe, bahkan setelah membuka iframe ke URL yang berbeda.

Jika referensi ke document dalam jendela atau iframe dipertahankan dari JavaScript, dokumen tersebut akan disimpan dalam memori meskipun jendela atau iframe yang berisinya membuka URL baru. Hal ini dapat sangat merepotkan jika JavaScript yang menyimpan referensi tersebut tidak mendeteksi bahwa jendela/bingkai telah membuka URL baru, karena tidak tahu kapan menjadi referensi terakhir yang menyimpan dokumen dalam memori.

Cara jendela yang terlepas menyebabkan kebocoran memori

Saat menggunakan jendela dan iframe di domain yang sama dengan halaman utama, biasanya Anda memproses peristiwa atau mengakses properti di seluruh batas dokumen. Misalnya, mari kita lihat kembali variasi pada contoh penampil presentasi dari awal panduan ini. Penampil akan membuka jendela kedua untuk menampilkan catatan pembicara. Jendela catatan pembicara memproses peristiwa click sebagai isyarat untuk berpindah ke slide berikutnya. Jika pengguna menutup jendela catatan ini, JavaScript yang berjalan di jendela induk asli masih memiliki akses penuh ke dokumen catatan pembicara:

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

Bayangkan kita menutup jendela browser yang dibuat oleh showNotes() di atas. Tidak ada pengendali peristiwa yang memproses untuk mendeteksi bahwa jendela telah ditutup, sehingga tidak ada yang memberi tahu kode kita bahwa kode tersebut harus membersihkan referensi apa pun ke dokumen. Fungsi nextSlide() masih "aktif" karena diikat sebagai pengendali klik di halaman utama, dan fakta bahwa nextSlide berisi referensi ke notesWindow berarti jendela masih direferensikan dan tidak dapat dikumpulkan sampahnya.

Ilustrasi cara referensi ke jendela mencegahnya di-garbage collection setelah ditutup.

Ada sejumlah skenario lain saat referensi tidak sengaja dipertahankan yang mencegah jendela terpisah memenuhi syarat untuk pengumpulan sampah:

  • Pengendali peristiwa dapat didaftarkan di dokumen awal iframe sebelum frame membuka URL yang diinginkan, sehingga menyebabkan referensi yang tidak disengaja ke dokumen dan iframe tetap ada setelah referensi lain dibersihkan.

  • Dokumen yang berat memorinya dimuat di jendela atau iframe dapat secara tidak sengaja disimpan dalam memori lama setelah membuka URL baru. Hal ini sering kali disebabkan oleh halaman induk yang mempertahankan referensi ke dokumen untuk memungkinkan penghapusan pemroses.

  • Saat meneruskan objek JavaScript ke jendela atau iframe lain, rantai prototipe Objek menyertakan referensi ke lingkungan tempat objek dibuat, termasuk jendela yang membuatnya. Artinya, sama pentingnya untuk menghindari menyimpan referensi ke objek dari jendela lain seperti menghindari menyimpan referensi ke jendela itu sendiri.

    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>
    

Mendeteksi kebocoran memori yang disebabkan oleh jendela yang terlepas

Melacak kebocoran memori bisa jadi sulit. Sering kali sulit untuk membuat reproduksi terpisah dari masalah ini, terutama jika beberapa dokumen atau jendela terlibat. Untuk mempersulit hal-hal, memeriksa potensi kebocoran referensi dapat berakhir dengan membuat referensi tambahan yang mencegah objek yang diperiksa dari pengumpulan sampah. Untuk itu, sebaiknya mulai dengan alat yang secara khusus menghindari kemungkinan ini.

Tempat yang tepat untuk mulai men-debug masalah memori adalah dengan mengambil snapshot heap. Hal ini memberikan tampilan titik waktu ke memori yang saat ini digunakan oleh aplikasi - semua objek yang telah dibuat tetapi belum di-garbage collection. Snapshot heap berisi informasi yang berguna tentang objek, termasuk ukurannya dan daftar variabel dan penutupan yang mereferensikannya.

Screenshot snapshot heap di Chrome DevTools yang menampilkan referensi yang mempertahankan objek besar.
Ringkasan heap yang menampilkan referensi yang mempertahankan objek besar.

Untuk merekam snapshot heap, buka tab Memory di Chrome DevTools, lalu pilih Heap Snapshot dalam daftar jenis pembuatan profil yang tersedia. Setelah perekaman selesai, tampilan Ringkasan akan menampilkan objek saat ini dalam memori, yang dikelompokkan menurut konstruktor.

Demonstrasi pengambilan snapshot heap di Chrome DevTools.

Menganalisis dump heap bisa menjadi tugas yang berat, dan cukup sulit untuk menemukan informasi yang tepat sebagai bagian dari proses debug. Untuk membantu hal ini, engineer Chromium yossik@ dan peledni@ mengembangkan alat Pembersih Heap mandiri yang dapat membantu menandai node tertentu seperti jendela terpisah. Menjalankan Heap Cleaner pada rekaman aktivitas akan menghapus informasi lain yang tidak diperlukan dari grafik retensi, sehingga rekaman aktivitas menjadi lebih bersih dan jauh lebih mudah dibaca.

Mengukur memori secara terprogram

Snapshot heap memberikan tingkat detail yang tinggi dan sangat cocok untuk mengetahui tempat kebocoran terjadi, tetapi mengambil snapshot heap adalah proses manual. Cara lain untuk memeriksa kebocoran memori adalah dengan mendapatkan ukuran heap JavaScript yang saat ini digunakan dari performance.memory API:

Screenshot bagian antarmuka pengguna Chrome DevTools.
Memeriksa ukuran heap JS yang digunakan di DevTools saat pop-up dibuat, ditutup, dan tidak direferensikan.

performance.memory API hanya memberikan informasi tentang ukuran heap JavaScript, yang berarti tidak menyertakan memori yang digunakan oleh dokumen dan resource pop-up. Untuk mendapatkan gambaran lengkap, kita harus menggunakan performance.measureUserAgentSpecificMemory() API baru yang saat ini sedang diuji coba di Chrome.

Solusi untuk menghindari kebocoran jendela yang terlepas

Dua kasus paling umum saat jendela yang terpisah menyebabkan kebocoran memori adalah saat dokumen induk mempertahankan referensi ke pop-up yang ditutup atau iframe yang dihapus, dan saat navigasi jendela atau iframe yang tidak terduga menyebabkan pengendali peristiwa tidak pernah dihapus pendaftarannya.

Contoh: Menutup pop-up

Dalam contoh berikut, dua tombol digunakan untuk membuka dan menutup jendela pop-up. Agar tombol Close Popup berfungsi, referensi ke jendela pop-up yang dibuka disimpan dalam variabel:

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

Sekilas, kode di atas tampaknya menghindari kesalahan umum: tidak ada referensi ke dokumen pop-up yang dipertahankan, dan tidak ada pengendali peristiwa yang terdaftar di jendela pop-up. Namun, setelah tombol Open Popup diklik, variabel popup kini mereferensikan jendela yang terbuka, dan variabel tersebut dapat diakses dari cakupan pengendali klik tombol Close Popup. Kecuali jika popup diatribusikan ulang atau pengendali klik dihapus, referensi yang disertakan pengendali tersebut ke popup berarti tidak dapat di-garbage collection.

Solusi: Hapus penetapan referensi

Variabel yang mereferensikan jendela lain atau dokumennya menyebabkan variabel tersebut dipertahankan dalam memori. Karena objek dalam JavaScript selalu merupakan referensi, menetapkan nilai baru ke variabel akan menghapus referensinya ke objek asli. Untuk "menghapus" referensi ke objek, kita dapat menetapkan ulang variabel tersebut ke nilai null.

Dengan menerapkannya ke contoh pop-up sebelumnya, kita dapat mengubah pengendali tombol tutup agar "tidak menetapkan" referensinya ke jendela pop-up:

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

Hal ini membantu, tetapi mengungkapkan masalah lebih lanjut khusus untuk jendela yang dibuat menggunakan open(): bagaimana jika pengguna menutup jendela, bukan mengklik tombol tutup kustom? Lebih jauh lagi, bagaimana jika pengguna mulai menjelajahi situs lain di jendela yang kita buka? Meskipun awalnya tampaknya cukup untuk menghapus setelan referensi popup saat mengklik tombol tutup, masih ada kebocoran memori saat pengguna tidak menggunakan tombol tertentu untuk menutup jendela. Untuk mengatasinya, Anda harus mendeteksi kasus ini agar dapat menetapkan ulang referensi yang tertinggal saat terjadi.

Solusi: Memantau dan membuang

Dalam banyak situasi, JavaScript yang bertanggung jawab untuk membuka jendela atau membuat bingkai tidak memiliki kontrol eksklusif atas siklus prosesnya. Pop-up dapat ditutup oleh pengguna, atau navigasi ke dokumen baru dapat menyebabkan dokumen yang sebelumnya berada dalam jendela atau bingkai menjadi terlepas. Dalam kedua kasus tersebut, browser akan memicu peristiwa pagehide untuk menandakan bahwa dokumen sedang di-unload.

Peristiwa pagehide dapat digunakan untuk mendeteksi jendela yang ditutup dan navigasi dari dokumen saat ini. Namun, ada satu peringatan penting: semua jendela dan iframe yang baru dibuat berisi dokumen kosong, lalu secara asinkron membuka URL yang diberikan jika disediakan. Akibatnya, peristiwa pagehide awal diaktifkan segera setelah membuat jendela atau bingkai, tepat sebelum dokumen target dimuat. Karena kode pembersihan referensi perlu dijalankan saat dokumen target di-unload, kita harus mengabaikan peristiwa pagehide pertama ini. Ada sejumlah teknik untuk melakukannya, yang paling sederhana adalah mengabaikan peristiwa pagehide yang berasal dari URL about:blank dokumen awal. Berikut tampilannya di contoh pop-up:

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

Penting untuk diperhatikan bahwa teknik ini hanya berfungsi untuk jendela dan bingkai yang memiliki origin efektif yang sama dengan halaman induk tempat kode kita berjalan. Saat memuat konten dari origin yang berbeda, peristiwa location.host dan pagehide tidak tersedia karena alasan keamanan. Meskipun sebaiknya hindari menyimpan referensi ke origin lain, dalam kasus yang jarang terjadi, jika hal ini diperlukan, Anda dapat memantau properti window.closed atau frame.isConnected. Jika properti ini berubah untuk menunjukkan jendela yang ditutup atau iframe yang dihapus, sebaiknya jangan tetapkan referensi apa pun ke jendela atau iframe tersebut.

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

Solusi: Gunakan WeakRef

JavaScript baru-baru ini mendapatkan dukungan untuk cara baru mereferensikan objek yang memungkinkan pembersihan sampah memori terjadi, yang disebut WeakRef. WeakRef yang dibuat untuk objek bukanlah referensi langsung, tetapi merupakan objek terpisah yang menyediakan metode .deref() khusus yang menampilkan referensi ke objek selama belum dihapus oleh pembersihan sampah memori. Dengan WeakRef, Anda dapat mengakses nilai jendela atau dokumen saat ini sekaligus tetap mengizinkan pengumpulan sampah. Daripada mempertahankan referensi ke jendela yang harus dibatalkan secara manual sebagai respons terhadap peristiwa seperti pagehide atau properti seperti window.closed, akses ke jendela diperoleh sesuai kebutuhan. Saat ditutup, jendela dapat di-garbage collection, sehingga metode .deref() mulai menampilkan 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>

Satu detail menarik yang perlu dipertimbangkan saat menggunakan WeakRef untuk mengakses jendela atau dokumen adalah bahwa referensi umumnya tetap tersedia selama jangka waktu singkat setelah jendela ditutup atau iframe dihapus. Hal ini karena WeakRef terus menampilkan nilai hingga objek terkaitnya telah di-garbage collection, yang terjadi secara asinkron di JavaScript dan umumnya selama waktu tidak ada aktivitas. Untungnya, saat memeriksa jendela yang terpisah di panel Memory Chrome DevTools, mengambil snapshot heap sebenarnya memicu pengumpulan sampah dan membuang jendela yang direferensikan secara lemah. Anda juga dapat memeriksa apakah objek yang direferensikan melalui WeakRef telah dihapus dari JavaScript, baik dengan mendeteksi kapan deref() menampilkan undefined atau menggunakan FinalizationRegistry API baru:

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

Solusi: Berkomunikasi melalui postMessage

Mendeteksi kapan jendela ditutup atau navigasi memuat ulang dokumen memberi kita cara untuk menghapus pengendali dan menghapus penetapan referensi sehingga jendela yang dilepas dapat dihapus sampahnya. Namun, perubahan ini adalah perbaikan khusus untuk masalah yang terkadang lebih mendasar: pengaitan langsung antara halaman.

Tersedia pendekatan alternatif yang lebih menyeluruh yang menghindari referensi yang sudah tidak berlaku antara jendela dan dokumen: menetapkan pemisahan dengan membatasi komunikasi lintas dokumen ke postMessage(). Ingat kembali contoh catatan presenter asli kita, fungsi seperti nextSlide() memperbarui jendela catatan secara langsung dengan mereferensikannya dan memanipulasi kontennya. Sebagai gantinya, halaman utama dapat meneruskan informasi yang diperlukan ke jendela catatan secara asinkron dan tidak langsung melalui 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;

Meskipun hal ini masih mengharuskan jendela untuk saling mereferensikan, keduanya tidak mempertahankan referensi ke dokumen saat ini dari jendela lain. Pendekatan penerusan pesan juga mendorong desain dengan referensi jendela yang disimpan di satu tempat, yang berarti hanya satu referensi yang perlu dibatalkan penetapannya saat jendela ditutup atau beralih. Pada contoh di atas, hanya showNotes() yang mempertahankan referensi ke jendela catatan, dan menggunakan peristiwa pagehide untuk memastikan referensi tersebut dihapus.

Solusi: Hindari referensi yang menggunakan noopener

Jika jendela pop-up dibuka dan halaman Anda tidak perlu berkomunikasi atau mengontrolnya, Anda mungkin dapat menghindari mendapatkan referensi ke jendela tersebut. Hal ini sangat berguna saat membuat jendela atau iframe yang akan memuat konten dari situs lain. Untuk kasus ini, window.open() menerima opsi "noopener" yang berfungsi seperti atribut rel="noopener" untuk link HTML:

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

Opsi "noopener" menyebabkan window.open() menampilkan null, sehingga tidak mungkin menyimpan referensi ke pop-up secara tidak sengaja. Hal ini juga mencegah jendela pop-up mendapatkan referensi ke jendela induknya, karena properti window.opener akan menjadi null.

Masukan

Semoga beberapa saran dalam artikel ini membantu Anda menemukan dan memperbaiki kebocoran memori. Jika Anda memiliki teknik lain untuk men-debug jendela yang terpisah atau artikel ini membantu menemukan kebocoran di aplikasi Anda, beri tahu kami. Anda dapat menemukan saya di Twitter @_developit.