发布于

Web Components与自定义元素:原生组件开发实战指南

作者

Web Components与自定义元素:原生组件开发实战指南

Web Components是浏览器原生支持的组件化技术,让我们能够创建可复用的自定义HTML元素。本文将分享Web Components的实战开发经验和最佳实践。

Custom Elements基础

自定义元素定义

// 基础自定义元素
class MyButton extends HTMLElement {
  constructor() {
    super();
    
    // 初始化状态
    this.disabled = false;
    this.variant = 'primary';
    
    // 绑定事件处理器
    this.handleClick = this.handleClick.bind(this);
  }
  
  // 生命周期:元素被插入DOM时调用
  connectedCallback() {
    this.render();
    this.attachEventListeners();
    
    // 设置默认属性
    if (!this.hasAttribute('role')) {
      this.setAttribute('role', 'button');
    }
    
    if (!this.hasAttribute('tabindex')) {
      this.setAttribute('tabindex', '0');
    }
  }
  
  // 生命周期:元素从DOM中移除时调用
  disconnectedCallback() {
    this.removeEventListeners();
  }
  
  // 生命周期:属性变化时调用
  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue !== newValue) {
      this.handleAttributeChange(name, oldValue, newValue);
    }
  }
  
  // 生命周期:元素被移动到新文档时调用
  adoptedCallback() {
    console.log('Button moved to new document');
  }
  
  // 指定要监听的属性
  static get observedAttributes() {
    return ['disabled', 'variant', 'size'];
  }
  
  // 属性getter/setter
  get disabled() {
    return this.hasAttribute('disabled');
  }
  
  set disabled(value) {
    if (value) {
      this.setAttribute('disabled', '');
    } else {
      this.removeAttribute('disabled');
    }
  }
  
  get variant() {
    return this.getAttribute('variant') || 'primary';
  }
  
  set variant(value) {
    this.setAttribute('variant', value);
  }
  
  // 渲染方法
  render() {
    this.innerHTML = `
      <style>
        :host {
          display: inline-block;
          padding: 8px 16px;
          border: none;
          border-radius: 4px;
          cursor: pointer;
          font-family: inherit;
          font-size: 14px;
          transition: all 0.2s ease;
          outline: none;
        }
        
        :host([variant="primary"]) {
          background-color: #007bff;
          color: white;
        }
        
        :host([variant="secondary"]) {
          background-color: #6c757d;
          color: white;
        }
        
        :host([variant="outline"]) {
          background-color: transparent;
          color: #007bff;
          border: 1px solid #007bff;
        }
        
        :host([disabled]) {
          opacity: 0.6;
          cursor: not-allowed;
          pointer-events: none;
        }
        
        :host(:hover:not([disabled])) {
          transform: translateY(-1px);
          box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        }
        
        :host(:focus) {
          box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.25);
        }
        
        :host([size="small"]) {
          padding: 4px 8px;
          font-size: 12px;
        }
        
        :host([size="large"]) {
          padding: 12px 24px;
          font-size: 16px;
        }
      </style>
      <slot></slot>
    `;
  }
  
  // 事件处理
  attachEventListeners() {
    this.addEventListener('click', this.handleClick);
    this.addEventListener('keydown', this.handleKeydown);
  }
  
  removeEventListeners() {
    this.removeEventListener('click', this.handleClick);
    this.removeEventListener('keydown', this.handleKeydown);
  }
  
  handleClick(event) {
    if (this.disabled) {
      event.preventDefault();
      event.stopPropagation();
      return;
    }
    
    // 触发自定义事件
    this.dispatchEvent(new CustomEvent('my-button-click', {
      detail: { variant: this.variant },
      bubbles: true,
      composed: true
    }));
  }
  
  handleKeydown(event) {
    if (event.key === 'Enter' || event.key === ' ') {
      event.preventDefault();
      this.handleClick(event);
    }
  }
  
  handleAttributeChange(name, oldValue, newValue) {
    switch (name) {
      case 'disabled':
        this.setAttribute('aria-disabled', this.disabled.toString());
        break;
      case 'variant':
      case 'size':
        // 样式会通过CSS自动更新
        break;
    }
  }
}

// 注册自定义元素
customElements.define('my-button', MyButton);

// 使用示例
/*
<my-button variant="primary" size="large">
  Click Me
</my-button>

<my-button variant="outline" disabled>
  Disabled Button
</my-button>
*/

// JavaScript中动态创建
const button = document.createElement('my-button');
button.variant = 'secondary';
button.textContent = 'Dynamic Button';
button.addEventListener('my-button-click', (event) => {
  console.log('Button clicked:', event.detail);
});
document.body.appendChild(button);

复杂自定义元素

// 复杂的卡片组件
class MyCard extends HTMLElement {
  constructor() {
    super();
    
    // 创建Shadow DOM
    this.attachShadow({ mode: 'open' });
    
    // 内部状态
    this.state = {
      expanded: false,
      loading: false
    };
  }
  
  connectedCallback() {
    this.render();
    this.attachEventListeners();
  }
  
  static get observedAttributes() {
    return ['title', 'subtitle', 'image', 'expandable', 'loading'];
  }
  
  attributeChangedCallback(name, oldValue, newValue) {
    if (this.shadowRoot) {
      this.updateContent(name, newValue);
    }
  }
  
  render() {
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          border: 1px solid #ddd;
          border-radius: 8px;
          overflow: hidden;
          box-shadow: 0 2px 4px rgba(0,0,0,0.1);
          transition: box-shadow 0.3s ease;
        }
        
        :host(:hover) {
          box-shadow: 0 4px 8px rgba(0,0,0,0.15);
        }
        
        .card-header {
          padding: 16px;
          background: #f8f9fa;
          border-bottom: 1px solid #ddd;
          display: flex;
          align-items: center;
          justify-content: space-between;
        }
        
        .card-image {
          width: 100%;
          height: 200px;
          object-fit: cover;
          display: block;
        }
        
        .card-content {
          padding: 16px;
        }
        
        .card-title {
          margin: 0 0 8px 0;
          font-size: 1.25rem;
          font-weight: bold;
        }
        
        .card-subtitle {
          margin: 0 0 16px 0;
          color: #666;
          font-size: 0.9rem;
        }
        
        .card-body {
          line-height: 1.5;
        }
        
        .card-footer {
          padding: 16px;
          background: #f8f9fa;
          border-top: 1px solid #ddd;
        }
        
        .expand-button {
          background: none;
          border: none;
          cursor: pointer;
          padding: 4px;
          border-radius: 4px;
          transition: background-color 0.2s ease;
        }
        
        .expand-button:hover {
          background-color: rgba(0,0,0,0.1);
        }
        
        .expand-icon {
          width: 16px;
          height: 16px;
          transition: transform 0.3s ease;
        }
        
        .expand-icon.expanded {
          transform: rotate(180deg);
        }
        
        .expandable-content {
          max-height: 0;
          overflow: hidden;
          transition: max-height 0.3s ease;
        }
        
        .expandable-content.expanded {
          max-height: 500px;
        }
        
        .loading-spinner {
          display: inline-block;
          width: 20px;
          height: 20px;
          border: 2px solid #f3f3f3;
          border-top: 2px solid #007bff;
          border-radius: 50%;
          animation: spin 1s linear infinite;
        }
        
        @keyframes spin {
          0% { transform: rotate(0deg); }
          100% { transform: rotate(360deg); }
        }
        
        .hidden {
          display: none;
        }
      </style>
      
      <div class="card-header">
        <div>
          <h3 class="card-title">${this.getAttribute('title') || ''}</h3>
          <p class="card-subtitle">${this.getAttribute('subtitle') || ''}</p>
        </div>
        ${this.hasAttribute('expandable') ? `
          <button class="expand-button" id="expandButton">
            <svg class="expand-icon" viewBox="0 0 24 24" fill="currentColor">
              <path d="M7 10l5 5 5-5z"/>
            </svg>
          </button>
        ` : ''}
        <div class="loading-spinner ${this.hasAttribute('loading') ? '' : 'hidden'}" id="loadingSpinner"></div>
      </div>
      
      ${this.getAttribute('image') ? `
        <img class="card-image" src="${this.getAttribute('image')}" alt="${this.getAttribute('title') || ''}" />
      ` : ''}
      
      <div class="card-content">
        <div class="card-body">
          <slot name="content"></slot>
        </div>
        
        <div class="expandable-content" id="expandableContent">
          <slot name="expandable"></slot>
        </div>
      </div>
      
      <div class="card-footer">
        <slot name="footer"></slot>
      </div>
    `;
  }
  
  attachEventListeners() {
    const expandButton = this.shadowRoot.getElementById('expandButton');
    if (expandButton) {
      expandButton.addEventListener('click', this.toggleExpanded.bind(this));
    }
  }
  
  updateContent(attributeName, value) {
    switch (attributeName) {
      case 'title':
        const titleElement = this.shadowRoot.querySelector('.card-title');
        if (titleElement) titleElement.textContent = value || '';
        break;
        
      case 'subtitle':
        const subtitleElement = this.shadowRoot.querySelector('.card-subtitle');
        if (subtitleElement) subtitleElement.textContent = value || '';
        break;
        
      case 'loading':
        const spinner = this.shadowRoot.getElementById('loadingSpinner');
        if (spinner) {
          spinner.classList.toggle('hidden', !this.hasAttribute('loading'));
        }
        break;
    }
  }
  
  toggleExpanded() {
    this.state.expanded = !this.state.expanded;
    
    const expandableContent = this.shadowRoot.getElementById('expandableContent');
    const expandIcon = this.shadowRoot.querySelector('.expand-icon');
    
    if (expandableContent) {
      expandableContent.classList.toggle('expanded', this.state.expanded);
    }
    
    if (expandIcon) {
      expandIcon.classList.toggle('expanded', this.state.expanded);
    }
    
    // 触发自定义事件
    this.dispatchEvent(new CustomEvent('card-toggle', {
      detail: { expanded: this.state.expanded },
      bubbles: true,
      composed: true
    }));
  }
  
  // 公共API
  expand() {
    if (!this.state.expanded) {
      this.toggleExpanded();
    }
  }
  
  collapse() {
    if (this.state.expanded) {
      this.toggleExpanded();
    }
  }
  
  setLoading(loading) {
    if (loading) {
      this.setAttribute('loading', '');
    } else {
      this.removeAttribute('loading');
    }
  }
}

customElements.define('my-card', MyCard);

// 使用示例
/*
<my-card title="Card Title" subtitle="Card Subtitle" expandable>
  <div slot="content">
    This is the main content of the card.
  </div>
  <div slot="expandable">
    This content is only visible when expanded.
  </div>
  <div slot="footer">
    <button>Action</button>
  </div>
</my-card>
*/

Shadow DOM深度应用

Shadow DOM封装

// 高级Shadow DOM组件
class MyModal extends HTMLElement {
  constructor() {
    super();
    
    // 创建封闭的Shadow DOM
    this.attachShadow({ mode: 'closed' });
    
    this.isOpen = false;
    this.focusableElements = [];
    this.previousFocus = null;
  }
  
  connectedCallback() {
    this.render();
    this.attachEventListeners();
    this.setupAccessibility();
  }
  
  render() {
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          position: fixed;
          top: 0;
          left: 0;
          width: 100%;
          height: 100%;
          z-index: 1000;
          display: none;
        }
        
        :host([open]) {
          display: flex;
          align-items: center;
          justify-content: center;
        }
        
        .modal-backdrop {
          position: absolute;
          top: 0;
          left: 0;
          width: 100%;
          height: 100%;
          background: rgba(0, 0, 0, 0.5);
          backdrop-filter: blur(2px);
        }
        
        .modal-container {
          position: relative;
          background: white;
          border-radius: 8px;
          box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
          max-width: 90vw;
          max-height: 90vh;
          overflow: hidden;
          transform: scale(0.9);
          opacity: 0;
          transition: all 0.3s ease;
        }
        
        :host([open]) .modal-container {
          transform: scale(1);
          opacity: 1;
        }
        
        .modal-header {
          padding: 20px;
          border-bottom: 1px solid #eee;
          display: flex;
          align-items: center;
          justify-content: space-between;
        }
        
        .modal-title {
          margin: 0;
          font-size: 1.25rem;
          font-weight: 600;
        }
        
        .modal-close {
          background: none;
          border: none;
          font-size: 24px;
          cursor: pointer;
          padding: 4px;
          border-radius: 4px;
          transition: background-color 0.2s ease;
        }
        
        .modal-close:hover {
          background-color: #f5f5f5;
        }
        
        .modal-body {
          padding: 20px;
          max-height: 60vh;
          overflow-y: auto;
        }
        
        .modal-footer {
          padding: 20px;
          border-top: 1px solid #eee;
          display: flex;
          justify-content: flex-end;
          gap: 10px;
        }
        
        /* 动画 */
        @keyframes modalFadeIn {
          from {
            opacity: 0;
            transform: scale(0.9) translateY(-50px);
          }
          to {
            opacity: 1;
            transform: scale(1) translateY(0);
          }
        }
        
        @keyframes modalFadeOut {
          from {
            opacity: 1;
            transform: scale(1) translateY(0);
          }
          to {
            opacity: 0;
            transform: scale(0.9) translateY(-50px);
          }
        }
        
        .modal-container.entering {
          animation: modalFadeIn 0.3s ease forwards;
        }
        
        .modal-container.leaving {
          animation: modalFadeOut 0.3s ease forwards;
        }
      </style>
      
      <div class="modal-backdrop" id="backdrop"></div>
      <div class="modal-container" id="container" role="dialog" aria-modal="true">
        <div class="modal-header">
          <h2 class="modal-title" id="modalTitle">
            <slot name="title">Modal Title</slot>
          </h2>
          <button class="modal-close" id="closeButton" aria-label="Close modal">
            ×
          </button>
        </div>
        <div class="modal-body">
          <slot></slot>
        </div>
        <div class="modal-footer">
          <slot name="footer"></slot>
        </div>
      </div>
    `;
  }
  
  attachEventListeners() {
    // 关闭按钮
    const closeButton = this.shadowRoot.getElementById('closeButton');
    closeButton.addEventListener('click', () => this.close());
    
    // 背景点击关闭
    const backdrop = this.shadowRoot.getElementById('backdrop');
    backdrop.addEventListener('click', () => this.close());
    
    // ESC键关闭
    document.addEventListener('keydown', (event) => {
      if (event.key === 'Escape' && this.isOpen) {
        this.close();
      }
    });
    
    // 焦点陷阱
    document.addEventListener('keydown', (event) => {
      if (event.key === 'Tab' && this.isOpen) {
        this.handleTabKey(event);
      }
    });
  }
  
  setupAccessibility() {
    const container = this.shadowRoot.getElementById('container');
    const title = this.shadowRoot.getElementById('modalTitle');
    
    // 设置ARIA属性
    container.setAttribute('aria-labelledby', 'modalTitle');
    
    // 获取所有可聚焦元素
    this.updateFocusableElements();
  }
  
  updateFocusableElements() {
    const focusableSelectors = [
      'button:not([disabled])',
      'input:not([disabled])',
      'select:not([disabled])',
      'textarea:not([disabled])',
      'a[href]',
      '[tabindex]:not([tabindex="-1"])'
    ];
    
    // Shadow DOM内的可聚焦元素
    const shadowFocusable = Array.from(
      this.shadowRoot.querySelectorAll(focusableSelectors.join(','))
    );
    
    // Light DOM内的可聚焦元素
    const lightFocusable = Array.from(
      this.querySelectorAll(focusableSelectors.join(','))
    );
    
    this.focusableElements = [...shadowFocusable, ...lightFocusable];
  }
  
  handleTabKey(event) {
    if (this.focusableElements.length === 0) return;
    
    const firstElement = this.focusableElements[0];
    const lastElement = this.focusableElements[this.focusableElements.length - 1];
    
    if (event.shiftKey) {
      // Shift + Tab
      if (document.activeElement === firstElement) {
        event.preventDefault();
        lastElement.focus();
      }
    } else {
      // Tab
      if (document.activeElement === lastElement) {
        event.preventDefault();
        firstElement.focus();
      }
    }
  }
  
  open() {
    if (this.isOpen) return;
    
    this.isOpen = true;
    this.previousFocus = document.activeElement;
    
    // 显示模态框
    this.setAttribute('open', '');
    
    // 更新可聚焦元素
    this.updateFocusableElements();
    
    // 聚焦到第一个可聚焦元素
    setTimeout(() => {
      if (this.focusableElements.length > 0) {
        this.focusableElements[0].focus();
      }
    }, 100);
    
    // 禁用页面滚动
    document.body.style.overflow = 'hidden';
    
    // 触发事件
    this.dispatchEvent(new CustomEvent('modal-open', {
      bubbles: true,
      composed: true
    }));
  }
  
  close() {
    if (!this.isOpen) return;
    
    this.isOpen = false;
    
    // 添加关闭动画
    const container = this.shadowRoot.getElementById('container');
    container.classList.add('leaving');
    
    setTimeout(() => {
      this.removeAttribute('open');
      container.classList.remove('leaving');
      
      // 恢复之前的焦点
      if (this.previousFocus) {
        this.previousFocus.focus();
      }
      
      // 恢复页面滚动
      document.body.style.overflow = '';
      
      // 触发事件
      this.dispatchEvent(new CustomEvent('modal-close', {
        bubbles: true,
        composed: true
      }));
    }, 300);
  }
  
  // 公共API
  toggle() {
    if (this.isOpen) {
      this.close();
    } else {
      this.open();
    }
  }
}

customElements.define('my-modal', MyModal);

// 使用示例
/*
<my-modal id="exampleModal">
  <span slot="title">Confirmation</span>
  <p>Are you sure you want to delete this item?</p>
  <div slot="footer">
    <button onclick="document.getElementById('exampleModal').close()">Cancel</button>
    <button onclick="handleDelete()">Delete</button>
  </div>
</my-modal>
*/

CSS自定义属性与主题

// 支持主题的组件
class ThemedButton extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }
  
  connectedCallback() {
    this.render();
  }
  
  render() {
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          /* 定义CSS自定义属性的默认值 */
          --button-bg: var(--primary-color, #007bff);
          --button-color: var(--primary-text-color, white);
          --button-border: var(--primary-border, 1px solid #007bff);
          --button-border-radius: var(--border-radius, 4px);
          --button-padding: var(--button-padding-default, 8px 16px);
          --button-font-size: var(--font-size-base, 14px);
          --button-font-weight: var(--font-weight-medium, 500);
          --button-transition: var(--transition-base, all 0.2s ease);
          
          /* 悬停状态变量 */
          --button-hover-bg: var(--primary-hover-color, #0056b3);
          --button-hover-transform: var(--button-hover-transform-default, translateY(-1px));
          
          /* 禁用状态变量 */
          --button-disabled-opacity: var(--disabled-opacity, 0.6);
          
          display: inline-block;
        }
        
        button {
          background: var(--button-bg);
          color: var(--button-color);
          border: var(--button-border);
          border-radius: var(--button-border-radius);
          padding: var(--button-padding);
          font-size: var(--button-font-size);
          font-weight: var(--button-font-weight);
          font-family: inherit;
          cursor: pointer;
          transition: var(--button-transition);
          outline: none;
          width: 100%;
        }
        
        button:hover:not(:disabled) {
          background: var(--button-hover-bg);
          transform: var(--button-hover-transform);
          box-shadow: var(--button-hover-shadow, 0 2px 4px rgba(0,0,0,0.1));
        }
        
        button:focus {
          box-shadow: var(--button-focus-shadow, 0 0 0 3px rgba(0, 123, 255, 0.25));
        }
        
        button:disabled {
          opacity: var(--button-disabled-opacity);
          cursor: not-allowed;
          transform: none;
        }
        
        /* 变体样式 */
        :host([variant="secondary"]) {
          --button-bg: var(--secondary-color, #6c757d);
          --button-hover-bg: var(--secondary-hover-color, #545b62);
        }
        
        :host([variant="success"]) {
          --button-bg: var(--success-color, #28a745);
          --button-hover-bg: var(--success-hover-color, #218838);
        }
        
        :host([variant="danger"]) {
          --button-bg: var(--danger-color, #dc3545);
          --button-hover-bg: var(--danger-hover-color, #c82333);
        }
        
        :host([variant="outline"]) {
          --button-bg: transparent;
          --button-color: var(--primary-color, #007bff);
          --button-border: 1px solid var(--primary-color, #007bff);
          --button-hover-bg: var(--primary-color, #007bff);
          --button-hover-color: white;
        }
        
        :host([variant="outline"]) button:hover:not(:disabled) {
          color: var(--button-hover-color);
        }
        
        /* 尺寸变体 */
        :host([size="small"]) {
          --button-padding: var(--button-padding-small, 4px 8px);
          --button-font-size: var(--font-size-small, 12px);
        }
        
        :host([size="large"]) {
          --button-padding: var(--button-padding-large, 12px 24px);
          --button-font-size: var(--font-size-large, 16px);
        }
        
        /* 响应式设计 */
        @media (max-width: 768px) {
          :host {
            --button-padding: var(--button-padding-mobile, 10px 16px);
            --button-font-size: var(--font-size-mobile, 16px);
          }
        }
        
        /* 深色主题支持 */
        @media (prefers-color-scheme: dark) {
          :host {
            --button-bg: var(--primary-color-dark, #0d6efd);
            --button-hover-bg: var(--primary-hover-color-dark, #0b5ed7);
          }
        }
      </style>
      
      <button>
        <slot></slot>
      </button>
    `;
  }
}

customElements.define('themed-button', ThemedButton);

// 主题管理器
class ThemeManager {
  constructor() {
    this.themes = {
      light: {
        '--primary-color': '#007bff',
        '--primary-hover-color': '#0056b3',
        '--secondary-color': '#6c757d',
        '--success-color': '#28a745',
        '--danger-color': '#dc3545',
        '--background-color': '#ffffff',
        '--text-color': '#333333',
        '--border-radius': '4px',
        '--font-size-base': '14px',
        '--transition-base': 'all 0.2s ease'
      },
      dark: {
        '--primary-color': '#0d6efd',
        '--primary-hover-color': '#0b5ed7',
        '--secondary-color': '#6c757d',
        '--success-color': '#198754',
        '--danger-color': '#dc3545',
        '--background-color': '#1a1a1a',
        '--text-color': '#ffffff',
        '--border-radius': '4px',
        '--font-size-base': '14px',
        '--transition-base': 'all 0.2s ease'
      },
      custom: {
        '--primary-color': '#8b5cf6',
        '--primary-hover-color': '#7c3aed',
        '--secondary-color': '#64748b',
        '--success-color': '#10b981',
        '--danger-color': '#ef4444',
        '--background-color': '#f8fafc',
        '--text-color': '#1e293b',
        '--border-radius': '8px',
        '--font-size-base': '16px',
        '--transition-base': 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)'
      }
    };
    
    this.currentTheme = 'light';
  }
  
  applyTheme(themeName) {
    const theme = this.themes[themeName];
    if (!theme) {
      console.warn(`Theme "${themeName}" not found`);
      return;
    }
    
    const root = document.documentElement;
    
    // 移除之前的主题类
    root.classList.remove(`theme-${this.currentTheme}`);
    
    // 应用新主题的CSS变量
    Object.entries(theme).forEach(([property, value]) => {
      root.style.setProperty(property, value);
    });
    
    // 添加新主题类
    root.classList.add(`theme-${themeName}`);
    
    this.currentTheme = themeName;
    
    // 触发主题变更事件
    document.dispatchEvent(new CustomEvent('theme-changed', {
      detail: { theme: themeName }
    }));
  }
  
  getTheme(themeName) {
    return this.themes[themeName];
  }
  
  addTheme(name, theme) {
    this.themes[name] = theme;
  }
  
  getCurrentTheme() {
    return this.currentTheme;
  }
}

// 全局主题管理器
const themeManager = new ThemeManager();

// 主题切换器组件
class ThemeSwitcher extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }
  
  connectedCallback() {
    this.render();
    this.attachEventListeners();
  }
  
  render() {
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: inline-block;
        }
        
        select {
          padding: 8px 12px;
          border: 1px solid var(--border-color, #ddd);
          border-radius: var(--border-radius, 4px);
          background: var(--background-color, white);
          color: var(--text-color, #333);
          font-family: inherit;
          font-size: var(--font-size-base, 14px);
          cursor: pointer;
        }
        
        select:focus {
          outline: none;
          border-color: var(--primary-color, #007bff);
          box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
        }
      </style>
      
      <select id="themeSelect">
        <option value="light">Light Theme</option>
        <option value="dark">Dark Theme</option>
        <option value="custom">Custom Theme</option>
      </select>
    `;
  }
  
  attachEventListeners() {
    const select = this.shadowRoot.getElementById('themeSelect');
    select.addEventListener('change', (event) => {
      themeManager.applyTheme(event.target.value);
    });
    
    // 监听主题变更事件
    document.addEventListener('theme-changed', (event) => {
      select.value = event.detail.theme;
    });
  }
}

customElements.define('theme-switcher', ThemeSwitcher);

// 使用示例
/*
<style>
  :root {
    --primary-color: #007bff;
    --primary-hover-color: #0056b3;
  }
</style>

<theme-switcher></theme-switcher>

<themed-button variant="primary" size="large">
  Primary Button
</themed-button>

<themed-button variant="outline" size="small">
  Outline Button
</themed-button>
*/

HTML Templates与Slots

模板系统

// 高级模板组件
class DataTable extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    
    this.data = [];
    this.columns = [];
    this.sortColumn = null;
    this.sortDirection = 'asc';
    this.currentPage = 1;
    this.pageSize = 10;
  }
  
  connectedCallback() {
    this.parseConfiguration();
    this.render();
    this.attachEventListeners();
  }
  
  static get observedAttributes() {
    return ['data', 'columns', 'page-size'];
  }
  
  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'data' && newValue) {
      try {
        this.data = JSON.parse(newValue);
        this.renderTable();
      } catch (e) {
        console.error('Invalid data format:', e);
      }
    }
  }
  
  parseConfiguration() {
    // 从slot中解析列配置
    const columnSlots = this.querySelectorAll('data-column');
    this.columns = Array.from(columnSlots).map(slot => ({
      key: slot.getAttribute('key'),
      title: slot.getAttribute('title') || slot.getAttribute('key'),
      sortable: slot.hasAttribute('sortable'),
      width: slot.getAttribute('width'),
      align: slot.getAttribute('align') || 'left',
      formatter: slot.getAttribute('formatter'),
      template: slot.innerHTML.trim()
    }));
  }
  
  render() {
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          font-family: var(--font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
        }
        
        .table-container {
          overflow-x: auto;
          border: 1px solid var(--border-color, #e1e5e9);
          border-radius: var(--border-radius, 4px);
        }
        
        table {
          width: 100%;
          border-collapse: collapse;
          background: var(--table-bg, white);
        }
        
        th, td {
          padding: var(--cell-padding, 12px);
          text-align: left;
          border-bottom: 1px solid var(--border-color, #e1e5e9);
        }
        
        th {
          background: var(--header-bg, #f8f9fa);
          font-weight: var(--header-font-weight, 600);
          color: var(--header-color, #495057);
          position: sticky;
          top: 0;
          z-index: 1;
        }
        
        th.sortable {
          cursor: pointer;
          user-select: none;
          position: relative;
        }
        
        th.sortable:hover {
          background: var(--header-hover-bg, #e9ecef);
        }
        
        .sort-indicator {
          display: inline-block;
          margin-left: 4px;
          opacity: 0.5;
          transition: opacity 0.2s ease;
        }
        
        th.sorted .sort-indicator {
          opacity: 1;
        }
        
        tr:hover td {
          background: var(--row-hover-bg, #f8f9fa);
        }
        
        .pagination {
          display: flex;
          justify-content: space-between;
          align-items: center;
          padding: var(--pagination-padding, 16px);
          background: var(--pagination-bg, #f8f9fa);
          border-top: 1px solid var(--border-color, #e1e5e9);
        }
        
        .pagination-info {
          color: var(--text-muted, #6c757d);
          font-size: var(--font-size-small, 14px);
        }
        
        .pagination-controls {
          display: flex;
          gap: 8px;
        }
        
        .pagination-button {
          padding: 6px 12px;
          border: 1px solid var(--border-color, #e1e5e9);
          background: var(--button-bg, white);
          color: var(--button-color, #495057);
          cursor: pointer;
          border-radius: var(--border-radius-small, 3px);
          font-size: var(--font-size-small, 14px);
          transition: all 0.2s ease;
        }
        
        .pagination-button:hover:not(:disabled) {
          background: var(--button-hover-bg, #e9ecef);
        }
        
        .pagination-button:disabled {
          opacity: 0.5;
          cursor: not-allowed;
        }
        
        .pagination-button.active {
          background: var(--primary-color, #007bff);
          color: white;
          border-color: var(--primary-color, #007bff);
        }
        
        .empty-state {
          text-align: center;
          padding: var(--empty-state-padding, 40px 20px);
          color: var(--text-muted, #6c757d);
        }
        
        .loading {
          text-align: center;
          padding: var(--loading-padding, 40px 20px);
        }
        
        .loading-spinner {
          display: inline-block;
          width: 20px;
          height: 20px;
          border: 2px solid #f3f3f3;
          border-top: 2px solid var(--primary-color, #007bff);
          border-radius: 50%;
          animation: spin 1s linear infinite;
        }
        
        @keyframes spin {
          0% { transform: rotate(0deg); }
          100% { transform: rotate(360deg); }
        }
      </style>
      
      <div class="table-container">
        <table id="dataTable">
          <thead id="tableHead"></thead>
          <tbody id="tableBody"></tbody>
        </table>
      </div>
      
      <div class="pagination" id="pagination"></div>
    `;
    
    this.renderTable();
  }
  
  renderTable() {
    this.renderHeader();
    this.renderBody();
    this.renderPagination();
  }
  
  renderHeader() {
    const thead = this.shadowRoot.getElementById('tableHead');
    const headerRow = document.createElement('tr');
    
    this.columns.forEach(column => {
      const th = document.createElement('th');
      th.textContent = column.title;
      
      if (column.width) {
        th.style.width = column.width;
      }
      
      if (column.align) {
        th.style.textAlign = column.align;
      }
      
      if (column.sortable) {
        th.classList.add('sortable');
        th.addEventListener('click', () => this.handleSort(column.key));
        
        const indicator = document.createElement('span');
        indicator.className = 'sort-indicator';
        indicator.innerHTML = this.sortColumn === column.key 
          ? (this.sortDirection === 'asc' ? '↑' : '↓')
          : '↕';
        
        if (this.sortColumn === column.key) {
          th.classList.add('sorted');
        }
        
        th.appendChild(indicator);
      }
      
      headerRow.appendChild(th);
    });
    
    thead.innerHTML = '';
    thead.appendChild(headerRow);
  }
  
  renderBody() {
    const tbody = this.shadowRoot.getElementById('tableBody');
    tbody.innerHTML = '';
    
    if (this.data.length === 0) {
      const emptyRow = document.createElement('tr');
      const emptyCell = document.createElement('td');
      emptyCell.colSpan = this.columns.length;
      emptyCell.className = 'empty-state';
      emptyCell.innerHTML = '<slot name="empty">No data available</slot>';
      emptyRow.appendChild(emptyCell);
      tbody.appendChild(emptyRow);
      return;
    }
    
    const sortedData = this.getSortedData();
    const paginatedData = this.getPaginatedData(sortedData);
    
    paginatedData.forEach((row, index) => {
      const tr = document.createElement('tr');
      
      this.columns.forEach(column => {
        const td = document.createElement('td');
        
        if (column.align) {
          td.style.textAlign = column.align;
        }
        
        const value = this.getCellValue(row, column.key);
        
        if (column.template) {
          // 使用模板渲染
          td.innerHTML = this.renderTemplate(column.template, row, value);
        } else if (column.formatter) {
          // 使用格式化函数
          td.textContent = this.formatValue(value, column.formatter);
        } else {
          td.textContent = value;
        }
        
        tr.appendChild(td);
      });
      
      tbody.appendChild(tr);
    });
  }
  
  renderPagination() {
    const pagination = this.shadowRoot.getElementById('pagination');
    const totalPages = Math.ceil(this.data.length / this.pageSize);
    
    if (totalPages <= 1) {
      pagination.style.display = 'none';
      return;
    }
    
    pagination.style.display = 'flex';
    
    const start = (this.currentPage - 1) * this.pageSize + 1;
    const end = Math.min(this.currentPage * this.pageSize, this.data.length);
    
    pagination.innerHTML = `
      <div class="pagination-info">
        Showing ${start}-${end} of ${this.data.length} entries
      </div>
      <div class="pagination-controls" id="paginationControls"></div>
    `;
    
    const controls = pagination.querySelector('#paginationControls');
    
    // Previous button
    const prevButton = document.createElement('button');
    prevButton.className = 'pagination-button';
    prevButton.textContent = 'Previous';
    prevButton.disabled = this.currentPage === 1;
    prevButton.addEventListener('click', () => this.goToPage(this.currentPage - 1));
    controls.appendChild(prevButton);
    
    // Page numbers
    const startPage = Math.max(1, this.currentPage - 2);
    const endPage = Math.min(totalPages, this.currentPage + 2);
    
    for (let i = startPage; i <= endPage; i++) {
      const pageButton = document.createElement('button');
      pageButton.className = 'pagination-button';
      pageButton.textContent = i;
      
      if (i === this.currentPage) {
        pageButton.classList.add('active');
      }
      
      pageButton.addEventListener('click', () => this.goToPage(i));
      controls.appendChild(pageButton);
    }
    
    // Next button
    const nextButton = document.createElement('button');
    nextButton.className = 'pagination-button';
    nextButton.textContent = 'Next';
    nextButton.disabled = this.currentPage === totalPages;
    nextButton.addEventListener('click', () => this.goToPage(this.currentPage + 1));
    controls.appendChild(nextButton);
  }
  
  getCellValue(row, key) {
    return key.split('.').reduce((obj, k) => obj && obj[k], row);
  }
  
  renderTemplate(template, row, value) {
    return template
      .replace(/\{\{(\w+(?:\.\w+)*)\}\}/g, (match, key) => {
        return this.getCellValue(row, key) || '';
      })
      .replace(/\{\{value\}\}/g, value || '');
  }
  
  formatValue(value, formatter) {
    switch (formatter) {
      case 'currency':
        return new Intl.NumberFormat('en-US', {
          style: 'currency',
          currency: 'USD'
        }).format(value);
      case 'date':
        return new Date(value).toLocaleDateString();
      case 'datetime':
        return new Date(value).toLocaleString();
      default:
        return value;
    }
  }
  
  getSortedData() {
    if (!this.sortColumn) return this.data;
    
    return [...this.data].sort((a, b) => {
      const aVal = this.getCellValue(a, this.sortColumn);
      const bVal = this.getCellValue(b, this.sortColumn);
      
      let comparison = 0;
      if (aVal > bVal) comparison = 1;
      if (aVal < bVal) comparison = -1;
      
      return this.sortDirection === 'desc' ? -comparison : comparison;
    });
  }
  
  getPaginatedData(data) {
    const start = (this.currentPage - 1) * this.pageSize;
    const end = start + this.pageSize;
    return data.slice(start, end);
  }
  
  handleSort(columnKey) {
    if (this.sortColumn === columnKey) {
      this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
    } else {
      this.sortColumn = columnKey;
      this.sortDirection = 'asc';
    }
    
    this.renderTable();
    
    this.dispatchEvent(new CustomEvent('sort-changed', {
      detail: { column: this.sortColumn, direction: this.sortDirection },
      bubbles: true,
      composed: true
    }));
  }
  
  goToPage(page) {
    const totalPages = Math.ceil(this.data.length / this.pageSize);
    if (page < 1 || page > totalPages) return;
    
    this.currentPage = page;
    this.renderTable();
    
    this.dispatchEvent(new CustomEvent('page-changed', {
      detail: { page: this.currentPage },
      bubbles: true,
      composed: true
    }));
  }
  
  attachEventListeners() {
    // 监听数据变化
    this.addEventListener('data-updated', () => {
      this.renderTable();
    });
  }
  
  // 公共API
  setData(data) {
    this.data = data;
    this.currentPage = 1;
    this.renderTable();
  }
  
  addRow(row) {
    this.data.push(row);
    this.renderTable();
  }
  
  removeRow(index) {
    this.data.splice(index, 1);
    this.renderTable();
  }
  
  updateRow(index, row) {
    this.data[index] = { ...this.data[index], ...row };
    this.renderTable();
  }
}

customElements.define('data-table', DataTable);

// 使用示例
/*
<data-table page-size="5">
  <data-column key="id" title="ID" sortable width="80px"></data-column>
  <data-column key="name" title="Name" sortable></data-column>
  <data-column key="email" title="Email"></data-column>
  <data-column key="status" title="Status" template='<span class="status-{{status}}">{{status}}</span>'></data-column>
  <data-column key="createdAt" title="Created" formatter="date" sortable></data-column>
  <data-column key="actions" title="Actions" template='<button onclick="editUser({{id}})">Edit</button>'></data-column>
  
  <div slot="empty">
    <p>No users found.</p>
    <button onclick="loadUsers()">Load Users</button>
  </div>
</data-table>

<script>
const table = document.querySelector('data-table');
table.setData([
  { id: 1, name: 'John Doe', email: 'john@example.com', status: 'active', createdAt: '2023-01-15' },
  { id: 2, name: 'Jane Smith', email: 'jane@example.com', status: 'inactive', createdAt: '2023-02-20' }
]);
</script>
*/

总结

Web Components的核心要点:

  1. Custom Elements:定义可复用的自定义HTML元素
  2. Shadow DOM:提供样式和DOM封装,避免全局污染
  3. HTML Templates:声明式模板和内容分发机制
  4. 生命周期:connectedCallback、disconnectedCallback等钩子
  5. 最佳实践:可访问性、主题支持、事件处理

Web Components为我们提供了原生的组件化解决方案,能够创建真正可复用、框架无关的UI组件,是现代Web开发的重要技术。