SiamCafe · Blog
Web Components กับ SaaS Architecture — วิธีสร้าง
บทความ

Web Components กับ SaaS Architecture — วิธีสร้าง

เผยแพร่ 28 พฤษภาคม 2569

Web Components คืออะไร

Web Components เป็นชุดมาตรฐานของ Web Browser ที่ให้สร้าง Reusable Custom HTML Elements ได้ ประกอบด้วย 4 เทคโนโลยีหลัก คือ Custom Elements (สร้าง HTML Tag ใหม่), Shadow DOM (แยก Style และ DOM), HTML Templates (Template ที่ไม่ Render จนกว่าจะใช้) และ Slots (ช่องรับ Content จากภายนอก)

สำหรับ SaaS Architecture Web Components เหมาะอย่างยิ่งเพราะ SaaS มักมีหลาย Product, Micro-frontends หรือ Embeddable Widgets ที่ต้องทำงานข้าม Framework Web Components เป็น Framework-agnostic ใช้ได้ทุกที่ที่มี Browser

สร้าง Web Components พื้นฐาน

// === Custom Element พื้นฐาน ===
// saas-pricing-card.js

class SaaSPricingCard extends HTMLElement {
  // Observed Attributes — React เมื่อ Attribute เปลี่ยน
  static get observedAttributes() {
    return ['plan', 'price', 'currency', 'period', 'featured'];
  }

  constructor() {
    super();
    // สร้าง Shadow DOM
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    // เรียกเมื่อ Element ถูกเพิ่มใน DOM
    this.render();
  }

  attributeChangedCallback(name, oldValue, newValue) {
    // เรียกเมื่อ Observed Attribute เปลี่ยน
    if (oldValue !== newValue) {
      this.render();
    }
  }

  get plan() { return this.getAttribute('plan') || 'Basic'; }
  get price() { return this.getAttribute('price') || '0'; }
  get currency() { return this.getAttribute('currency') || '$'; }
  get period() { return this.getAttribute('period') || '/mo'; }
  get featured() { return this.hasAttribute('featured'); }

  render() {
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          font-family: system-ui, -apple-system, sans-serif;
        }
        .card {
          border: 1px solid ;
          border-radius: 12px;
          padding: 2rem;
          text-align: center;
          background: ;
          color: ;
          transition: transform 0.2s, box-shadow 0.2s;
          position: relative;
        }
        .card:hover {
          transform: translateY(-4px);
          box-shadow: 0 12px 24px rgba(0,0,0,0.1);
        }
        .plan-name {
          font-size: 1.25rem;
          font-weight: 600;
          margin: 0 0 1rem;
        }
        .price {
          font-size: 3rem;
          font-weight: 800;
          line-height: 1;
        }
        .currency { font-size: 1.5rem; vertical-align: super; }
        .period {
          font-size: 0.875rem;
          opacity: 0.7;
          margin-top: 0.25rem;
        }
        .features {
          list-style: none;
          padding: 0;
          margin: 1.5rem 0;
          text-align: left;
        }
        .features ::slotted(li) {
          padding: 0.5rem 0;
          border-bottom: 1px solid ;
        }
        .cta {
          display: inline-block;
          padding: 0.75rem 2rem;
          border-radius: 8px;
          font-weight: 600;
          cursor: pointer;
          border: none;
          font-size: 1rem;
          background: ;
          color: ;
          transition: opacity 0.2s;
        }
        .cta:hover { opacity: 0.9; }
        .badge {
          position: absolute;
          top: -12px;
          left: 50%;
          transform: translateX(-50%);
          background: #f59e0b;
          color: #fff;
          padding: 4px 16px;
          border-radius: 20px;
          font-size: 0.75rem;
          font-weight: 600;
          display: ;
        }
      </style>

      <div class="card">
        <div class="badge">Most Popular</div>
        <h3 class="plan-name"></h3>
        <div class="price">
          <span class="currency"></span>
          
        </div>
        <div class="period"></div>
        <ul class="features">
          <slot name="features"></slot>
        </ul>
        <button class="cta">
          <slot name="cta">Get Started</slot>
        </button>
      </div>
    `;

    // Event Handling
    this.shadowRoot.querySelector('.cta').addEventListener('click', () => {
      this.dispatchEvent(new CustomEvent('plan-selected', {
        bubbles: true,
        composed: true,  // ข้าม Shadow DOM boundary
        detail: { plan: this.plan, price: this.price },
      }));
    });
  }
}

// Register Custom Element
customElements.define('saas-pricing-card', SaaSPricingCard);

// === ใช้งานใน HTML ===
// <saas-pricing-card plan="Pro" price="29" featured>
//   <li slot="features">Unlimited Projects</li>
//   <li slot="features">Priority Support</li>
//   <li slot="features">API Access</li>
//   <span slot="cta">Start Free Trial</span>
// </saas-pricing-card>

Design System ด้วย Web Components สำหรับ SaaS

// === Design System — Base Component Class ===
// lib/base-element.js

export class BaseElement extends HTMLElement {
  static styles = '';  // Override ใน Subclass

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._state = {};
  }

  // Reactive State
  setState(newState) {
    this._state = { ...this._state, ...newState };
    this.render();
  }

  // Theme Support
  get theme() {
    return {
      primary: getComputedStyle(this).getPropertyValue('--ds-primary')
        || '#6366f1',
      background: getComputedStyle(this).getPropertyValue('--ds-bg')
        || '#ffffff',
      text: getComputedStyle(this).getPropertyValue('--ds-text')
        || '#1f2937',
      border: getComputedStyle(this).getPropertyValue('--ds-border')
        || '#e5e7eb',
      radius: getComputedStyle(this).getPropertyValue('--ds-radius')
        || '8px',
      font: getComputedStyle(this).getPropertyValue('--ds-font')
        || 'system-ui, sans-serif',
    };
  }

  connectedCallback() {
    this.render();
  }

  render() {
    // Override ใน Subclass
  }
}

// === Design Token CSS Variables ===
// ตั้ง Theme ที่ Root Level
// :root {
//   --ds-primary: #6366f1;
//   --ds-bg: #ffffff;
//   --ds-text: #1f2937;
//   --ds-border: #e5e7eb;
//   --ds-radius: 8px;
//   --ds-font: 'Inter', system-ui, sans-serif;
// }
//
// /* Dark Theme */
// [data-theme="dark"] {
//   --ds-primary: #818cf8;
//   --ds-bg: #111827;
//   --ds-text: #f9fafb;
//   --ds-border: #374151;
// }

// === Button Component ===
// components/ds-button.js
import { BaseElement } from '../lib/base-element.js';

class DSButton extends BaseElement {
  static get observedAttributes() {
    return ['variant', 'size', 'disabled', 'loading'];
  }

  attributeChangedCallback() { this.render(); }

  get variant() { return this.getAttribute('variant') || 'primary'; }
  get size() { return this.getAttribute('size') || 'md'; }
  get isLoading() { return this.hasAttribute('loading'); }

  render() {
    const t = this.theme;
    const sizes = { sm: '8px 16px', md: '10px 20px', lg: '14px 28px' };
    const fontSizes = { sm: '0.875rem', md: '1rem', lg: '1.125rem' };

    this.shadowRoot.innerHTML = `
      <style>
        :host { display: inline-block; }
        button {
          font-family: ;
          padding: ;
          font-size: ;
          border-radius: ;
          border: 1px solid transparent;
          cursor: pointer;
          font-weight: 600;
          transition: all 0.2s;
          display: inline-flex;
          align-items: center;
          gap: 8px;
        }
        button[disabled] { opacity: 0.5; cursor: not-allowed; }
        .primary {
          background: ;
          color: white;
        }
        .outline {
          background: transparent;
          color: ;
          border-color: ;
        }
        .ghost {
          background: transparent;
          color: ;
        }
        .spinner {
          width: 16px; height: 16px;
          border: 2px solid currentColor;
          border-top-color: transparent;
          border-radius: 50%;
          animation: spin 0.6s linear infinite;
        }
        @keyframes spin { to { transform: rotate(360deg); } }
      </style>
      <button class=""
        
        >
        
        <slot></slot>
      </button>
    `;
  }
}
customElements.define('ds-button', DSButton);

Embeddable Widget สำหรับ SaaS

// === Embeddable Chat Widget สำหรับ SaaS ===
// widget/saas-chat-widget.js
// ลูกค้าฝังบนเว็บด้วย <script> tag เดียว

class SaaSChatWidget extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._isOpen = false;
    this._messages = [];
  }

  static get observedAttributes() {
    return ['api-key', 'position', 'theme-color'];
  }

  get apiKey() { return this.getAttribute('api-key') || ''; }
  get position() { return this.getAttribute('position') || 'bottom-right'; }
  get themeColor() { return this.getAttribute('theme-color') || '#6366f1'; }

  connectedCallback() {
    this.render();
    this.setupWebSocket();
  }

  disconnectedCallback() {
    if (this._ws) this._ws.close();
  }

  setupWebSocket() {
    this._ws = new WebSocket(
      `wss://chat.saas-app.com/ws?key=`
    );
    this._ws.onmessage = (event) => {
      const msg = JSON.parse(event.data);
      this._messages.push(msg);
      this.updateMessages();
    };
  }

  toggleChat() {
    this._isOpen = !this._isOpen;
    this.render();
  }

  sendMessage(text) {
    if (!text.trim()) return;
    this._messages.push({ role: 'user', text, time: new Date() });
    this._ws.send(JSON.stringify({ text }));
    this.updateMessages();
  }

  render() {
    const pos = this.position === 'bottom-left'
      ? 'left: 20px' : 'right: 20px';

    this.shadowRoot.innerHTML = `
      <style>
        :host { position: fixed; bottom: 20px; ; z-index: 99999;
                font-family: system-ui, sans-serif; }
        .fab {
          width: 56px; height: 56px; border-radius: 50%;
          background: ; border: none; cursor: pointer;
          color: white; font-size: 24px; display: flex;
          align-items: center; justify-content: center;
          box-shadow: 0 4px 12px rgba(0,0,0,0.15);
        }
        .panel {
          display: ;
          flex-direction: column; width: 360px; height: 500px;
          background: white; border-radius: 12px;
          box-shadow: 0 8px 32px rgba(0,0,0,0.15);
          margin-bottom: 12px; overflow: hidden;
        }
        .header {
          background: ; color: white;
          padding: 16px; font-weight: 600;
        }
        .messages {
          flex: 1; overflow-y: auto; padding: 16px;
        }
        .input-area {
          display: flex; padding: 12px; border-top: 1px solid #e5e7eb;
        }
        .input-area input {
          flex: 1; padding: 8px 12px; border: 1px solid #e5e7eb;
          border-radius: 8px; outline: none;
        }
        .input-area button {
          margin-left: 8px; padding: 8px 16px;
          background: ; color: white;
          border: none; border-radius: 8px; cursor: pointer;
        }
      </style>

      <div class="panel">
        <div class="header">Chat Support</div>
        <div class="messages" id="messages"></div>
        <div class="input-area">
          <input type="text" placeholder="Type a message..."
                 id="chatInput" />
          <button id="sendBtn">Send</button>
        </div>
      </div>
      <button class="fab" id="fabBtn">
        
      </button>
    `;

    // Events
    this.shadowRoot.getElementById('fabBtn')
      .addEventListener('click', () => this.toggleChat());

    const input = this.shadowRoot.getElementById('chatInput');
    const sendBtn = this.shadowRoot.getElementById('sendBtn');
    if (input && sendBtn) {
      sendBtn.addEventListener('click', () => {
        this.sendMessage(input.value);
        input.value = '';
      });
      input.addEventListener('keypress', (e) => {
        if (e.key === 'Enter') {
          this.sendMessage(input.value);
          input.value = '';
        }
      });
    }
    this.updateMessages();
  }

  updateMessages() {
    const container = this.shadowRoot.getElementById('messages');
    if (!container) return;
    container.innerHTML = this._messages.map(m => `
      <div style="margin:8px 0;text-align:">
        <span style="display:inline-block;padding:8px 12px;border-radius:12px;
          max-width:80%;background:;
          color:">
          
        </span>
      </div>
    `).join('');
    container.scrollTop = container.scrollHeight;
  }
}
customElements.define('saas-chat-widget', SaaSChatWidget);

// ลูกค้าฝังด้วย:
// <script src="https://cdn.saas-app.com/widget.js"></script>
// <saas-chat-widget api-key="abc123"
//   theme-color="#6366f1"></saas-chat-widget>

ใช้ Web Components กับ React, Vue, Angular

  • React: ใช้ได้โดยตรงใน JSX แต่ Event Handling ต้องใช้ ref เพราะ React ไม่ Forward Custom Events อัตโนมัติ หรือใช้ @lit/react Wrapper
  • Vue: รองรับ Web Components โดยตรง ใช้ได้เลยใน Template ตั้ง Config ให้ Vue ไม่ Resolve Custom Elements
  • Angular: เพิ่ม CUSTOM_ELEMENTS_SCHEMA ใน Module แล้วใช้ได้เลย รองรับ Property Binding ด้วย
  • Svelte: ใช้ได้โดยตรง Svelte ไม่จัดการ Custom Elements ปล่อยให้ Browser จัดการ
  • Lit: Library สำหรับสร้าง Web Components ง่ายขึ้น มี Reactive Properties, Templates และ Scoped Styles

Build และ Distribution

  • Bundling: ใช้ Vite หรือ Rollup Bundle เป็น Single File สำหรับ CDN Distribution
  • CDN: Host บน CDN เช่น CloudFlare, jsDelivr ให้ลูกค้าใช้ Script Tag เดียว
  • NPM: Publish เป็น NPM Package สำหรับ Developer ที่ใช้ Build Tools
  • Tree Shaking: Export แต่ละ Component แยกให้ Import เฉพาะที่ต้องการ
  • Versioning: ใช้ Semantic Versioning CDN URL มี Version เช่น /v2/widget.js

Web Components คืออะไร

Web Components เป็นมาตรฐาน Browser สร้าง Custom HTML Elements ประกอบด้วย Custom Elements, Shadow DOM, HTML Templates และ Slots ทำงานได้ทุก Framework ไม่ต้องมี Runtime เพิ่ม เหมาะสำหรับ Shared Components ข้าม Framework