Mert Özen Her Satırda Daha İleri
Frontend JavaScript İleri — #12

JavaScript Observer API'leri: Intersection, Mutation ve Resize Observer

Mert Özen 3 Kas 2026 9 dk 5 görüntülenme
JavaScript Observer API'leri: Intersection, Mutation ve Resize Observer

IntersectionObserver ile lazy load ve sonsuz kaydırma kuruyor, MutationObserver ile DOM değişikliklerini, ResizeObserver ile element boyutunu izliyor ve observer'ları doğru temizlemeyi öğreniyoruz.

Sürüm notu: Bu yazıdaki kodlar ES2026 ve güncel tarayıcılar (Chrome, Edge, Firefox, Safari) içindir.

Önce Tahmin Et

Aşağıdaki kodda sayfayı hiç kaydırmıyoruz, hiçbir element yerinden oynamıyor. Sadece bir observer kuruyor ve document.body'yi izlemesini istiyoruz:

const observer = new IntersectionObserver((entries) => {
  console.log("callback ran:", entries.length);
});

observer.observe(document.body);
console.log("observe called");

Bu kod konsola ne yazdırır? Callback hiç çalışır mı, çalışırsa hangi satırdan önce? Okumaya devam etmeden önce bir tahmin yap. Cevabı IntersectionObserver'ı tanıdıktan sonra birlikte göreceğiz.

Sormak Yerine Haber Almak

Daha önce gördüğümüz Web Worker'lar ağır hesabı ana thread'den alıp başka bir thread'e taşıyordu. Observer'lar ise başka bir dertle uğraşıyor: ana thread'in boşu boşuna yaptığı işle. Bir görselin ekrana girip girmediğini anlamanın klasik yolu, scroll event'ini dinleyip her seferinde getBoundingClientRect() ile konumu ölçmekti. Kaydırırken saniyede onlarca kez tetiklenen bir event ve her tetiklenişte layout hesaplatan bir ölçüm. Performans yazısında konuştuğumuz layout thrashing'in ders kitabı örneği.

Bunu kargo beklemeye benzetebilirsin. Beş dakikada bir kapıyı açıp "geldi mi?" diye bakmak hem yorucu hem gereksiz. Kapıcıya "kargo gelince zili çal" demek çok daha akıllıca. Observer API'leri tam olarak bu kapıcı. Tarayıcıya neyi izlemek istediğini söylüyorsun; tarayıcı zaten layout ve çizim adımlarında bu bilgiyi hesaplıyor, bir değişiklik olduğunda da seni çağırıyor.

Üç observer'ın kullanımı birbirine çok benziyor. Bir callback ile observer'ı oluşturursun, observe() ile hedef verirsin, işin bitince disconnect() ile bağı koparırsın. Callback'e tek bir kayıt değil, bir kayıt listesi gelir; çünkü tarayıcı değişiklikleri biriktirip toplu teslim eder. Birini iyi öğrendiğinde diğer ikisi kendiliğinden oturuyor.

IntersectionObserver: Ekrana Girdi mi?

IntersectionObserver, bir element'in viewport ile, ya da seçtiğin kaydırılabilir bir kapsayıcıyla kesişip kesişmediğini bildirir. En bilinen kullanımı lazy load, yani görseli ancak kullanıcı ona yaklaştığında yüklemek. Aşağıdaki dosyayı kaydet, yanına photo-1.jpg ve photo-2.jpg adında herhangi iki görsel koy ve tarayıcıda aç:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Lazy load</title>
  <style>
    .spacer { height: 150vh; }
    img { display: block; width: 300px; height: 200px; margin: 24px auto; background: #ddd; }
  </style>
</head>
<body>
  <p>Scroll down.</p>
  <div class="spacer"></div>
  <img data-src="photo-1.jpg" alt="First photo">
  <img data-src="photo-2.jpg" alt="Second photo">
  <script>
    const observer = new IntersectionObserver((entries, obs) => {
      for (const entry of entries) {
        if (!entry.isIntersecting) continue;
        const img = entry.target;
        img.src = img.dataset.src;
        obs.unobserve(img);
        console.log("Loaded:", img.alt);
      }
    }, { rootMargin: "200px" });

    document.querySelectorAll("img[data-src]").forEach((img) => observer.observe(img));
  </script>
</body>
</html>

DevTools'ta Network sekmesini açık tutarak kaydır. Görsel istekleri sayfa açılırken değil, görseller ekrana yaklaştığında gidecek. Gerçek adres data-src'de bekliyor; element görünür alana girince onu src'ye taşıyoruz ve o görseli izlemeyi bırakıyoruz, çünkü bu iş bir kez yapılır.

İkinci parametredeki seçeneklere bakalım. root kesişimin neye göre hesaplanacağını belirler; varsayılanı null, yani viewport. rootMargin bu alanı CSS'teki margin gibi genişletir ya da daraltır: "200px" dediğimizde görsel ekrana girmeden 200 piksel önce haber alıyoruz, kullanıcı oraya vardığında görsel çoktan inmiş oluyor. threshold ise ne kadarının görünmesi gerektiğini söyler. Varsayılan 0, tek bir pikselin görünmesi yeter demek. 0.5 yarısı, 1 tamamı görünsün demek; [0, 0.25, 0.5, 0.75, 1] gibi bir array verirsen her eşikte ayrı bildirim alırsın.

Callback'e gelen her entry object'inde işine yarayacak alanlar var: target izlenen element, isIntersecting şu an kesişip kesişmediği, intersectionRatio görünen oranı, boundingClientRect de element'in konumu. Ölçümü tarayıcı senin yerine yapmış oluyor.

Şimdi baştaki soruya dönelim. Callback çalışır, hem de hiçbir şey kaydırılmadan. observe() çağrıldığında tarayıcı hedefin o anki durumunu hesaplar ve bunu bir kez bildirir. Bu bildirim asenkron gelir, yani önce observe called, ardından callback ran: 1 yazılır. Lazy load örneğinde isIntersecting kontrolünün sebebi de bu: o kontrol olmasa ekranın çok aşağısındaki görseli de "göründü" sanıp hemen yüklerdik.

Küçük bir not: Sadece görsel geciktirmek istiyorsan HTML'in kendi loading="lazy" attribute'u bu işi JavaScript'siz yapıyor. IntersectionObserver'ı yükleme mantığını kendin yönetmek istediğinde ya da görsel dışı işlerde kullan: bir reklamın gerçekten görüldüğünü saymak, ekrana giren kartı animasyonla getirmek, görünmeyen videoyu durdurmak gibi.

Sonsuz Kaydırma: Listenin Sonundaki Nöbetçi

Sonsuz kaydırma için listedeki her element'i izlemene gerek yok. Listenin en altına boş bir nöbetçi element koyarsın; nöbetçi göründüğünde bir sonraki sayfayı yüklersin. Tam bir örnek:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Infinite scroll</title>
  <style>
    li { padding: 24px; border-bottom: 1px solid #ccc; }
    #sentinel { padding: 16px; text-align: center; }
  </style>
</head>
<body>
  <ul id="list"></ul>
  <div id="sentinel">Loading...</div>
  <script>
    const list = document.getElementById("list");
    const sentinel = document.getElementById("sentinel");
    let page = 0;
    let loading = false;

    async function nextPage() {
      loading = true;
      await new Promise((resolve) => setTimeout(resolve, 500)); // sahte ağ gecikmesi
      for (let i = 1; i <= 20; i++) {
        const li = document.createElement("li");
        li.textContent = `Item ${page * 20 + i}`;
        list.append(li);
      }
      page++;
      loading = false;

      if (page === 5) {
        observer.disconnect();
        sentinel.textContent = "That's all.";
      }
    }

    const observer = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting && !loading) nextPage();
    });

    observer.observe(sentinel);
  </script>
</body>
</html>

Sayfa açılır açılmaz nöbetçi görünür olduğu için ilk 20 element hemen gelir. Aşağı indikçe nöbetçi yeniden ekrana girer ve yeni sayfa eklenir. loading bayrağı, istek sürerken aynı sayfanın iki kez istenmesini engelliyor. Beşinci sayfada veri bittiği için observer'ı tamamen kapatıyoruz. Gerçek bir uygulamada sahte gecikmenin yerinde daha önce gördüğümüz fetch çağrısı olurdu.

MutationObserver: DOM Değişti mi?

MutationObserver, DOM ağacındaki değişiklikleri izler: child eklenip çıkarılması, attribute değişimi, metin değişimi. Kendi yazmadığın bir kodun DOM'a dokunduğunu fark etmek, sonradan eklenen element'lere davranış kazandırmak ya da bir editörde içerik değiştiğinde taslağı kaydetmek için birebir. Bunu tarayıcının konsoluna yapıştırıp dene:

const list = document.createElement("ul");
document.body.append(list);

const observer = new MutationObserver((records) => {
  console.log("Record count:", records.length);
  for (const r of records) {
    console.log(r.type, r.addedNodes.length, r.attributeName);
  }
});

observer.observe(list, { childList: true, attributes: true, subtree: true });

for (let i = 0; i < 3; i++) {
  list.append(document.createElement("li"));
}
list.setAttribute("data-state", "full");
console.log("Loop done");

Önce Loop done yazılır, sonra callback bir kez çalışır ve dört kayıt getirir: üç childList, bir attributes. Dört ayrı değişiklik oldu ama callback dört kez çağrılmadı. MutationObserver kayıtları biriktirir ve callback'i bir microtask olarak çalıştırır. Event loop yazısından hatırlarsan microtask'lar mevcut senkron kod bittikten hemen sonra, ekran çizilmeden önce işlenir. Yani bütün değişiklikleri tek pakette alırsın ve kullanıcı ara durumları hiç görmez.

observe()'un ikinci parametresi neyi izleyeceğini söyler ve en az birini seçmek zorundasın: childList (doğrudan child ekleme/çıkarma), attributes ya da characterData (metin node'unun içeriği). Hiçbirini vermezsen TypeError alırsın. subtree: true izlemeyi bütün alt ağaca yayar. attributeFilter: ["class", "hidden"] sadece belirli attribute'ları izler; attributeOldValue: true ise her kayda eski değeri oldValue olarak ekler. Kayıtlarda type, target, addedNodes, removedNodes ve attributeName alanlarını bulursun.

ResizeObserver: Boyut Değişti mi?

window'un resize event'i sadece pencere boyutunu bildirir. Oysa bir element pencere hiç değişmeden de büyüyüp küçülebilir: yanındaki menü açılır, içine içerik yüklenir, düzen değişir. ResizeObserver, boyut bilgisini element bazında verir. Aşağıdaki dosyada kutuyu sağ alt köşesinden tutup boyutlandır:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>ResizeObserver</title>
  <style>
    #box { width: 400px; height: 150px; padding: 12px; border: 2px solid #333; resize: both; overflow: auto; }
    #box.narrow { background: #fde68a; }
  </style>
</head>
<body>
  <div id="box">Grab the bottom-right corner to resize.</div>
  <p id="info"></p>
  <script>
    const box = document.getElementById("box");
    const info = document.getElementById("info");

    const observer = new ResizeObserver((entries) => {
      for (const entry of entries) {
        const content = entry.contentBoxSize[0].inlineSize;
        const withBorder = entry.borderBoxSize[0].inlineSize;
        info.textContent = `Content: ${Math.round(content)}px, with border: ${Math.round(withBorder)}px`;
        entry.target.classList.toggle("narrow", content < 300);
      }
    });

    observer.observe(box);
  </script>
</body>
</html>

Sayfa açılır açılmaz bilgi satırı dolar, çünkü ResizeObserver da IntersectionObserver gibi ilk durumu hemen bildirir. contentBoxSize padding ve kenarlık hariç içerik alanını, borderBoxSize ise kenarlık dahil kutuyu verir. İkisi de array, çünkü bir element çok sütunlu düzende birden fazla parçaya bölünebilir; günlük kullanımda [0] yeterli. inlineSize yatay yazılan dillerde genişlik, blockSize yükseklik demek. Eski örneklerde göreceğin contentRect de hâlâ çalışıyor ama yeni kodda bu iki alan daha net.

Observer'ları Temizlemek

Bellek yönetimi yazısında unutulan listener ve timer'ların nasıl sızıntıya dönüştüğünü görmüştük. Observer'lar da aynı ailedendir. Observer çalıştığı sürece callback'i ve o callback'in closure ile tuttuğu her şey hayatta kalır. Özellikle document.body gibi sayfada kalıcı bir hedefi izliyorsan, ilgilendiğin arayüz parçası çoktan kaldırılmış olsa bile callback çalışmaya devam eder.

Üç basit kural var. Tek seferlik işlerde, lazy load gibi, iş bitince unobserve(target) ile o hedefi bırak. Sayfanın bir bölümü kaldırıldığında o bölüm için kurduğun observer'ı disconnect() ile kapat. MutationObserver'da unobserve yok; ya hep ya hiç, disconnect() kullanırsın. Kapatmadan önce henüz callback'e ulaşmamış kayıtlar lazımsa takeRecords() onları sana verir:

function watchSize(element, handle) {
  const observer = new ResizeObserver((entries) => handle(entries[0]));
  observer.observe(element);
  return () => observer.disconnect(); // temizlik fonksiyonu
}

const stop = watchSize(document.body, (entry) => {
  console.log("Width:", entry.contentBoxSize[0].inlineSize);
});

// Bu bölüm sayfadan kalktığında:
stop();

Kuran fonksiyonun bir temizlik fonksiyonu döndürmesi iyi bir alışkanlık. Observer'ı kim kurduysa söküm talimatını da o veriyor, kimse unutmuyor.

Sık Düşülen Tuzaklar

En sık hata ilk bildirimi hesaba katmamak. IntersectionObserver ve ResizeObserver observe() anında bir kez çağrılır; callback'te isIntersecting ya da gerçek boyutu kontrol etmezsen olmamış bir olaya tepki verirsin.

İkincisi MutationObserver callback'inde izlediğin DOM'u değiştirmek. Callback'te bir child eklersen bu da yeni bir kayıt üretir, callback tekrar çalışır, yine ekler. Ya bir koşulla işi bir kez yap ya da değişiklikten önce disconnect(), sonra yeniden observe() çağır.

ResizeObserver'da da benzer bir tuzak var: callback içinde aynı element'in boyutunu değiştirirsen konsolda "ResizeObserver loop completed with undelivered notifications" hatasını görürsün. Tarayıcı sonsuz döngüye girmemek için bildirimleri bir sonraki kareye erteler. Boyuta göre sadece renk, class ya da içerik değiştir; boyutun kendisiyle oynayacaksan bunun bir döngü yaratmadığından emin ol.

Sonsuz kaydırmada nöbetçi, yeni element'ler eklendikten sonra hâlâ görünüyorsa, mesela çok uzun bir ekranda, yeni bildirim gelmez. IntersectionObserver durum değiştiğinde haber verir; "görünüyordu, hâlâ görünüyor" bir değişiklik değildir. Çözümlerden biri, yükleme bitince observer.unobserve(sentinel) ve hemen ardından observer.observe(sentinel) çağırmak; böylece yeni bir ilk bildirim alırsın.

threshold: 1 verip viewport'tan uzun bir element izlersen callback beklediğin anda hiç gelmez, çünkü o element hiçbir zaman tamamen görünemez. Uzun içerik için daha küçük bir eşik seç. Son olarak, MutationObserver'ı subtree: true ile document.body üzerinde açıp her şeyi izlemek büyük sayfalarda pahalıdır; hedefi dar tut, gerekiyorsa attributeFilter kullan.

Küçük Bir Deneme

★☆☆ Lazy load örneğinde rootMargin değerini önce "0px", sonra "600px" yap. Network sekmesinde görsel isteklerinin ne zaman gittiğine bak ve farkı kendi cümlelerinle açıkla.

★★☆ Çalıştırmadan önce çıktıyı tahmin et: Bir div'i { attributes: true, attributeOldValue: true } ile izleyen bir MutationObserver kur. Ardından bir döngüde div.dataset.color değerini sırasıyla "a", "b" ve "c" yap. Callback kaç kez çalışır, kaç kayıt gelir, oldValue değerleri neler olur?

★★★ Bir okuma göstergesi yaz: uzun bir makaledeki her h2'yi IntersectionObserver ile izle ve o an ekranda olan bölümün başlığını sayfanın üstündeki sabit bir çubukta göster. Bir buton ekle; tıklandığında makaleyi DOM'dan kaldırsın ve observer'ı temizlesin.

Observer'larla tarayıcının sayfada olup bitenleri bize haber vermesini sağladık. Bir sonraki yazıda sayfanın dışından gelen veriye geçiyoruz: kullanıcının seçtiği ya da sürükleyip bıraktığı dosyalar, Blob'lar ve byte byte okunan ikili veri.