Event Delegation Büyük DOM'larda Neden Önemlidir?
Her öğeye ayrı listener eklemek yerine parent'a tek listener bağlamak, büyük ve dinamik DOM'larda listener sızıntısını önler ve tarayıcının tuttuğu kayıt sayısını kökünden düşürür.
Bir liste 50 öğeyle başlar, zamanla 5.000'e ulaşır. Her öğeye ayrı bir click listener eklediğinizde başlangıçta sorun göze çarpmaz; ama liste dinamikse, öğeler gelip gidiyorsa, listener'lar birikmeye devam eder. Tarayıcı her listener için bellek ayırır, her öğe kaldırıldığında temizlenmezse bu bellek serbest bırakılmaz. Sayfanın yavaşlamasının kökü çoğunlukla buradan çıkar.
Event delegation, her öğeye ayrı listener eklemek yerine tek bir parent öğeye listener koyma yöntemidir. Temel ilkesi DOM'un kendi işleyiş biçiminden, yani event bubbling'den gelir. Bir butona tıkladığınızda o event yukarı doğru yayılır; parent, parent'ın parent'ı sırayla tetiklenir. Delegation bu yayılımı kasıtlı olarak kullanır ve listener sayısını kökünden düşürür.
Yöntem yeni değil. Ama büyük DOM'larda, özellikle çok sayıda dinamik öğe içeren sayfalarda ve listener yönetimini dışarıdan kontrol edemediğiniz durumlarda bu yaklaşım ciddi fark yaratır. Farkın nereden geldiğini anlamak için önce tarayıcının listener'ları nasıl tuttuğuna bakmak gerekir.
Event Listener'ların DOM'a Maliyeti
Her addEventListener çağrısı, tarayıcının o öğeye ait dahili kayıt tablosuna bir giriş ekler. Öğe ne kadar küçük olursa olsun bu kayıt bellekte yer tutar ve tarayıcının event dispatching döngüsüne dahil olur. 50 öğe için maliyet ihmal edilebilir; 5.000 öğe için artık değil.
Her listener kaydı birkaç on bayt ile birkaç yüz bayt arasında değişir; listener callback'i bir closure içeriyorsa ve dışarıdan değişken yakalıyorsa bu boyut artar. 5.000 öğe, 5.000 closure demektir. Her closure'ın kapsamındaki değişkenler garbage collector tarafından serbest bırakılamaz, çünkü listener hâlâ aktiftir ve öğeye referans tutmaktadır.
Statik bir liste için bu durum sabit maliyet sayılabilir. Liste dinamikse ve öğeler sık sık eklenip çıkarılıyorsa, kaldırılan öğelerin listener'larını da temizlemek gerekir. removeEventListener çağrısını unutmak veya yanlış referansla çağırmak yeterlidir; listener bellekte kalmaya devam eder, kaldırılan DOM öğesi ise garbage collector tarafından toplanamaz. Buna listener sızıntısı (memory leak) denir.
DevTools Memory sekmesinde heap snapshot alarak bu sızıntıyı doğrulayabilirsiniz. Listeyi birkaç kez yeniledikten sonra snapshot'lar arasında node sayısı artıyorsa ve detached DOM tree içinde eski öğeler görünüyorsa sorun listener yönetimindedir. Bulmak için aramak gerekmez; düzeltmek için mimariyi baştan kurmak gerekir.
Event Bubbling Nasıl Çalışır?
Bir DOM öğesinde bir event tetiklendiğinde, bu event önce öğenin kendisini, ardından parent'ını, sonra onun parent'ını hedef alarak document'e kadar yayılır. Bu yayılıma bubbling denir. Üç aşama vardır: capturing (yukarıdan aşağıya), target (öğenin kendisi), bubbling (aşağıdan yukarıya). addEventListener'ın üçüncü parametresi varsayılan olarak false'tur, yani listener bubbling aşamasına bağlanır.
Delegation bu davranışı tersine kullanır. Parent'ta oturan listener, herhangi bir child'da tetiklenen event'i yakalar. Hangi child'ın tetiklediğini bulmak için event.target kullanılır. event.target, eventi asıl başlatan öğeyi gösterir; event.currentTarget ise listener'ın bağlı olduğu öğeyi, yani parent'ı gösterir.
document.querySelector('#liste').addEventListener('click', function(e) {
const li = e.target.closest('li');
if (!li) return;
// li ile çalış
});
closest kullanımı burada kritiktir. Bir li içinde başka öğeler varsa kullanıcı o iç öğeye tıklamış olabilir; e.target doğrudan li olmayabilir. En yakın li atayı bulmak için closest güvenilir yoldur ve null döndürdüğünde konteyner dışı tıklamaların işlenmesi otomatik olarak önlenir.
Dinamik Listede Listener Sızıntısı Nasıl Oluşur?
Klasik hata şablonu şudur: her veri geldiğinde yeni öğeler oluşturulur, her öğeye listener eklenir, eski öğeler DOM'dan silinir ama listener'ları silinmez. Sık güncellenen bir tablo veya gerçek zamanlı veri akışı olan bir liste düşünün. Her güncelleme döngüsünde yeni listener'lar eklenir; öncekiler hâlâ bellektedir.
// Sorunlu yaklaşım
function listeYenile(veriler) {
const konteyner = document.querySelector('#liste');
konteyner.innerHTML = '';
veriler.forEach(veri => {
const li = document.createElement('li');
li.textContent = veri.ad;
li.addEventListener('click', () => detayGoster(veri));
konteyner.appendChild(li);
});
}
innerHTML = '' ile DOM öğeleri kaldırılır. Ancak addEventListener ile bağlanan listener'lar, closure üzerinden veri nesnesine referans tuttuğu için garbage collector bu öğeleri her zaman toplayamaz. Modern tarayıcılar bu konuda büyük ölçüde iyileşmiştir; closure'ın dışsal değişkenleri ise hâlâ risk oluşturur ve birkaç güncelleme döngüsü sonra heap boyutunun arttığı izlenebilir.
Önemli olan nokta şu: removeEventListener ile temizlik yapmayı hatırlamak mümkün değildir çünkü innerHTML = '' bu çağrıyı yapmaz. Temizliği dışarıdan yönetmek zorunda kalırsınız ve bu yönetim kolayca atlanır. Delegation bu soruyu ortadan kaldırır.
Parent'a Tek Listener Kurmak
Delegation yaklaşımında listener yalnızca bir kez ve parent öğeye bağlanır. Liste yenilendiğinde, yeni öğeler eklendiğinde veya silindiğinde bu listener etkilenmez. Tarayıcı yalnızca bir kayıt tutar ve bu kayıt sayfa ömrü boyunca sabit kalır.
// Delegation yaklaşımı
const konteyner = document.querySelector('#liste');
konteyner.addEventListener('click', function(e) {
const li = e.target.closest('li[data-id]');
if (!li) return;
const id = li.dataset.id;
detayGoster(id);
});
function listeYenile(veriler) {
konteyner.innerHTML = '';
veriler.forEach(veri => {
const li = document.createElement('li');
li.dataset.id = veri.id;
li.textContent = veri.ad;
konteyner.appendChild(li);
});
}
Listener bir kez kurulur, liste kaç kez yenilenirse yenilensin çalışmaya devam eder. data-id gibi bir attribute ile hangi veri kaydıyla çalışıldığı belirlenir; closure üzerinden veri taşımaya gerek kalmaz. Bu yapı, listener sızıntısı riskini köklü biçimde azaltır çünkü yönetilecek bir temizlik adımı bırakmaz.
Birden fazla işlem türü için aynı parent'a listener eklenebilir. Tıklama, klavye olayları, fare üzerine gelme hepsi aynı parent üzerinden yönetilebilir. Her event türü için ayrı listener eklemek makuldür; bunları tek bir listener içinde event.type'a göre yönlendirmek ise karmaşıklık yaratır ve genellikle gereksizdir.
e.target ve e.currentTarget Arasındaki Fark
Bu iki property'nin karıştırılması, delegation'da en sık hata kaynağıdır. e.target, eventi başlatan gerçek öğedir; kullanıcının tıkladığı yer. e.currentTarget ise listener'ın bağlı olduğu öğedir; delegation'da bu her zaman parent'tır.
konteyner.addEventListener('click', function(e) {
console.log(e.target); // Tıklanan gerçek öğe
console.log(e.currentTarget); // Her zaman konteyner
});
İçinde iç öğeler bulunan karmaşık liste satırlarında e.target beklenmedik öğelere işaret edebilir. Kullanıcı bir ikon, rozet veya etiket öğesine tıklamış olabilir. closest ile en yakın anlamlı atayı bulmak bu belirsizliği giderir ve her durumda tutarlı sonuç verir.
konteyner.addEventListener('click', function(e) {
const buton = e.target.closest('button[data-eylem]');
if (buton) {
eylemYurutu(buton.dataset.eylem, buton);
return;
}
const satir = e.target.closest('tr[data-id]');
if (satir) {
satirSec(satir.dataset.id);
}
});
closest, çağrıldığı öğeden başlayarak yukarıya doğru arar ve ilk eşleşen atayı döndürür; eşleşme yoksa null döndürür. Null kontrolü, listener'ın ilgisiz tıklamalarda çalışmamasını sağlar ve aynı zamanda iç içe geçmiş tıklamalarda yanlış öğeyi işleme olasılığını ortadan kaldırır.
Delegation Ne Zaman İşe Yaramaz?
Bazı event'ler bubble etmez. focus, blur, mouseenter, mouseleave bu gruba girer. Bu event'ler için delegation doğrudan çalışmaz. focusin ve focusout ise bubble eder; focus ve blur yerine bunlar kullanılabilir. mouseenter ve mouseleave yerine mouseover ve mouseout bubble eder, ama her child geçişinde de tetiklenir; istenmeyen yan etkilere yol açabilir.
e.stopPropagation() çağrısı bulunan child listener'lar delegation'ı kırar. Bubbling durdurulduğunda event parent'a ulaşmaz. Üçüncü parti bir bileşen veya eski kod bu çağrıyı yapıyorsa delegation beklendiği gibi çalışmayabilir. Bu durumu tespit etmek için browser DevTools'taki Event Listeners paneli ve breakpoint ile bubbling akışını izlemek yeterlidir.
Sabit, az sayıda öğeden oluşan bir arayüzde doğrudan listener eklemek daha anlaşılırdır ve teknik açıdan bir zararı yoktur. Delegation'ın değeri dinamiklikte, yüksek öğe sayısında ve sık güncellemelerde ortaya çıkar. Her yere uygulamak doğru yaklaşım değildir; sorunun gerçekten var olduğu yerde kullanmak doğrudur.
Büyük Tablolarda ve Sonsuz Kaydırmada Pratik Etki
Bin satırlı bir tablo düşünün. Her satırda üç ayrı buton varsa doğrudan listener yaklaşımı 3.000 listener kaydı oluşturur. Tablo her saniye yenileniyorsa bu sayı katlanır. Delegation ile üç listener yeterlidir: tüm satırlar için biri, spesifik buton eylemleri için diğerleri, ya da hepsi tek bir listener içinde. Tarayıcının tuttuğu kayıt sayısı 3.000'den birkaçına düşer.
Sonsuz kaydırma (infinite scroll) senaryolarında delegation neredeyse zorunlu hale gelir. Her yeni içerik bloğuna listener eklemek ve kaydırılmayan eski blokları temizlemek yerine, konteyner üzerindeki tek listener tüm blokların event'lerini yakalar. Sayfa başlangıcından itibaren listener sayısı sabit kalır ve sayfa uzadıkça maliyet artmaz.
Sanal liste (virtual list) kullanan bileşenler, yalnızca görünürdeki öğeleri DOM'da tutan yaklaşımlardır ve öğeleri sürekli ekleyip çıkardığı için delegation doğal bir tamamlayıcı olur. Listener'lar sanal liste katmanına değil, dış konteyner'e bağlanır. Görünümden çıkan öğe DOM'dan kalktığında geride listener bırakmaz.
Listener yönetimi, frontend performansının görünmez taraflarından biridir. Bellek profili incelenene, heap boyutu izlenene kadar fark edilmeyebilir. Parent'a tek listener koymak küçük bir kural gibi görünür; büyük DOM'larda bu kural, belleği öngörülebilir kılan temel karardır ve ilerleyen zamanda arama maliyetini sıfırlar.