Ayrılan pencere bellek sızıntıları

Ayrılmış pencerelerden kaynaklanan zor bellek sızıntılarını bulup düzeltin.

Bartek Nowierski
Bartek Nowierski

JavaScript'de bellek sızıntısı nedir?

Bellek sızıntısı, bir uygulamanın zaman içinde kullandığı bellek miktarında istenmeden gerçekleşen bir artıştır. JavaScript'de, nesnelere artık ihtiyaç duyulmamasına rağmen işlevler veya diğer nesneler tarafından referans verildiğinde bellek sızıntısı meydana gelir. Bu referanslar, gereksiz nesnelerin çöp toplayıcı tarafından yeniden kullanılmasını engeller.

Çöp toplayıcının görevi, artık uygulamadan erişilemeyen nesneleri tanımlamak ve geri almaktır. Bu, nesneler kendilerine referans verdiğinde veya birbirlerine döngüsel olarak referans verdiğinde bile işe yarar. Bir uygulamanın bir nesne grubuna erişebileceği referanslar kalmadığında, bu nesne grubu çöp toplanabilir.

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.

Özellikle de bir uygulama, DOM öğeleri veya pop-up pencereler gibi kendi yaşam döngüsü olan nesnelere referans verdiğinde ortaya çıkan hafıza sızıntısı sınıfı oldukça can sıkıcı olabilir. Bu tür nesnelerin, uygulamanın haberi olmadan kullanılmaz hale gelmesi mümkündür. Bu, aksi takdirde çöp toplanabilir olan bir nesneye ilişkin kalan tek referansın uygulama kodunda olabileceği anlamına gelir.

Ayrık pencere nedir?

Aşağıdaki örnekte, slayt gösterisi görüntüleyici uygulamasında sunucu notları pop-up'ını açma ve kapatma düğmeleri bulunmaktadır. Bir kullanıcının Notları Göster'i tıkladığını ve ardından Notları Gizle düğmesini tıklamak yerine doğrudan pop-up pencereyi kapattığını varsayalım. notesWindow değişkeni, pop-up artık kullanılmasa bile pop-upa erişilebilecek bir referans tutar.

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

Bu, ayrılmış bir pencere örneğidir. Pop-up pencere kapatılmıştı ancak kodumuzda, tarayıcının pencereyi silemesini ve belleği geri almasını engelleyen bir referans vardı.

Bir sayfa yeni bir tarayıcı penceresi veya sekmesi oluşturmak için window.open() işlevini çağrdığında, pencereyi veya sekmeyi temsil eden bir Window nesnesi döndürülür. Bu tür bir pencere kapatıldıktan veya kullanıcı pencereden ayrıldıktan sonra bile, window.open() tarafından döndürülen Window nesnesi, pencereyle ilgili bilgilere erişmek için kullanılabilir. Bu, ayrılmış pencere türlerinden biridir: JavaScript kodu, kapalı Window nesnesinde bulunan özelliklere hâlâ erişebileceğinden bu nesne bellekte tutulmalıdır. Pencere çok fazla JavaScript nesnesi veya iframe içeriyorsa pencerenin özelliklerine yönelik JavaScript referansları kalmayana kadar bu bellek geri alınamaz.

Bir pencere kapatıldıktan sonra bir dokümanı nasıl tutabileceğinizi göstermek için Chrome Geliştirici Araçları'nı kullanma.

Aynı sorun, <iframe> öğeleri kullanılırken de ortaya çıkabilir. Iframe'ler, doküman içeren iç içe yerleştirilmiş pencereler gibi davranır ve contentWindow özellikleri, window.open() tarafından döndürülen değere benzer şekilde, kapsanan Window nesnesine erişim sağlar. JavaScript kodu, iframe DOM'dan kaldırılsa veya URL'si değişse bile iframe'ın contentWindow veya contentDocument öğesine referans tutabilir. Bu sayede, özelliklerine hâlâ erişilebildiği için belgenin çöp toplanması engellenir.

Bir etkinlik işleyicinin, iframe'i farklı bir URL'ye yönlendirdikten sonra bile iframe'in dokümanını nasıl koruyabileceğini gösteren gösterim.

Bir pencere veya iframe içindeki document'ye ait referansın JavaScript'den saklandığı durumlarda, kapsayıcı pencere veya iframe yeni bir URL'ye yönlendirilse bile söz konusu belge bellekte tutulur. Bu durum, özellikle de bu referansı tutan JavaScript, pencerenin/çerçevenin yeni bir URL'ye gittiğini algılamadığında sorun yaratabilir. Çünkü JavaScript, bir dokümanı bellekte tutan son referans olduğunda bunu bilemez.

Ayrılmış pencereler nasıl bellek sızıntısına neden olur?

Birincil sayfayla aynı alandaki pencereler ve iFrame'lerle çalışırken, doküman sınırları genelinde etkinlikleri dinlemek veya mülklere erişmek yaygındır. Örneğin, bu kılavuzun başındaki sunu görüntüleyici örneğindeki bir varyasyonu tekrar gözden geçirelim. İzleyici, konuşmacı notlarını görüntülemek için ikinci bir pencere açar. Konuşmacı notları penceresi, sonraki slayta geçmek içinclick etkinliklerini dinler. Kullanıcı bu notlar penceresini kapatırsa orijinal üst pencerede çalışan JavaScript, konuşmacı notları belgesine tam erişmeye devam eder:

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

Yukarıdaki showNotes() tarafından oluşturulan tarayıcı penceresini kapattığımızı varsayalım. Pencerenin kapatıldığını algılamak için dinleyen bir etkinlik işleyici olmadığından kodumuz, dokümana yapılan tüm referansları temizlemesi gerektiğini hiçbir şekilde öğrenemez. nextSlide() işlevi, ana sayfamızda tıklama işleyicisi olarak bağlı olduğu için hâlâ "etkin" durumdadır. nextSlide işlevinin notesWindow işlevine referans içermesi, pencereye hâlâ referans verildiği ve pencerenin çöp toplanamayacağını gösterir.

Bir pencereye yapılan referansların, pencere kapatıldıktan sonra çöp toplanması işlemini nasıl engellediğini gösteren görsel.

Referansların yanlışlıkla tutularak ayrılmış pencerelerin çöp toplama işlemine uygun olmasını engellediği başka senaryolar da vardır:

  • Etkinlik işleyiciler, çerçevenin amaçlanan URL'ye gitmeden önce bir iframe'in ilk dokümanına kaydedilebilir. Bu da, dokümana ve iframe'e diğer referanslar temizlendikten sonra yanlışlıkla referans verilmesine neden olur.

  • Bir pencereye veya iframe'a yüklenen ve bellek kullanımı yüksek bir doküman, yeni bir URL'ye gidildikten sonra yanlışlıkla bellekte uzun süre tutulabilir. Bu durum genellikle üst sayfanın, dinleyicinin kaldırılmasına izin vermek için dokümana referanslar tutmasından kaynaklanır.

  • Bir JavaScript nesnesi başka bir pencereye veya iFrame'e aktarılırken nesnenin prototip zinciri, onu oluşturan pencere de dahil olmak üzere oluşturulduğu ortama ait referansları içerir. Diğer pencerelerdeki nesnelere referans vermekten kaçınmak, pencerelere referans vermekten kaçınmak kadar önemlidir.

    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>
    

Ayrılmış pencerelerden kaynaklanan bellek sızıntılarını algılama

Bellek sızıntısını tespit etmek zor olabilir. Özellikle birden fazla doküman veya pencere söz konusu olduğunda bu sorunların tek başına yeniden üretilmesi genellikle zordur. Daha da karmaşık hale getirmek gerekirse, olası sızıntıya uğramış referansları incelemek, incelenen nesnelerin çöp toplanması işlemini engelleyen ek referanslar oluşturabilir. Bu nedenle, özellikle bu olasılığı ortadan kaldıran araçlarla başlamak faydalı olacaktır.

Bellek sorunlarını ayıklamaya başlamak için yığın anlık görüntüsü alma iyi bir başlangıç noktasıdır. Bu, bir uygulama tarafından şu anda kullanılan bellekteki (oluşturulan ancak henüz çöp toplamaya tabi tutulmamış tüm nesneler) anlık bir görünüm sağlar. Yığın anlık görüntüleri, nesnelerle ilgili boyutları ve bunlara referans veren değişkenler ile kapatma işlemlerinin listesi gibi yararlı bilgiler içerir.

Chrome Geliştirici Araçları&#39;ndaki bir yığın anlık görüntüsünün ekran görüntüsü. Büyük bir nesneyi tutan referansları gösterir.
Büyük bir nesneyi saklayan referansları gösteren bir yığın anlık görüntüsü.

Bir yığın anlık görüntüsü kaydetmek için Chrome Geliştirici Araçları'ndaki Bellek sekmesine gidin ve mevcut profil oluşturma türleri listesinde Yığın Anlık Görüntüsü'nü seçin. Kayıt tamamlandıktan sonra Özet görünümünde, bellekteki mevcut nesneler oluşturucuya göre gruplandırılmış olarak gösterilir.

Chrome Geliştirici Araçları'nda yığın anlık görüntüsünün alınmasını gösteren gösterim.

Yığın dökümlerini analiz etmek göz korkutucu bir görev olabilir ve hata ayıklama kapsamında doğru bilgileri bulmak oldukça zor olabilir. Chromium mühendisleri yossik@ ve peledni@, bu konuda yardımcı olmak için bağımsız bir Yığın Temizleyici aracı geliştirdi. Bu araç, ayrılmış pencere gibi belirli bir düğümü vurgulamaya yardımcı olabilir. Bir izlemede Yığın Temizleyici'yi çalıştırmak, saklama grafiğinden diğer gereksiz bilgileri kaldırır. Bu sayede izleme daha net ve çok daha kolay okunur hale gelir.

Belleği programatik olarak ölçme

Yığın anlık görüntüleri yüksek düzeyde ayrıntı sağlar ve sızıntıların nerede gerçekleştiğini belirlemek için mükemmeldir ancak yığın anlık görüntüsü almak manuel bir işlemdir. Bellek sızıntısı olup olmadığını kontrol etmenin bir başka yolu da performance.memory API'den şu anda kullanılan JavaScript yığın boyutunu elde etmektir:

Chrome Geliştirici Araçları kullanıcı arayüzünün bir bölümünün ekran görüntüsü.
Bir pop-up oluşturulurken, kapatılırken ve referansı kaldırılırken DevTools'da kullanılan JS yığın boyutunu kontrol etme.

performance.memory API yalnızca JavaScript yığın boyutu hakkında bilgi sağlar. Diğer bir deyişle, pop-up'ın dokümanı ve kaynakları tarafından kullanılan belleği içermez. Tam bir görünüm elde etmek için şu anda Chrome'da deneme aşamasında olan yeni performance.measureUserAgentSpecificMemory() API'yi kullanmamız gerekir.

Pencerelerin ayrılmasından kaynaklanan sızıntıları önleme çözümleri

Bağımsız pencerelerin bellek sızıntılarına neden olduğu en yaygın iki durum, üst belgenin kapalı bir pop-up'a veya kaldırılmış bir iframe'e referanslar tutması ve bir pencerenin veya iframe'in beklenmedik şekilde gezinmesi sonucunda etkinlik işleyicilerin hiçbir zaman kaydedilmemesidir.

Örnek: Pop-up'ı kapatma

Aşağıdaki örnekte, bir pop-up pencereyi açmak ve kapatmak için iki düğme kullanılmaktadır. Pop-up'ı Kapat düğmesinin çalışması için açılan pop-up pencereye ait bir referans bir değişkende saklanır:

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

İlk bakışta, yukarıdaki kodun yaygın hatalardan kaçındığı görülüyor: Pop-up'ın belgesine referans verilmez ve pop-up pencereye etkinlik işleyici kaydedilmez. Ancak Pop-up'ı Aç düğmesi tıklandığında popup değişkeni artık açılan pencereye referans verir ve bu değişkene Pop-up'ı Kapat düğmesi tıklama işleyicisinin kapsamından erişilebilir. popup yeniden atanmadığı veya tıklama işleyicisi kaldırılmadığı sürece, bu işleyicinin popup'e ekli referansı, işleyicinin çöp toplanamayacağını gösterir.

Çözüm: Referansları kaldırın

Başka bir pencereye veya dokümanına referans veren değişkenler, bu pencerenin bellekte tutulmasına neden olur. JavaScript'teki nesneler her zaman referans olduğundan, değişkenlere yeni bir değer atamak, orijinal nesneye olan referanslarını kaldırır. Bir nesneye yapılan referansları "silmek" için bu değişkenleri null değerine yeniden atayabiliriz.

Bunu önceki pop-up örneğine uygulayarak kapat düğmesi işleyicisini, pop-up pencereye referansını "silecek" şekilde değiştirebiliriz:

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

Bu, sorunun çözülmesine yardımcı olsa da open() kullanılarak oluşturulan pencerelere özgü başka bir sorun ortaya çıkar: Kullanıcı, özel kapatma düğmemizi tıklamak yerine pencereyi kapatırsa ne olur? Ayrıca, kullanıcı açtığımız pencerede diğer web sitelerine göz atmaya başlarsa ne olur? Başlangıçta, kapat düğmemiz tıklandığında popup referansının ayarlanmaması yeterli gibi görünse de kullanıcılar pencereyi kapatmak için bu düğmeyi kullanmadığında hâlâ bellek sızıntısı yaşanıyor. Bu sorunu çözmek için, kalan referansların oluştuğu durumlarda bunların ayarlarını kaldırmak amacıyla bu durumların algılanması gerekir.

Çözüm: İzleme ve bertaraf etme

Birçok durumda, pencereleri açan veya çerçeve oluşturan JavaScript'in yaşam döngüsü üzerinde tam kontrolü yoktur. Pop-up'lar kullanıcı tarafından kapatılabilir veya yeni bir dokümana gidildiğinde daha önce bir pencere veya çerçevede bulunan doküman ayrılabilir. Her iki durumda da tarayıcı, dokümanın kaldırıldığını belirtmek için bir pagehide etkinliği tetikler.

pagehide etkinliği, kapalı pencereleri ve geçerli belgeden uzaklaşmayı algılamak için kullanılabilir. Ancak önemli bir uyarı vardır: Yeni oluşturulan tüm pencereler ve iframe'ler boş bir doküman içerir, ardından sağlanmışsa verilen URL'ye eşzamansız olarak gider. Sonuç olarak, pencere veya çerçeve oluşturulduktan kısa bir süre sonra, hedef doküman yüklenmeden hemen önce ilk bir pagehide etkinliği tetiklenir. Referans temizleme kodumuzun hedef belge yüklendiğinde çalışması gerektiğinden bu ilk pagehide etkinliğini göz ardı etmemiz gerekir. Bunu yapmanın birkaç yolu vardır. En basiti, ilk belgenin about:blank URL'sinden gelen sayfagizleme etkinliklerini yoksaymaktır. Bu, pop-up örneğimizde aşağıdaki gibi görünür:

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

Bu tekniğin yalnızca kodumuzun çalıştığı ebeveyn sayfayla aynı etkili köke sahip pencereler ve çerçeveler için işe yaradığını unutmayın. Farklı bir kaynaktan içerik yüklerken güvenlik nedeniyle hem location.host hem de pagehide etkinliği kullanılamaz. Genellikle diğer kaynaklara referans vermekten kaçınmak en iyisidir. Bunun gerekli olduğu nadir durumlarda window.closed veya frame.isConnected mülklerini izleyebilirsiniz. Bu özellikler kapalı bir pencereyi veya kaldırılmış bir iFrame'i belirtecek şekilde değiştiğinde, bu öğelere yapılan referansların ayarlarını kaldırmak iyi bir fikirdir.

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

Çözüm: WeakRef kullanın

JavaScript, yakın zamanda WeakRef adlı, çöp toplama işleminin yapılmasına olanak tanıyan nesneleri referanslamanın yeni bir yolu için destek kazandı. Bir nesne için oluşturulan WeakRef doğrudan bir referans değil, çöp toplanma işlemine tabi tutulmadığı sürece nesneye referans döndüren özel bir .deref() yöntemi sağlayan ayrı bir nesnedir. WeakRef ile, bir pencerenin veya dokümanın geçerli değerine erişebilir ve aynı zamanda çöp toplama işlemine izin verebilirsiniz. pagehide gibi etkinliklere veya window.closed gibi özelliklere yanıt olarak manuel olarak kaldırılması gereken pencereye referans tutmak yerine, pencereye ihtiyaç duyulduğunda erişim elde edilir. Pencere kapatıldığında, çöp toplanabilir. Bu durumda .deref() yöntemi undefined döndürmeye başlar.

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

Pencerelere veya dokümanlara erişmek için WeakRef kullanılırken dikkat edilmesi gereken ilginç bir ayrıntı, referansın genellikle pencere kapatıldıktan veya iFrame kaldırıldıktan sonra kısa bir süre boyunca kullanılabilir durumda kalmasıdır. Bunun nedeni, WeakRef'ün ilişkili nesnesi çöp toplanana kadar değer döndürmeye devam etmesidir. Bu işlem JavaScript'te eşzamansız olarak ve genellikle boştayken gerçekleşir. Neyse ki Chrome Geliştirici Araçları Bellek panelinde ayrılmış pencereleri kontrol ederken bir yığın anlık görüntüsü almak, çöp toplamayı tetikler ve zayıf referans verilen pencereyi kaldırır. WeakRef aracılığıyla referans verilen bir nesnenin JavaScript'den kaldırılıp kaldırılmadığını kontrol etmek için deref()'ın undefined değerini döndürdüğü zamanı algılayabilir veya yeni FinalizationRegistry API'yi kullanabilirsiniz:

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

Çözüm: postMessage üzerinden iletişim kurun

Pencerelerin kapandığını veya gezinme sırasında bir belgenin kaldırıldığını algılamak, işleyicileri kaldırmamıza ve referansları ayarlamamıza olanak tanır. Böylece, ayrılmış pencereler çöp toplanabilir. Ancak bu değişiklikler, bazen daha temel bir sorun olan sayfalar arasındaki doğrudan bağlantı için özel düzeltmelerdir.

Pencereler ve dokümanlar arasında eski referansların kullanılmasını önleyen daha bütünsel bir alternatif yaklaşım da mevcuttur: Belgeler arası iletişimi postMessage() ile sınırlayarak ayrım oluşturma. Orijinal sunucu notları örneğimize dönecek olursak nextSlide() gibi işlevler, notlar penceresine doğrudan referans vererek ve içeriğini değiştirerek pencereyi güncelliyordu. Bunun yerine birincil sayfa, gerekli bilgileri postMessage() üzerinden ayarsız ve dolaylı olarak notlar penceresine iletebilir.

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;

Bu durumda, pencerelerin birbirine referans vermesi gerekir ancak hiçbiri başka bir pencereden geçerli belge referansını tutmaz. Mesaj aktarma yaklaşımı, pencere referanslarının tek bir yerde tutulduğu tasarımları da teşvik eder. Bu sayede, pencereler kapatıldığında veya başka bir sayfaya gidildiğinde yalnızca tek bir referansın kaldırılması gerekir. Yukarıdaki örnekte, yalnızca showNotes() notlar penceresine referans tutar ve referansın temizlendiğinden emin olmak için pagehide etkinliğini kullanır.

Çözüm: noopener kullanan referanslardan kaçının

Sayfanızın iletişim kurması veya kontrol etmesi gerekmeyen bir pop-up pencerenin açıldığı durumlarda, pencereye referans almayı tamamen önleyebilirsiniz. Bu özellik, özellikle başka bir siteden içerik yükleyecek pencereler veya iFrame'ler oluştururken yararlıdır. Bu durumlarda window.open(), HTML bağlantıları için rel="noopener" özelliği gibi çalışan bir "noopener" seçeneğini kabul eder:

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

"noopener" seçeneği, window.open()'un null döndürmesine neden olur. Bu da pop-up'a ait bir referansın yanlışlıkla depolanmasını imkansız hale getirir. Ayrıca window.opener özelliği null olacağından pop-up pencerenin üst penceresine referans almasını da engeller.

Geri bildirim

Bu makaledeki önerilerden bazıları bellek sızıntılarını bulup düzeltmenize yardımcı olacaktır. Ayrılmış pencerelerde hata ayıklamayla ilgili başka bir tekniğiniz varsa veya bu makale, uygulamanızdaki sızıntıları ortaya çıkarmanıza yardımcı olduysa lütfen bize bildirin. Beni Twitter'da @_developit hesabından bulabilirsiniz.