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

Ayrılan pencerelerin neden olduğu aldatıcı bellek sızıntılarını bulun ve düzeltin.

Bartek Nowierski
Bartek Nowierski

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

Bellek sızıntısı, bir uygulama tarafından kullanılan bellek miktarında zaman içinde istenmeyen bir artıştır. JavaScript'te, bellek sızıntıları, nesnelere artık ihtiyaç duyulmasa da işlevler veya başka nesneler tarafından başvurulmaya devam edildiğinde ortaya çıkar. Bu referanslar, gereksiz nesnelerin çöp toplayıcı tarafından geri alınmasını önler.

Çöp toplayıcının işi, artık uygulamadan erişilemeyen nesneleri tanımlamak ve geri talep etmektir. Bu yöntem, nesneler kendilerine başvurduğunda veya döngüsel olarak birbirlerine referansta bulunduğunda bile işe yarar. Bir uygulamanın, bir nesne grubuna erişmesi için gerekli referans kalmadığında bu öğeler atık olarak 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 karmaşık olan bir bellek sızıntısı sınıfı, bir uygulama kendi yaşam döngüleri olan nesnelere (ör. DOM öğeleri veya pop-up pencereler) referans verdiğinde ortaya çıkar. Bu tür nesneler uygulamanın bilgisi olmadan kullanılamaz hale gelebilir. Bu da uygulama kodunun, aksi halde çöp olarak toplanabilecek bir nesne için geriye kalan tek referansa sahip olabileceği anlamına gelir.

Ayrılan pencere nedir?

Aşağıdaki örnekte, bir slayt gösterisi görüntüleyici uygulaması, bir sunucu notları pop-up'ını açıp kapatmak için kullanılan düğmeler içerir. Bir kullanıcının Notları Göster'i tıkladığını ve ardından Notları Gizle düğmesini tıklamak yerine pop-up pencereyi doğrudan kapattığını düşünün. notesWindow değişkeni, pop-up artık kullanımda olmasa bile erişebileceğiniz pop-up'a bir referans tutmaya devam eder.

<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ıldı, ancak kodumuzun, tarayıcının bu pencereyi silmesini ve belleği geri almasını engelleyen bir referansı var.

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. Böyle bir pencere kapatıldıktan veya kullanıcı bu pencereden çıktıktan sonra bile window.open() tarafından döndürülen Window nesnesi, hakkındaki bilgilere erişmek için kullanılmaya devam edebilir. Bu, ayrılmış bir pencere türüdür: JavaScript kodu hâlâ kapalı Window nesnesindeki özelliklere erişmeye devam edebileceği için bellekte tutulması gerekir. Pencere çok sayıda JavaScript nesnesi veya iframe içeriyorsa pencerenin özelliklerine ilişkin kalan JavaScript referansları kalmayana kadar bu bellek geri çekilemez.

Bir pencere kapatıldıktan sonra bir dokümanın nasıl saklanabileceğini 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 çalışır ve contentWindow özellikleri, window.open() tarafından döndürülen değere çok benzer şekilde, içerilen Window nesnesine erişim sağlar. JavaScript kodu, iframe DOM'dan kaldırılsa veya URL'si değişse bile iframe'in contentWindow veya contentDocument öğesine başvuru tutabilir. Bu, özelliklerine erişmeye devam edebileceğiniz için dokümanın çöp toplamasını engeller.

Bir etkinlik işleyicinin, iframe'de farklı bir URL'ye gittikten sonra bile iFrame dokümanını nasıl saklayabileceğinin gösterimi.

Bir pencere veya iframe içindeki document referansının JavaScript'ten saklandığı durumlarda, kapsayıcı pencere veya iframe yeni bir URL'ye gitse bile bu doküman bellekte tutulur. Bu, özellikle ilgili referansı tutan JavaScript pencere/çerçevenin yeni bir URL'ye gittiğini algılamadığında sorun yaratabilir, çünkü bunun bir dokümanı bellekte tutan son başvuru olduğunu bilemez.

Ayrılan pencereler bellek sızıntılarına nasıl neden olur?

Birincil sayfayla aynı alan adındaki pencereler ve iframe'lerle çalışırken, etkinlikleri dinlemek veya doküman sınırları genelindeki özelliklere erişmek yaygın bir durumdur. Örneğin, bu kılavuzun başından itibaren bulunan sunum görüntüleyicisi örneğindeki bir varyasyonu tekrar inceleyelim. Görüntüleyici, konuşmacı notlarını görüntülemek için ikinci bir pencere açar. Konuşmacı notları penceresi, sonraki slayta geçmek için işaret olarak click etkinliklerini dinler. Kullanıcı bu notlar penceresini kapatırsa orijinal üst pencerede çalışan JavaScript, konuşmacı notları dokümanına hâlâ tam erişim sahibi olur:

<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ıda showNotes() tarafından oluşturulan tarayıcı penceresini kapattığımızı varsayalım. Pencerenin kapatıldığını algılamayı bekleyen bir etkinlik işleyici yoktur. Bu nedenle kodumuza, belgeye yapılan tüm referansları temizlemesi gerektiği konusunda bilgi veren herhangi bir şey yoktur. nextSlide() işlevi, ana sayfamızda bir tıklama işleyici olarak bağlandığından ve nextSlide işlevi notesWindow öğesine bir başvuru içerdiği için hâlâ "yayında"dır. Bu, pencerenin hâlâ başvuruda bulunulduğu ve atık toplanamayacağı anlamına gelir.

Bir pencereye yapılan referansların, pencerenin kapatıldıktan sonra çöp toplanmasına nasıl engel olduğunu gösteren resim.

Ayrılmış pencerelerin çöp toplama işlemi için uygun olmasını engelleyen referansların yanlışlıkla saklandığı başka senaryolar da vardır:

  • Etkinlik işleyiciler, çerçevenin amaçlanan URL'sine gitmeden önce bir iframe'in ilk belgesine kaydedilebilir. Bu durum, dokümana ve iframe'e yanlışlıkla referans verilmesine, diğer referanslar temizlendikten sonra da devam etmesine yol açar.

  • Bir pencerede veya iframe'de yüklenen, belleği yoğun bir doküman, yeni bir URL'ye gittikten sonra yanlışlıkla uzun bir süre bellekte tutulabilir. Bu durum genellikle üst sayfanın, işleyicinin kaldırılabilmesi için dokümana başvuruları saklamasından kaynaklanır.

  • Bir JavaScript nesnesini başka bir pencereye veya iframe'e geçirirken, Nesnenin prototip zinciri, nesneyi oluşturan pencere de dahil olmak üzere nesnenin oluşturulduğu ortama referanslar içerir. Bu nedenle, diğer pencerelerdeki nesne referanslarını tutmaktan kaçınmak pencerelerin kendisine referansları tutmaktan 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ılan pencerelerin neden olduğu bellek sızıntılarını algılama

Bellek sızıntılarını izlemek zor olabilir. Söz konusu birden fazla belge veya pencere olduğunda, bu sorunların ayrı ayrı kopyalarını oluşturmak genellikle zordur. İşleri daha karmaşık hale getirmek için sızdırılmış potansiyel referansları incelemek, incelenen nesnelerin çöp toplamasını engelleyen ek referanslar oluşturabilir. Bu amaçla, özellikle bu olasılığı sunmaktan sakınan araçlarla başlamakta fayda vardır.

Yığın anlık görüntüsü almak, bellek sorunlarını ayıklamaya başlamak için en uygun yerdir. Bu şekilde, bir uygulama tarafından kullanılmakta olan bellekle ilgili, oluşturulmuş ancak henüz çöp toplama işlemi gerçekleştirmemiş olan tüm nesneler için belirli bir zaman noktası görünümü sağlanır. Yığın anlık görüntüleri; nesnelerin boyutları ve bunlara atıfta bulunan değişkenler ile kapanışların bir listesi dahil olmak üzere nesneler hakkında faydalı bilgiler içerir.

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

Yığın anlık görüntüsü kaydetmek için Chrome Geliştirici Araçları'nda Bellek sekmesine gidin ve kullanılabilir profil oluşturma türleri listesinden Yığın Anlık Görüntüsü'nü seçin. Kayıt sona erdikten sonra, Özet görünümü, oluşturucuya göre gruplandırılmış şekilde bellekteki mevcut nesneleri gösterir.

Chrome Geliştirici Araçları'nda yığın anlık görüntüsü alma gösterimi.

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 bu konuda yardımcı olmak için yossik@ ve peledni@, ayrılmış pencere gibi belirli bir düğümü vurgulamaya yardımcı olabilecek bağımsız bir Yığın Temizleyici aracı geliştirdi. Yığın Temizleyici'yi bir iz üzerinde çalıştırmak, saklama grafiğindeki diğer gereksiz bilgileri kaldırarak izlemeyi daha temiz ve çok daha kolay okunmasını sağlar.

Belleği programlı şekilde ö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 anlamak için mükemmeldir ancak yığın anlık görüntüsü almak manuel bir işlemdir. Bellek sızıntılarını kontrol etmenin bir başka yolu da şu anda kullanılan JavaScript yığın boyutunu performance.memory API'den almaktır:

Chrome Geliştirici Araçları kullanıcı arayüzünün ekran görüntüsü.
Geliştirici Araçları'nda, pop-up oluşturulurken, kapatılırken ve referansta bulunulmadan kullanılan JS yığın boyutunun kontrol edilmesi.

performance.memory API yalnızca JavaScript yığın boyutu hakkında bilgi sağlar. Yani, pop-up belgesi ve kaynakları tarafından kullanılan belleği içermez. Durumu tam olarak görmek için şu anda Chrome'da denenmekte olan yeni performance.measureUserAgentSpecificMemory() API'yi kullanmamız gerekir.

Sökülen cam sızıntılarını önlemeye yönelik çözümler

Ayrılmış pencerelerin bellek sızıntılarına neden olduğu en yaygın iki durum, üst dokümanın kapalı bir pop-up'a veya kaldırılmış bir iframe'e referansları tutmaya devam etmesi ve bir pencerenin veya iframe'in beklenmedik şekilde gezinmesinin, etkinlik işleyicilerin hiçbir zaman silinmemesiyle sonuçlanmasıdır.

Örnek: Pop-up'ı kapatma

Aşağıdaki örnekte, bir pop-up pencereyi açıp kapatmak için iki düğme kullanılmıştır. Pop-up'ı Kapat düğmesinin çalışması için, açılan pop-up penceresinin referansı bir değişkende depolanı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 kod genel sorunlardan kaçınıyor gibi görünüyor. Pop-up belgesine yapılan referanslar saklanmaz ve pop-up pencerede 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 Kapat Pop-up düğmesi tıklama işleyicisinin kapsamından erişilebilir. popup yeniden atanmadığı veya tıklama işleyici kaldırılmadığı sürece, bu işleyicinin popup referansının çöp toplama işlemi alamayacağı anlamına gelir.

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

Başka bir pencereye veya onun dokümanına referans veren değişkenler, pencerenin bellekte tutulmasına neden olur. JavaScript'teki nesneler her zaman referans olduğundan, değişkenlere yeni bir değer atanması, değişkenlerin orijinal nesneye olan başvurularını kaldırır. Bir nesnenin referanslarını "ayarlarını kaldırmak" 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 penceresine referansının "ayarını kaldıracak" şekilde değiştirebiliriz:

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

Bu yardımcı olmakla birlikte open() kullanılarak oluşturulan pencerelere özgü başka bir sorunu da ortaya çıkarır: Kullanıcı, özel kapat 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üğmemizi tıkladığında popup referansının ayarını kaldırmak için yeterli görünse de kullanıcılar pencereyi kapatmak için bu düğmeyi kullanmadığında hâlâ bir bellek sızıntısı vardır. Bu sorunu çözmek, gerçekleştiğinde bekleyen referansları iptal etmek için bu durumların tespit edilmesini gerektirir.

Çözüm: İzleyin ve imha edin

Çoğu durumda, pencere açmaktan veya çerçeve oluşturmaktan sorumlu JavaScript'in, yaşam döngüleri üzerinde münhasır bir kontrolü olmaz. Pop-up'lar kullanıcı tarafından kapatılabilir veya yeni bir dokümana gitmek, daha önce bir pencere ya da çerçevede bulunan dokümanın ayrılmasına neden olabilir. Her iki durumda da tarayıcı, dokümanın kaldırılmakta olduğunu belirtmek için bir pagehide etkinliği tetikler.

pagehide etkinliği, kapalı pencerelerin ve geçerli dokümandan uzaklaşmanın algılanması için kullanılabilir. Ancak dikkat edilmesi gereken bir nokta var: Yeni oluşturulan tüm pencereler ve iframe'ler boş bir doküman içerir, ardından sağlanırsa eşzamansız olarak belirtilen URL'ye gider. Bunun sonucunda, pencere veya çerçeve oluşturulduktan kısa bir süre sonra, hedef belge yüklenmeden hemen önce ilk pagehide etkinliği tetiklenir. Referans temizleme kodumuzun target belgesi kaldırıldığında çalışması gerektiğinden bu ilk pagehide etkinliğini yoksaymamız gerekir. Bunun için birkaç teknik vardır. En basit yöntem, ilk dokümanın about:blank URL'sinden gelen sayfa gizleme etkinliklerini yoksaymaktır. Pop-up örneğimizde şöyle görünecektir:

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ırıldığı üst sayfayla aynı etkili kaynağa sahip pencereler ve çerçevelerde çalıştığını unutmayın. Farklı bir kaynaktan içerik yüklenirken location.host ve pagehide etkinliği güvenlik nedeniyle kullanılamaz. Genellikle en iyi yöntem, başka kaynaklara başvuruları tutmaktan kaçınmak olsa da bunun gerekli olduğu nadir durumlarda window.closed veya frame.isConnected özelliklerini izlemek mümkündür. Bu özellikler, kapalı bir pencereyi veya kaldırılmış bir iframe'i belirtecek şekilde değiştiğinde, bu özelliğe ilişkin referansların ayarı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 kısa süre önce, çöp toplama işleminin yapılmasına izin veren WeakRef adlı yeni bir yöntem için destek kazandı. Bir nesne için oluşturulan WeakRef, doğrudan bir başvuru değildir. Bunun yerine, çöp toplama işlemi yapılmadığı sürece nesneye bir referans döndüren özel bir .deref() yöntemi sağlayan ayrı bir nesnedir. WeakRef ile bir pencere veya belgenin mevcut değerine erişip çöp toplama işlemine devam etmek mümkündür. pagehide gibi etkinliklere veya window.closed gibi mülklere yanıt olarak manuel olarak ayarlanmamış olması gereken pencereye yönelik bir referans tutmak yerine, gerektiğinde pencereye erişim elde edilir. Pencere kapalıyken atık toplanabilir ve bu da .deref() yönteminin undefined döndürmeye başlamasına neden olur.

<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 belgelere erişmek için WeakRef kullanılırken dikkat edilmesi gereken ilginç bir nokta da, referansın genellikle pencere kapatıldıktan veya iframe kaldırıldıktan sonra kısa bir süre daha kullanımda kalmasıdır. Bunun nedeni, WeakRef ürününün, ilişkili nesnesi çöp toplama işlemi gerçekleşene kadar bir değer döndürmeye devam etmesidir. Bu, JavaScript'te eşzamansız olarak ve genellikle boşta kalma süresinde gerçekleşir. Neyse ki Chrome DevTools Bellek panelinde ayrılmış pencereler kontrol edilirken, bir yığın anlık görüntüsü alınması çöp toplama işlemini tetikler ve zayıf referans verilen pencereyi ortadan kaldırır. deref() tarafından undefined döndürüldüğünde veya yeni FinalizationRegistry API kullanılarak, WeakRef aracılığıyla referans verilen bir nesnenin JavaScript'ten imha edilip edilmediğini kontrol etmek de mümkündür:

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 gezinmenin bir dokümanın yüklemesini kaldırdığını algılamak, bize işleyicileri kaldırma ve ayrılmış pencerelerin çöp toplama işlemi yapabilmesi için referansların ayarını kaldırmamızı sağlar. Bununla birlikte, bu değişiklikler, bazen daha temel bir soruna, yani sayfalar arasında doğrudan eşleşmeye neden olabilen spesifik düzeltmelerdir.

Pencereler ve belgeler arasındaki eski referansları önleyen daha bütünsel bir alternatif yaklaşım vardır: belgeler arası iletişimi postMessage() ile sınırlandırarak ayrım oluşturulmasını sağlar. Orijinal sunucu notları örneğimize dönecek olursak nextSlide() gibi işlevler, notlar penceresini doğrudan kendisine referans vererek ve içeriğini değiştirerek güncellemiştir. Bunun yerine, birincil sayfa gerekli bilgileri notlar penceresine eşzamansız olarak ve dolaylı olarak postMessage() üzerinden 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 işlem için yine de pencerelerin birbirine başvuruda bulunması gerekir, ancak ikisi de geçerli belgeye başka bir pencereden referans vermez. Mesaj iletme yaklaşımı, pencere referanslarının tek bir yerde tutulduğu tasarımları da teşvik eder. Yani, pencereler kapatıldığında veya sayfadan çıkıldığında yalnızca tek bir referansın ayarlanmadan bırakılması yeterlidir. Yukarıdaki örnekte yalnızca showNotes(), notlar penceresi için bir referans tutar ve referansın temizlendiğinden emin olmak için pagehide etkinliğini kullanır.

Çözüm: Referansları noopener kullanarak kullanmaktan kaçının

Sayfanızın iletişim kurmasına veya denetlemesine gerek olmayan bir pop-up pencere açıldığında, pencereye ilişkin referans elde etmekten kaçınabilirsiniz. Bu, ö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() öğesinin null değerini döndürmesine neden olarak pop-up'a ilişkin bir referansı yanlışlıkla depolamayı imkansız hale getirir. window.opener özelliği null olacağı için pop-up penceresinin üst penceresine başvuru almasını da engeller.

Geri bildirim

Bu makaledeki önerilerin bazılarının bellek sızıntılarını bulup düzeltmeye yardımcı olacağını umuyoruz. Bağımsız pencerelerde hata ayıklamak için başka bir tekniğiniz varsa veya bu makale uygulamanızdaki sızıntıları ortaya çıkarmaya yardımcı olduysa bunu öğrenmek isteriz. Beni Twitter'da @_developit adresinden bulabilirsiniz.