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

JavaScript Web Components: Custom Elements, Shadow DOM ve Slot

Mert Özen 3 Kas 2026 10 dk 6 görüntülenme
JavaScript Web Components: Custom Elements, Shadow DOM ve Slot

customElements.define ile kendi HTML etiketimizi yazıyor, lifecycle callback'lerini ve observedAttributes'u öğreniyor, Shadow DOM, template ve slot ile framework'süz bir yıldızlı puan component'i kuruyoruz.

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

Önce Tahmin Et

Kendi HTML etiketimizi tanımlıyoruz. Üç callback'e bilerek numara verdik; ama numaralar sırayı değil, sadece hangi callback olduğunu gösteriyor:

class HelloCard extends HTMLElement {
  static observedAttributes = ["name"];

  constructor() {
    super();
    console.log("1 constructor");
  }
  connectedCallback() {
    console.log("2 connected");
  }
  attributeChangedCallback(name, oldValue, newValue) {
    console.log("3 attribute", name, oldValue, newValue);
  }
}

customElements.define("hello-card", HelloCard);

const card = document.createElement("hello-card");
card.setAttribute("name", "Ada");
document.body.append(card);

Bu kod konsola ne yazdırır? Hangi satırlar hangi sırayla gelir, oldValue değeri ne olur? Okumaya devam etmeden önce bir tahmin yap. Cevabı yaşam döngüsünü konuşurken vereceğiz.

Neden Kendi Etiketin?

HTML'in hazır etiketleri bir kutu lego gibi: button, input, details hepsi iş görür. Ama projende on yerde aynı yıldızlı puan alanını, aynı bilgi kartını kullanıyorsan, her seferinde aynı HTML'i, aynı CSS'i ve aynı JavaScript'i kopyalamak yerine kendi lego parçanı üretmek istersin. Web Components tam olarak bunu sağlayan tarayıcı standartlarının ortak adı.

Üç parçadan oluşur. Custom Elements, tarayıcıya yeni bir etiket öğretmeni ve davranışını bir class ile tanımlamanı sağlar. Shadow DOM, component'in iç yapısını ve stilini sayfanın geri kalanından yalıtır. <template> ve <slot> de component'in iskeletini ve dışarıdan içerik alacağı yerleri tanımlar. Hepsi tarayıcının kendisinde var; ek bir kütüphane, derleme adımı ya da kurulum gerektirmez.

İlk Custom Element

Tam bir dosyayla başlayalım:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>First custom element</title>
</head>
<body>
  <say-hello name="Ada"></say-hello>
  <say-hello></say-hello>
  <script>
    class SayHello extends HTMLElement {
      connectedCallback() {
        const name = this.getAttribute("name") ?? "stranger";
        this.textContent = `Hello, ${name}!`;
      }
    }

    customElements.define("say-hello", SayHello);
  </script>
</body>
</html>

Sayfada "Hello, Ada!" ve "Hello, stranger!" yazar. customElements.define etiket adını bir class'a bağlar; class HTMLElement'ten türediği için this gerçek bir DOM element'idir ve daha önce gördüğümüz bütün DOM method'larını kullanabilir.

İsim kuralları katı: küçük harfle başlamalı ve en az bir tire içermeli. Tire şartı, bugün ya da gelecekte HTML'e eklenecek hiçbir etiketle çakışmamanı garanti ediyor; standart etiketlerin adında tire yok. Bir isim sayfada sadece bir kez tanımlanabilir.

Bir ayrıntı daha: tarayıcı <say-hello>'i HTML'de, define çağrılmadan önce görür. O an ne olduğunu bilmediği için onu sıradan bir HTMLElement olarak bırakır. define çalışınca sayfadaki bütün say-hello'ler yükseltilir (upgrade) ve gerçek class'larına kavuşur. Bu bekleme anını CSS'te :defined pseudo-class'ıyla, JavaScript'te customElements.whenDefined("say-hello") promise'iyle yakalayabilirsin.

Yaşam Döngüsü

Bir custom element'in hayatındaki önemli anlarda tarayıcı class'ındaki belirli method'ları çağırır. constructor element oluşturulduğunda çalışır; ilk iş super() çağırmak zorundasın. Burada başlangıç değerlerini ayarlayabilir, event listener ekleyebilir, shadow root oluşturabilirsin, ama attribute ya da child okumak ve eklemek yasak; o bilgiler henüz hazır olmayabilir. connectedCallback element sayfaya her eklendiğinde, disconnectedCallback sayfadan her çıkarıldığında çağrılır. attributeChangedCallback(name, oldValue, newValue) izlediğin bir attribute eklendiğinde, değiştiğinde ya da silindiğinde çalışır. adoptedCallback ise element başka bir belgeye, mesela bir iframe'e taşındığında çalışır ve nadiren lazım olur. Yeni eklenen ve henüz her tarayıcıda olmayan Element.moveBefore() için bir de connectedMoveCallback var; tanımlarsan taşıma sırasında ayrılma ve bağlanma callback'lerinin yerine o çağrılır.

Baştaki sorunun cevabı bu sıralamada saklı. Çıktı şöyle:

1 constructor
3 attribute name null Ada
2 connected

createElement element'i oluşturduğu an constructor çalışır. setAttribute çağrıldığında element henüz sayfada değil, ama attribute izlenen listede olduğu için callback hemen çalışır; öncesinde bir değer olmadığından oldValue null gelir. connectedCallback ancak append ile sayfaya girince gelir. Numaralar sırayı değil, callback'in adını gösteriyordu.

disconnectedCallback temizlik için çok önemli. Bir element zamanlayıcı kuruyorsa, document'a listener ekliyorsa ya da daha önce gördüğümüz bir observer başlatıyorsa, bunları kaldırmanın yeri burası. Aşağıdaki saati ekleyip çıkararak dene:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Live clock</title>
</head>
<body>
  <button id="toggle">Add / remove clock</button>
  <live-clock></live-clock>
  <script>
    class LiveClock extends HTMLElement {
      #timer = null;

      connectedCallback() {
        this.#update();
        this.#timer = setInterval(() => this.#update(), 1000);
        console.log("Clock started");
      }

      disconnectedCallback() {
        clearInterval(this.#timer);
        console.log("Clock stopped");
      }

      #update() {
        this.textContent = new Date().toLocaleTimeString();
      }
    }

    customElements.define("live-clock", LiveClock);

    const clock = document.querySelector("live-clock");
    document.getElementById("toggle").addEventListener("click", () => {
      if (clock.isConnected) clock.remove();
      else document.body.append(clock);
    });
  </script>
</body>
</html>

disconnectedCallback olmasaydı kaldırdığın saat arka planda her saniye çalışmaya devam ederdi. Element'i tekrar eklediğinde connectedCallback'in yeniden çalıştığına da dikkat et; bu callback bir kez değil, her bağlanışta gelir.

Attribute'ları İzlemek

attributeChangedCallback sadece static observedAttributes array'inde adı geçen attribute'lar için çağrılır. Bu bir performans tercihi: tarayıcı her attribute değişikliğinde seni rahatsız etmez. Listede olmayan bir attribute'u değiştirirsen callback hiç çalışmaz.

Component'i kullananlar genelde iki yol bekler: HTML'de value="3" yazmak ve JavaScript'te el.value = 3 demek. İkisini senkron tutmanın en temiz yolu, attribute'u tek doğru kaynak kabul edip property'yi ona bağlamaktır:

get value() {
  return Number(this.getAttribute("value") ?? 0);
}
set value(newValue) {
  this.setAttribute("value", String(newValue));
}

Property'ye yazılan değer attribute'a gider, attribute değişince attributeChangedCallback çalışır ve ekranı yeniden çizersin. Hangi yoldan gelirse gelsin değişiklik aynı kapıdan geçer. Daha önce Orta seride gördüğümüz getter ve setter'lar burada tam yerini buluyor.

Shadow DOM ve Stil İzolasyonu

Component'in iç HTML'ini doğrudan element'in içine yazarsan sayfanın CSS'i ona da uygulanır; component'in CSS'i de sayfaya taşar. Shadow DOM bu karışıklığı çözer. Onu bir apartman dairesi gibi düşün: içeride duvarları hangi renge boyadığın dışarıyı ilgilendirmez, koridordaki boya da içeri girmez. Ama kapı aralığından hava geçer; color ve font gibi inherit edilen property'ler içeri süzülür.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Shadow DOM</title>
  <style>
    p { color: red; border: 2px solid red; }
  </style>
</head>
<body>
  <p>Paragraph on the page</p>
  <shadow-box></shadow-box>
  <script>
    class ShadowBox extends HTMLElement {
      constructor() {
        super();
        const shadow = this.attachShadow({ mode: "open" });
        shadow.innerHTML = `
          <style>
            :host { display: block; padding: 12px; background: #eef; }
            p { color: navy; }
          </style>
          <p>Paragraph inside the Shadow DOM</p>
        `;
      }
    }

    customElements.define("shadow-box", ShadowBox);
    console.log(document.querySelectorAll("p").length); // 1
  </script>
</body>
</html>

Sayfadaki kırmızı kenarlıklı stil shadow içindeki paragrafa ulaşmaz; içerideki lacivert stil de dışarıdaki paragrafı etkilemez. :host seçicisi component'in kendisini, yani <shadow-box>'yu hedefler. querySelectorAll("p") sadece bir paragraf bulur, çünkü shadow içi sayfanın normal aramalarından da gizlidir; içeriye ulaşmak için element.shadowRoot.querySelector() kullanırsın.

mode: "open" olduğunda shadowRoot dışarıdan erişilebilir. "closed" dersen element.shadowRoot null döner. Bu bir güvenlik önlemi değil, sadece bir "dokunma" işaretidir; pratikte çoğu component open kullanır. Dışarıdan tema vermek istediğinde CSS custom property'leri kullan: --main-color gibi değişkenler shadow sınırından geçer, component de onları var(--main-color, blue) diye okur.

template ve slot: Dışarıdan İçerik Almak

Daha önce gördüğümüz <template> etiketi, sayfada görünmeyen ama klonlanmaya hazır bir HTML kalıbıdır. Shadow DOM ile birleşince component'in iskeleti olur. <slot> ise o iskelette kullanıcının içeriğini yerleştireceğin deliklerdir:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Slot</title>
</head>
<body>
  <template id="card-template">
    <style>
      .card { border: 1px solid #ccc; border-radius: 8px; padding: 12px; margin: 8px; max-width: 280px; }
      ::slotted(h3) { margin: 0 0 8px; color: #2563eb; }
    </style>
    <div class="card">
      <slot name="title"><h3>Untitled card</h3></slot>
      <slot></slot>
    </div>
  </template>

  <info-card>
    <h3 slot="title">Coffee</h3>
    <p>Keep it under two cups a day.</p>
  </info-card>

  <info-card>
    <p>This card has no title.</p>
  </info-card>

  <script>
    class InfoCard extends HTMLElement {
      constructor() {
        super();
        const template = document.getElementById("card-template");
        this.attachShadow({ mode: "open" }).append(template.content.cloneNode(true));
      }
    }

    customElements.define("info-card", InfoCard);
  </script>
</body>
</html>

slot="title" attribute'u taşıyan element, name="title" olan slot'a yerleşir. İsmi olmayan slot geri kalan her şeyi alır. İkinci kartta başlık verilmediği için slot'un içindeki yedek içerik, "Untitled card", görünür. ::slotted(h3) dışarıdan gelen ve slot'a doğrudan yerleşen h3'leri stillendirir.

Burada ince bir nokta var: slot'a giren içerik shadow DOM'a taşınmaz, sayfada kalır ve sadece orada gösterilir. Bu yüzden sayfanın CSS'i hâlâ onu stillendirebilir ve document.querySelector onu bulabilir. Shadow DOM'un yalıttığı şey component'in kendi iç yapısıdır, kullanıcının verdiği içerik değil.

Framework'süz Bir Component: Yıldızlı Puan

Öğrendiklerimizi birleştirip gerçekten yeniden kullanılabilir bir component yazalım. <star-rating> attribute'tan değer alır, tıklamayla değişir, değiştiğinde dışarıya bir event fırlatır ve bir CSS değişkeniyle boyutlandırılabilir:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Star rating</title>
  <style>
    star-rating:not(:defined) { visibility: hidden; }
  </style>
</head>
<body>
  <star-rating value="3">Coffee</star-rating>
  <star-rating value="7" max="10" style="--star-size: 18px">Movie</star-rating>
  <p id="last"></p>
  <script>
    const template = document.createElement("template");
    template.innerHTML = `
      <style>
        :host { display: flex; align-items: center; gap: 8px; font-size: var(--star-size, 28px); }
        button { all: unset; cursor: pointer; color: #d1d5db; }
        button.filled { color: #f59e0b; }
        button:focus-visible { outline: 2px solid #2563eb; border-radius: 4px; }
      </style>
      <slot></slot>
      <div id="stars"></div>
    `;

    class StarRating extends HTMLElement {
      static observedAttributes = ["value", "max"];

      constructor() {
        super();
        this.attachShadow({ mode: "open" }).append(template.content.cloneNode(true));
        this.shadowRoot.getElementById("stars").addEventListener("click", (event) => {
          const button = event.target.closest("button");
          if (!button) return;
          this.value = Number(button.dataset.rating);
          this.dispatchEvent(new CustomEvent("rating-change", {
            detail: { value: this.value },
            bubbles: true,
          }));
        });
      }

      get value() { return Number(this.getAttribute("value") ?? 0); }
      set value(newValue) { this.setAttribute("value", String(newValue)); }
      get max() { return Number(this.getAttribute("max") ?? 5); }
      set max(newValue) { this.setAttribute("max", String(newValue)); }

      connectedCallback() { this.#render(); }
      attributeChangedCallback() { this.#render(); }

      #render() {
        const container = this.shadowRoot.getElementById("stars");
        container.replaceChildren();
        for (let i = 1; i <= this.max; i++) {
          const button = document.createElement("button");
          button.textContent = "★";
          button.dataset.rating = i;
          button.className = i <= this.value ? "filled" : "";
          button.setAttribute("aria-label", `Rate ${i}`);
          container.append(button);
        }
      }
    }

    customElements.define("star-rating", StarRating);

    document.addEventListener("rating-change", (event) => {
      document.getElementById("last").textContent =
        `New rating for ${event.target.textContent}: ${event.detail.value}`;
    });
  </script>
</body>
</html>

Tıklamayı tek tek butonlara değil, kapsayıcıya bağladık; daha önce gördüğümüz event delegation. Tıklanan yıldız value property'sini günceller, o attribute'u değiştirir, attribute de attributeChangedCallback üzerinden ekranı yeniden çizer. Dışarıya haber vermek için yine daha önce gördüğümüz CustomEvent'i kullanıyoruz; bubbles: true sayesinde event document'a kadar çıkıyor. Sayfanın en üstündeki :not(:defined) kuralı, script çalışana kadar ham etiketlerin bir an görünüp kaybolmasını engelliyor. Konsoldan document.querySelector("star-rating").max = 8 yazıp sonucu gör.

Sık Düşülen Tuzaklar

İsim hataları ilk engeldir. Tiresiz bir isimle define çağırırsan SyntaxError, aynı ismi ikinci kez tanımlarsan NotSupportedError alırsın. İkincisi, aynı script'i iki kez yükleyen sayfalarda sık görülür; customElements.get("star-rating") ile önce kontrol edebilirsin.

Custom element'ler kendiliğinden kapanmaz. <star-rating /> yazarsan HTML ayrıştırıcısı sondaki eğik çizgiyi yok sayar ve ardından gelen her şeyi bu element'in içine koyar. Her zaman <star-rating></star-rating> yaz.

Constructor'da attribute okumak ya da element'e child ve attribute eklemek bir başka klasik hata. createElement ile oluşturulan bir element'te attribute'lar constructor sırasında henüz yoktur; constructor'da attribute eklemeye çalışırsan da tarayıcı hata verir. Bu işleri connectedCallback'e taşı. Bu callback'in birden fazla kez çalışabileceğini de unutma; sadece bir kez yapılması gereken bir kurulum varsa bir bayrakla koru.

Shadow DOM içindeki bir element'ten fırlattığın event varsayılan olarak shadow sınırında durur. Bizim örneğimizde event'i host'un kendisinden, yani this'ten fırlattığımız için sorun yoktu. İçerideki bir butondan fırlatsaydın dışarıda duyulması için bubbles: true ile birlikte composed: true da vermen gerekirdi.

Son olarak, <button is="fancy-button"> biçiminde yazılan ve mevcut bir etiketi genişleten customized built-in element'leri Safari desteklemiyor ve desteklemeyi de planlamıyor. Bu yazıda yaptığımız gibi HTMLElement'ten türeyen bağımsız element'ler her tarayıcıda çalışır, onlarla devam et.

Küçük Bir Deneme

★☆☆ say-hello component'ine izlenen bir color attribute'u ekle. Konsoldan document.querySelector("say-hello").setAttribute("color", "tomato") yazdığında metnin rengi değişsin.

★★☆ Çalıştırmadan önce çıktıyı tahmin et, sonra konsolda dene:

class CounterBox extends HTMLElement {
  connectedCallback() { console.log("connected"); }
  disconnectedCallback() { console.log("disconnected"); }
}
customElements.define("counter-box", CounterBox);

const a = document.createElement("counter-box");
const b = document.createElement("div");
document.body.append(a);
b.append(a);
document.body.append(b);

★★★ star-rating'e klavye desteği ekle: component odaktayken sol ve sağ ok tuşları puanı bir azaltıp artırsın. Bir de readonly attribute'u ekle; bu attribute varken tıklamalar ve tuşlar puanı değiştirmesin.

Web Components, tarayıcı standartlarının yıllar içinde nasıl olgunlaştığının güzel bir örneği. Bir sonraki yazıda, bu serinin de son yazısında, aynı olgunlaşmayı dilin kendisinde izleyeceğiz: TC39'un aşamaları, Date'in yerini almaya gelen Temporal ve using ile kaynak yönetimi.