JavaScript İle DOM Değişikliklerinin İzlenmesi

CSS Animations ve MutationObserver Kullanımı

Google Analytics İle Görsel Tıklamalarının Log'lanması başlıklı yazıda yer alan örnekte WordPress NextGEN Gallery eklentisi ile oluşturulan bir galeri içerisinde yer alan görsel tıklamalarının nasıl izlenebileceğinden bahsetmiştim.

AA

Not olarak eklemekte fayda var. NextGEN Gallery eklentisi galeri içerisindeki sayfalamayı iki şekilde ele alır; GET ve AJAX1. İlgili örnek içerisinde URL değişikliği temelinde ilerlemiştim. AJAX ile sayfalama değişiğin JS ile bir önceki sayfaya ait görsel adresleri değer olarak kalmaya devam etmekte. Bu durum da, görüntüde değişiklik olsa da tıklamalar neticesinde iletilen bilgiler aynı kalmaktaydı. Bu yazıda ilgili durum ile ilişkili olarak DOM değişikliklerinin nasıl takip edilebileceğinden bahsetmeye çalışacağım.

DOM Değişikliklerinin İzlenmesi

Bir önceki yazıda JS İle Temel DOM İşlemleri konusu ile DOM işlemlerine dair temel bir başlangıç yapmaya çalışmıştım. Yazıda da bahsi geçtiği üzere, JS ile element'leri seçip listelediğimizde gelen değerler statiktir. Dolayısıyla, anlık değişiklikler listeye yansımayacaktır. Ancak, JS ile DOM değişikliklerini kısmen veya tamamını izleyerek değişimlere bağlı olarak liste içeriklerini güncelleyebilmekteyiz.

Öncelikle, örnek işlemler doğrultusunda kullanacağımız basit bir HAML sayfası oluşturalım.

!!!5
%html
  %head
    %meta{:content => "text/html; charset=UTF-8", "http-equiv" => "Content-Type"}
    %meta{:charset => "utf-8"}
    %meta{:content => "width=device-width, initial-scale=1", :name => "viewport"}
    %title Hello Bulma!
    %link{:href => "https://cdn.jsdelivr.net/npm/bulma@0.9.1/css/bulma.min.css", :rel => "stylesheet"}
  %body
    %section.section
      .container.is-max-desktop
        .columns.is-vcentered.is-centered.is-variable.is-6
          .column.is-8-desktop.is-8-tablet.is-12-mobile
            .box
              %p#changeme.content
                The Caterpillar and Alice looked at each other for some time in silence: at last the Caterpillar took the hookah out of its mouth, and addressed her in a languid, sleepy voice.
            .box
              .field.has-addons
                .control.is-expanded
                  %input.input{:placeholder => "Text input", :type => "text"}
                .control
                  %button#addItem.button.is-link Add to list
              %ul#myList
                %li.notification.is-link.is-light No item!
            %button#disconnect.button.is-warning Disconnect
    :javascript
      -#...

Elimizde input ve liste alanından oluşan bir to-do list HTML arayüzü var. Amacımız text alanı üzerinden yazılan metni ilgili listeye eklemek. Bu aşamada 2 olay gerçekleşecek; var olan No item! ibaresi silinecek ve text alanı içerisine yazılan değer listeye eklenecek. Normalde bu etkinlikleri izlemek için etkinliklerin kendi tanımları ile ilişkilenebiliriz.

document
  .querySelector('#addItem')
  .addEventListener('click', e => {
  //...
});

Yukarıdaki kod parçacığı ile ilgili eleman için gerçekleşecek tıklama etkinlikleri dinlenmekte ve tıklama olduğunda belirtilen eylemler işleme alınmaktadır. Ancak, bu tür bir kod içerisinde değişiklik yapmadan DOM değişikliklerini gözlemlemek durumunda kalabiliriz. Bu amaçla kullanabileceğimiz birkaç yol var.

DOM Level 3 - UI Events bu seçeneklerden biri idi. Ancak, güncel durumda uygun bulunmadığı (deprecated) için geliştirilmesi durduruldu2 3. Yazının geri kalanında haberdar olduğum 2 yönemden bahsedeceğim; animationstart ve MutationObserver.

CSS Animations

MutationObserver kullanımı ile ilgili okuma yaparken denk geldiğim bu çözüm de oldukça ilginç / faydalı göründüğü için yazıya dahil etmek istedim. Çözümleme David Walsh'e ait. Diğer yandan, ilgili yazıya da ilham veren Daniel Buchner'in SelectorListener örneğinin de oldukça zihin açıcı olduğunu söyleyebilirim.

Bu çözümünün temel aldığı prensip CSS animasyonları. Bildiğiniz üzere; CSS animasyonları, bir CSS stili yapılandırmasından diğerine geçişleri (transitions) mümkün kılar. Bu işlem iki bileşen sayesinde gerçekleşir: CSS animasyonunu açıklayan bir stil tanımı ve animasyon stilinin başlangıç ve bitiş durumlarının yanı sıra olası ara geçiş noktalarını gösteren bir dizi anahtar kare (keyframe)4 5.

CSS Animasyon

DOM etkinliği ile ilişkilendirmeden önce CSS animasyonları için basit bir örnek oluşturalım.

p#changeme {
  animation-duration: 3s;
  animation-name: colorize;
  animation-iteration-count: 3;
}
@keyframes colorize {
  from {
    color: blue;
  }

  to {
    color: red;
  }
}

İlgili animatik geçiş @keyframes ile belirtilen yeteneklere sahip anahtar kare tanımları sayesinde gerçekleşmekte. JavaScript ile bu anahtar karelerin başlangıç, animasyon akışı ve bitişleri sırası ile animationstart, animationiteration ve animationend etkinlikleri aracılığı ile takip edilebilmekte. Bu şekilde, animasyon ile ilişkili farkı işlemlerin de gerçekleştirilebilmesini sağlamak mümkün hale gelmekte. DOM değişikliklerinin yakalanması da bunlardan biri5.

Yukarıdaki örnek p etiketini temel almaktaydı. O halde, aynı örnek üzerinden ilerleyelim ve animasyon akışını izleyelim.

const element = document.getElementById("changeme");
element.addEventListener("animationstart", e => console.log(e), false);
element.addEventListener("animationend", e => console.log(e), false);
element.addEventListener("animationiteration", e => console.log(e), false);

Yukarıdaki kod parçacığı @keyframes duurmlarını console üzerinden bize iletecektir.

Infinite CSS Animation

Görüldüğü üzere animation-iteration-count: infinite tanımı sebebiyle animationstart sonrasında gelen animationiteration etkinliği sürekli yinelenmekte ve harici bir müdahale olana kadar animationend etkinliğine ulaşılamamakta. Şimdi animation-iteration-count: 3 ile bir tekrar sınırı koyalım.

Infinite CSS Animation

Görüldüğü üzere animationstart sonrasında animationiteration n-1 kez yineleniyor ve sonrasında animationend etkinliği ile animasyon sonlandırılıyor.

Şimdi bu mantığı DOM değişimlerinin takibinde nasıl kullanabileceğimize bakalım.

David'in ilgili yazıda bahsettiği hack6 esasında animasyon kareleri arasındaki geçişi çok yakın tutup sadece animayon başlangıcını izlemek.

O halde bu mantığı yukarıdaki listeye uygulayalım ve yeni eklenen item'leri izleyelim.

const node = document.createElement('li');
const textMode = document.createTextNode('A new item!');
node.appendChild(textMode);
node.setAttribute('class', 'notification is-link')
list.appendChild(node);

list.addEventListener("animationstart", e => {
  if (e.animationName == "nodeInserted") console.warn("Another node has been inserted! ", e, e.target);
}, false);
DOM İzleme - CSS Animasyon

Evet, CSS animasyon ile ilişkili DOM değişimlerini bu şekilde takip edebilmek mümkün. Ancak, bu işlem sadece bir node ile ilişkili olacaktır, bir attribute değişikliğinde ilgili etkinlik değişimi tetiklenemeyebilir. Bu tür durumlar için MutationObserver seçeneği değerlendirilebilir.

MutationObserver API

MutationObserver, bir DOM öğesini gözlemleyen ve değişiklik durumunda bir callback tetikleyen ve modern internet tarayıcıları tarafından desteklenen Mutation events yerine gelişirilen yerleşik (built-in) bir nesnedir7.

const observer = new MutationObserver(callback);

MutationObserver sayesinde, bir node değişimi yaşandığında (ekleme, silme) ve/veya node özelliği veya içeriğine müdahale edildiğinde haberdar olabilmekteyiz8 9. DOM değişimlerini izleyebilmek amacıyla, öncelikle bir MutationObserver instance (örneklem) oluşturmamız gerekir. Bu işlem eğer belirli bir süre için geçerliyse işlem sonunda instance durdurulabilir. Instance ile ilişkili oluşturduğumuz fonksiyonun ilk argümanı tek bir grupta meydana gelen mutation'a ait bir koleksiyondur. Her mutation meydana gelen değişiklikler hakkında bilgi döner10.

const obsInstance = new MutationObserver(function(mutations) {
  mutations.forEach(function(mutation) {
    console.log(mutation);
  });
});

Instance oluşturulmasıyla birlikte artık ilgili nesneye observe, disconnect ve takeRecords metodlarını kullanarak erişebiliriz. observe hangi node ile ilgili ne tür değişimlerin izleneceğini (observe) belirtir; mutationObserver.observe(target, options).

obsInstance.observe(document.documentElement, {
  attributes: true,
  characterData: true,
  childList: true,
  subtree: true,
  attributeOldValue: true,
  characterDataOldValue: true
});

Genelde target için doğrudan ilgili node'un tanımlanması, eğer bir querySelection* kullanılacaksa, işlem öncesinde sorgunun null dönmediğinden emin olunması önerilmektedir. Yukarıdaki opsiyon (option) tanımları şu karşılıklara sahip3:

childList
Belirtilen node'a ait child node modifikasyonlarını izler.
subtree
Belirtilen node içerisindeki tüm torunları (descendants) izler.
attributes
Belirtilen node özniteliklerini (attributes) izler.
attributeFilter
İzlenecek öznitelikleri (attributes) beliren bir dizi tanımıdır.
characterData
node.data'nın izlenip izlenmeyeceğini belirtir.

Şimdi, bu bilgileri içerecek şekilde örneğimizi düzenleyebiliriz.

const item = {
  add: (item, val, clss = 'is-link') => {
    if(val){
      const node = document.createElement('li'),
            textMode = document.createTextNode(val);
      node.appendChild(textMode);
      node.classList.add('notification', clss);
      list.appendChild(node);
    }
  },
  remove: (clss) => {
    const found = [...list.children].find(e => e.classList.contains(clss));
    if(found) found.remove(this);
  }
}

const list = document.getElementById('myList'),
      addItemButton = document.querySelector('#addItem'),
      disconnectButton = document.querySelector('#disconnect'),
      txtInput = document.querySelector('.input');

addItemButton.addEventListener('click', () => {
  if (txtInput.value) {
    item.remove('is-light');
    item.add(list, txtInput.value);
    txtInput.value = '';
    txtInput.focus();
  }
});

if(list){
  MutationObserver = window.MutationObserver || window.WebKitMutationObserver;

  const observer = new MutationObserver((mutations, observer) => {
    mutations.forEach(e => {
      console.log(e);
      // console.log(e.type);
      // console.log(e.target);
      // console.log(e.addedNodes)
    });
  });

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

  disconnectButton.addEventListener('click', () => {
    observer.disconnect();
    item.add(list, 'Disconnected');
  });
}

Console alanını izlediğinizde, listeye her yeni kayıt eklemesinde MutationRecord nesnesinin eklenen, kaldırılan node içerikleri, bundan etkilenen node ile ilişki, diğer ilişkili node'lar gibi pek çok bilgiyi bize sunduğunu görebilirsiniz3 11 8. Edindiğiniz bu bilgilere göre artık işlemlerinizi gerçekleşirebilirsiniz. İşlemlerinizin tamamlanmasının ardından Disconnect butonunu tıklarsanız, sonraki lise değişimlerinin artık izlenmediğini görebilirsiniz.