ข้ามไปเนื้อหาหลัก

Category: reference

Web Components & Custom Elements

สร้าง reusable components ด้วย browser APIs โดยตรง: Custom Elements, Shadow DOM, HTML Templates — ไม่ต้องการ framework

· อ่านประมาณ 3 นาที

สารบัญ

Web Components ประกอบด้วย 3 ส่วน

APIหน้าที่
Custom Elementsสร้าง HTML element ใหม่ เช่น <my-card>
Shadow DOMencapsulate HTML + CSS ไม่ให้ leak ออกหรือเข้ามา
HTML Templates<template> และ <slot> สำหรับ markup ที่ reuse ได้

Custom Element พื้นฐาน

class MyButton extends HTMLElement {
  // browser เรียก ตอน element ถูกเพิ่มใน DOM
  connectedCallback() {
    this.innerHTML = `<button>${this.getAttribute('label') ?? 'Click'}</button>`;
    this.querySelector('button').addEventListener('click', () => {
      this.dispatchEvent(new CustomEvent('my-click', { bubbles: true }));
    });
  }

  // browser เรียก ตอน element ถูกลบออกจาก DOM
  disconnectedCallback() {
    // cleanup event listeners ที่ผูกกับ external elements
  }

  // browser เรียก ตอน attribute เปลี่ยน (ต้องระบุใน observedAttributes)
  attributeChangedCallback(name, oldVal, newVal) {
    if (name === 'label') {
      const btn = this.querySelector('button');
      if (btn) btn.textContent = newVal;
    }
  }

  // ต้องระบุ attributes ที่ต้องการ observe
  static get observedAttributes() {
    return ['label', 'disabled'];
  }
}

// ลงทะเบียน — ชื่อต้องมีเครื่องหมาย - (hyphen required)
customElements.define('my-button', MyButton);
<!-- ใช้งาน -->
<my-button label="Save"></my-button>
<my-button label="Cancel" disabled></my-button>

<script>
  document.querySelector('my-button').addEventListener('my-click', (e) => {
    console.log('clicked!');
  });
</script>

Shadow DOM — CSS Encapsulation

class MyCard extends HTMLElement {
  constructor() {
    super();
    // attachShadow ต้องเรียกใน constructor
    this._shadow = this.attachShadow({ mode: 'open' });
    // mode: 'open' → เข้าถึงได้จาก element.shadowRoot
    // mode: 'closed' → ซ่อน shadow DOM จาก outside
  }

  connectedCallback() {
    this._shadow.innerHTML = `
      <style>
        /* CSS นี้ไม่ leak ออกไปข้างนอก */
        :host {
          display: block;
          border: 1px solid #e2e8f0;
          border-radius: 8px;
          padding: 1rem;
        }
        :host([highlighted]) {
          border-color: #2563eb;
        }
        h3 { color: #0f172a; margin: 0 0 0.5rem; }
      </style>
      <h3>${this.getAttribute('title') ?? ''}</h3>
      <slot></slot>  <!-- content จาก light DOM ถูก inject ที่นี่ -->
    `;
  }
}

customElements.define('my-card', MyCard);
<my-card title="Hello" highlighted>
  <p>Content จาก light DOM — ถูก inject เข้า <slot></slot></p>
</my-card>

HTML Template

<!-- ประกาศ template — ไม่ render จนกว่าจะ clone -->
<template id="card-template">
  <style>
    .card { padding: 1rem; border: 1px solid #e2e8f0; border-radius: 8px; }
    .card-title { font-weight: 700; }
  </style>
  <div class="card">
    <h3 class="card-title"></h3>
    <slot name="body"></slot>
    <slot name="footer"></slot>
  </div>
</template>
class TemplateCard extends HTMLElement {
  connectedCallback() {
    const template = document.getElementById('card-template');
    const clone = template.content.cloneNode(true);

    clone.querySelector('.card-title').textContent = this.getAttribute('title') ?? '';

    this.attachShadow({ mode: 'open' }).appendChild(clone);
  }
}

customElements.define('template-card', TemplateCard);
<template-card title="Project Update">
  <p slot="body">เนื้อหาหลัก</p>
  <a slot="footer" href="/more">อ่านเพิ่ม</a>
</template-card>

Named Slots

<template id="article-card">
  <style>
    :host { display: grid; gap: 0.5rem; }
    .meta { font-size: 0.8rem; color: #64748b; }
  </style>
  <slot name="meta" class="meta"></slot>
  <slot name="title"></slot>
  <slot name="body"></slot>
  <slot></slot>  <!-- default slot สำหรับ content ที่ไม่ระบุ slot name -->
</template>

<article-card>
  <span slot="meta">มิถุนายน 2026 · 5 นาที</span>
  <h2 slot="title">ชื่อบทความ</h2>
  <p slot="body">สรุปสั้นๆ</p>
</article-card>

Life Cycle และ Properties

class MyInput extends HTMLElement {
  // JavaScript property ← → HTML attribute sync
  get value() {
    return this._input?.value ?? '';
  }

  set value(val) {
    if (this._input) this._input.value = val;
  }

  connectedCallback() {
    this._shadow = this.attachShadow({ mode: 'open' });
    this._shadow.innerHTML = `
      <input type="text" value="${this.getAttribute('value') ?? ''}" />
    `;
    this._input = this._shadow.querySelector('input');
    this._input.addEventListener('input', () => {
      this.dispatchEvent(
        new CustomEvent('change', { detail: { value: this._input.value }, bubbles: true })
      );
    });
  }

  attributeChangedCallback(name, _, newVal) {
    if (name === 'value' && this._input) this._input.value = newVal;
  }

  static get observedAttributes() { return ['value', 'placeholder']; }
}

customElements.define('my-input', MyInput);

// ใช้งานด้วย property แทน attribute
const input = document.querySelector('my-input');
input.value = 'Hello';  // ← JavaScript property
console.log(input.value);

Web Components กับ Framework อื่น

Web Components ทำงานใน HTML ธรรมดา — ใช้ร่วมกับ React, Vue, Astro, Svelte ได้

---
// Astro — import เพื่อ register
import '../components/my-button.js';
---

<my-button label="Save">Save</my-button>

เมื่อไรควรใช้ Web Components

ใช้เมื่อ:

  • ต้องการ component ที่ทำงานในทุก framework (design system ที่แชร์ได้)
  • ต้องการ style encapsulation จริงๆ ที่ CSS จาก parent เข้ามาไม่ได้
  • ทำ micro-frontend ที่แต่ละ team ใช้ tech stack ต่างกัน

ไม่จำเป็นต้องใช้เมื่อ:

  • ทำงานใน single framework — ใช้ component ของ framework ตรงๆ ดีกว่า
  • ต้องการ server-side rendering — Shadow DOM ไม่รองรับ SSR ดีนัก (กำลังพัฒนา)