Kebocoran memori jendela yang terpisah

Temukan dan perbaiki kebocoran memori rumit yang disebabkan oleh jendela yang terputus.

Bartek Nowierski
Bartek Nowierski

Apa yang dimaksud dengan kebocoran memori di JavaScript?

Kebocoran memori adalah peningkatan jumlah memori yang digunakan oleh aplikasi secara tidak sengaja dari waktu ke waktu. Dalam JavaScript, kebocoran memori terjadi saat objek tidak lagi diperlukan, tetapi masih direferensikan oleh fungsi atau objek lain. Referensi ini mencegah objek yang tidak diperlukan agar tidak diklaim kembali oleh pembersih sampah memori.

Tugas pembersih sampah memori adalah mengidentifikasi dan mengklaim kembali objek yang tidak lagi dapat dijangkau dari aplikasi. Ini dapat diterapkan meskipun objek mereferensikan dirinya sendiri, atau secara siklis mereferensikan satu sama lain–setelah tidak ada lagi referensi yang dapat digunakan aplikasi untuk mengakses sekelompok objek, aplikasi 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.

Class 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 diketahui aplikasi. Artinya, kode aplikasi mungkin memiliki satu-satunya referensi tersisa ke objek yang seharusnya dapat dibersihkan sampah memori.

Apa itu jendela terpisah?

Dalam contoh berikut, aplikasi penampil slideshow menyertakan tombol untuk membuka dan menutup pop-up catatan presenter. Bayangkan pengguna mengklik Show Notes, lalu menutup jendela pop-up secara langsung, bukan mengklik tombol Hide Notes, variabel notesWindow masih menyimpan referensi ke pop-up yang dapat diakses, meskipun pop-up tersebut 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 terlepas. Jendela pop-up ditutup, tetapi kode kita memiliki referensi yang mencegah browser menghancurkannya dan mengklaim kembali memori tersebut.

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

Menggunakan Chrome DevTools untuk menunjukkan bagaimana mungkin 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 di dalamnya, seperti nilai yang ditampilkan oleh window.open(). Kode JavaScript dapat mempertahankan referensi ke contentWindow atau contentDocument iframe meskipun iframe dihapus dari DOM atau URL-nya berubah, yang mencegah dokumen dibersihkan dari sampah karena propertinya masih dapat diakses.

Demonstrasi tentang bagaimana 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 di memori meskipun jendela atau iframe yang memuatnya membuka URL baru. Hal ini dapat sangat merepotkan saat JavaScript yang menyimpan referensi tersebut tidak mendeteksi bahwa jendela/frame telah mengarah ke URL baru, karena tidak tahu kapan JavaScript menjadi referensi terakhir yang menyimpan dokumen dalam memori.

Cara jendela yang terputus menyebabkan kebocoran memori

Saat menggunakan jendela dan iframe di domain yang sama dengan halaman utama, biasanya peristiwa atau akses properti di seluruh batas dokumen akan diproses. Sebagai contoh, mari kita lihat kembali variasi {i>presentasi penampil presentasi <i}dari awal panduan ini. Penampil akan membuka jendela kedua untuk menampilkan catatan pembicara. Jendela catatan pembicara memproses peristiwa click sebagai isyaratnya 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 mendeteksi bahwa jendela telah ditutup, sehingga tidak ada yang memberi tahu kode kita bahwa jendela tersebut harus membersihkan referensi apa pun ke dokumen. Fungsi nextSlide() masih "aktif" karena terikat sebagai pengendali klik di halaman utama, dan fakta bahwa nextSlide berisi referensi ke notesWindow berarti jendela masih direferensikan dan tidak dapat dibersihkan sampah memori.

Ilustrasi tentang cara referensi ke jendela mencegah pembersihan sampah memori setelah ditutup.

Ada sejumlah skenario lain saat referensi secara tidak sengaja disimpan sehingga mencegah jendela yang terpisah memenuhi syarat untuk pembersihan sampah memori:

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

  • Dokumen dengan banyak memori yang dimuat di jendela atau iframe dapat tersimpan secara tidak sengaja dalam memori lama setelah membuka URL baru. Hal ini sering kali disebabkan oleh halaman induk yang mempertahankan referensi ke dokumen agar pemroses dapat dihapus.

  • Saat meneruskan objek JavaScript ke jendela atau iframe lain, rantai prototipe Objek tersebut menyertakan referensi ke lingkungan pembuatannya, termasuk jendela yang membuatnya. Artinya, menghindari memegang referensi ke objek dari jendela lain sama pentingnya dengan menghindari 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 terputus

Melacak kebocoran memori bisa menjadi hal yang rumit. Membuat reproduksi masalah ini sering kali sulit dilakukan, terutama jika melibatkan beberapa dokumen atau jendela. Untuk membuatnya lebih rumit, memeriksa potensi referensi yang bocor dapat menghasilkan referensi tambahan yang mencegah pembersihan sampah memori objek yang diperiksa. Untuk itu, ada baiknya memulai dengan alat yang secara khusus menghindari timbulnya kemungkinan ini.

Tempat yang tepat untuk memulai proses debug masalah memori adalah dengan mengambil snapshot heap. Fungsi ini memberikan tampilan point-in-time ke dalam memori yang saat ini digunakan oleh aplikasi - semua objek yang telah dibuat, tetapi belum dibersihkan sampah memorinya. Snapshot heap berisi informasi yang berguna tentang objek, termasuk ukurannya serta daftar variabel dan penutupan yang mereferensikannya.

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

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

Demonstrasi pengambilan cuplikan heap di Chrome DevTools.

Menganalisis heap dump dapat menjadi tugas yang berat, dan mungkin cukup sulit untuk menemukan informasi yang tepat sebagai bagian dari proses debug. Untuk membantu menangani hal ini, engineer Chromium yossik@ dan peledni@ mengembangkan alat Heap Cleaner 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 membuat rekaman aktivitas lebih bersih dan lebih mudah dibaca.

Mengukur memori secara terprogram

Snapshot heap memberikan detail tingkat tinggi dan sangat cocok untuk mencari tahu tempat terjadinya kebocoran, tetapi pengambilan snapshot heap merupakan 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 bahwa memori yang digunakan oleh dokumen dan resource pop-up tidak disertakan. 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 terputus

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

Contoh: Menutup pop-up

Pada contoh berikut, dua tombol digunakan untuk membuka dan menutup jendela pop-up. Agar tombol Close Popup berfungsi, referensi ke jendela pop-up yang terbuka 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>

Secara sekilas, sepertinya kode di atas 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 sekarang akan mereferensikan jendela yang terbuka, dan variabel tersebut dapat diakses dari cakupan pengendali klik tombol Close Popup. Kecuali jika popup ditetapkan ulang atau pengendali klik dihapus, referensi tertutup pengendali ke popup berarti tidak dapat dibersihkan sampah memori.

Solusi: Batalkan penetapan referensi

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

Dengan menerapkan ini ke contoh pop-up sebelumnya, kita dapat memodifikasi pengendali tombol tutup untuk membuatnya "membatalkan penetapan" referensinya ke jendela pop-up:

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

Cara ini membantu, tetapi menampilkan masalah lebih lanjut khusus untuk jendela yang dibuat menggunakan open(): bagaimana jika pengguna menutup jendela, bukan mengklik tombol tutup khusus? Lebih jauh lagi, bagaimana jika pengguna mulai menjelajahi situs web lain di jendela yang kita buka? Meskipun awalnya tampaknya cukup untuk membatalkan penetapan referensi popup saat mengklik tombol tutup, masih ada kebocoran memori ketika pengguna tidak menggunakan tombol tertentu untuk menutup jendela. Untuk menyelesaikan masalah ini, Anda harus mendeteksi kasus ini agar dapat mengganggu referensi yang masih ada saat terjadi.

Solusi: Pantau dan buang

Dalam banyak situasi, JavaScript yang bertanggung jawab untuk membuka jendela atau membuat frame tidak memiliki kontrol eksklusif atas siklus prosesnya. Pop-up dapat ditutup oleh pengguna, atau navigasi ke dokumen baru dapat menyebabkan dokumen yang sebelumnya terdapat dalam jendela atau frame terlepas. Dalam kedua kasus, browser mengaktifkan peristiwa pagehide untuk menandakan bahwa dokumen sedang dihapus muatannya.

Peristiwa pagehide dapat digunakan untuk mendeteksi jendela yang ditutup dan navigasi keluar dari dokumen saat ini. Namun, ada satu peringatan penting: semua jendela dan iframe yang baru dibuat berisi dokumen kosong, lalu membuka URL yang diberikan secara asinkron jika disediakan. Akibatnya, peristiwa pagehide awal diaktifkan sesaat setelah membuat jendela atau frame, tepat sebelum dokumen target dimuat. Karena kode pembersihan referensi harus dijalankan saat dokumen target dihapus muatannya, 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 adalah tampilannya dalam 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 frame yang memiliki origin yang efektif yang sama dengan halaman induk tempat kode kita dijalankan. Saat memuat konten dari origin yang berbeda, baik peristiwa location.host maupun pagehide tidak tersedia karena alasan keamanan. Meskipun secara umum sebaiknya hindari menyimpan referensi ke asal lain, dalam kasus yang jarang terjadi saat hal ini diperlukan, Anda dapat memantau properti window.closed atau frame.isConnected. Jika properti ini berubah untuk menunjukkan jendela yang tertutup atau iframe yang dihapus, sebaiknya batalkan penetapan referensi ke properti 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 berlangsung, yang disebut WeakRef. WeakRef yang dibuat untuk suatu objek bukanlah referensi langsung, melainkan objek terpisah yang menyediakan metode .deref() khusus yang menampilkan referensi ke objek tersebut selama objek tersebut belum dibersihkan sampah memorinya. Dengan WeakRef, Anda dapat mengakses nilai jendela atau dokumen saat ini sambil tetap mengizinkannya untuk dibersihkan. Alih-alih mempertahankan referensi ke jendela yang harus dibatalkan penetapannya secara manual sebagai respons terhadap peristiwa seperti pagehide atau properti seperti window.closed, akses ke jendela diperoleh sesuai kebutuhan. Saat jendela ditutup, sampah dapat dibersihkan, yang menyebabkan 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 dalam waktu singkat setelah jendela ditutup atau iframe dihapus. Ini karena WeakRef terus menampilkan nilai hingga objek terkaitnya telah dibersihkan sampah memorinya, yang terjadi secara asinkron di JavaScript dan umumnya selama waktu tidak ada aktivitas. Untungnya, saat memeriksa jendela yang terputus di panel Memory Chrome DevTools, mengambil snapshot heap sebenarnya akan memicu pembersihan sampah memori dan membuang jendela yang direferensikan dengan lemah. Anda juga dapat memeriksa apakah objek yang direferensikan melalui WeakRef telah dibuang dari JavaScript, baik dengan mendeteksi saat deref() menampilkan undefined atau menggunakan FinalizationRegistry API yang 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 saat jendela ditutup atau navigasi menghapus muatan dokumen memberi kita cara untuk menghapus pengendali dan membatalkan penetapan referensi sehingga jendela yang dilepas dapat dibersihkan sampah memori. Namun, perubahan ini adalah perbaikan khusus untuk hal yang terkadang menjadi masalah yang lebih mendasar: pengaitan langsung antar-halaman.

Pendekatan alternatif yang lebih menyeluruh tersedia untuk menghindari referensi tidak berlaku antara jendela dan dokumen: membuat pemisahan dengan membatasi komunikasi lintas dokumen ke postMessage(). Dengan mengingat 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, tidak ada yang mempertahankan referensi ke dokumen saat ini dari jendela lain. Pendekatan penerusan pesan juga mendorong desain di mana referensi jendela disimpan di satu tempat, yang berarti hanya satu referensi yang perlu dibatalkan penetapannya saat jendela ditutup atau keluar. Dalam contoh di atas, hanya showNotes() yang mempertahankan referensi ke jendela catatan, dan menggunakan peristiwa pagehide untuk memastikan bahwa referensi dibersihkan.

Solusi: Hindari referensi menggunakan noopener

Jika jendela pop-up terbuka sehingga halaman Anda tidak perlu melakukan komunikasi atau mengontrolnya, Anda mungkin dapat menghindari 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 yang membantu mengungkap kebocoran di aplikasi, saya ingin mengetahuinya. Anda dapat menemukan saya di Twitter @_developit.