15.4 Web Components 与 Shadow DOM

Web Components 基础

核心概念与技术栈

Web Components 是一套允许创建可重用、封装的自定义HTML元素的技术,包含三个主要技术:

  1. Custom Elements - 定义自定义元素及其行为
  2. Shadow DOM - 封装样式和标记结构
  3. HTML Templates - 定义可复用的标记模板

基本组件定义

class MyComponent extends HTMLElement {
  constructor() {
    super();
    
    // 创建Shadow DOM
    this.attachShadow({ mode: 'open' });
    
    // 添加HTML内容
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          border: 1px solid #ccc;
          padding: 16px;
        }
        h3 {
          color: var(--title-color, #333);
        }
      </style>
      <h3><slot name="title">默认标题</slot></h3>
      <div class="content">
        <slot></slot>
      </div>
    `;
  }
  
  // 定义可观察属性
  static get observedAttributes() {
    return ['disabled', 'theme'];
  }
  
  // 属性变化回调
  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'disabled') {
      this.shadowRoot.querySelector('button').disabled = newValue !== null;
    }
  }
  
  // 元素插入DOM时调用
  connectedCallback() {
    console.log('Custom element added to page');
  }
  
  // 元素从DOM移除时调用
  disconnectedCallback() {
    console.log('Custom element removed from page');
  }
}

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

Shadow DOM 深度解析

封装样式与标记

Shadow DOM 提供了样式和标记的封装,防止外部样式影响组件内部。

class StyledButton extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    
    shadow.innerHTML = `
      <style>
        button {
          background: var(--button-bg, #6200ee);
          color: white;
          border: none;
          padding: 8px 16px;
          border-radius: 4px;
          cursor: pointer;
          font-size: 14px;
        }
        
        button:disabled {
          opacity: 0.5;
          cursor: not-allowed;
        }
      </style>
      <button><slot></slot></button>
    `;
  }
}
customElements.define('styled-button', StyledButton);

插槽(Slot)机制

插槽允许组件使用者自定义部分内容。

<!-- 使用组件 -->
<user-card>
  <span slot="name">张三</span>
  <span slot="email">[email protected]</span>
  <div>其他信息...</div>
</user-card>

<!-- 组件定义 -->
<script>
class UserCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <div class="card">
        <div class="header">
          <slot name="name"></slot>
        </div>
        <div class="body">
          <slot name="email"></slot>
          <slot></slot>
        </div>
      </div>
    `;
  }
}
customElements.define('user-card', UserCard);
</script>

高级组件开发

响应式属性与数据绑定

class DataTable extends HTMLElement {
  static get observedAttributes() {
    return ['data'];
  }
  
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._data = [];
  }
  
  get data() {
    return this._data;
  }
  
  set data(value) {
    this._data = value;
    this.render();
  }
  
  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'data') {
      this.data = JSON.parse(newValue);
    }
  }
  
  render() {
    this.shadowRoot.innerHTML = `
      <style>
        table {
          width: 100%;
          border-collapse: collapse;
        }
        th, td {
          border: 1px solid #ddd;
          padding: 8px;
          text-align: left;
        }
      </style>
      <table>
        <thead>
          <tr>
            ${Object.keys(this.data[0] || {}).map(key => `<th>${key}</th>`).join('')}
          </tr>
        </thead>
        <tbody>
          ${this.data.map(row => `
            <tr>
              ${Object.values(row).map(val => `<td>${val}</td>`).join('')}
            </tr>
          `).join('')}
        </tbody>
      </table>
    `;
  }
}
customElements.define('data-table', DataTable);

组件生命周期管理

class LifecycleDemo extends HTMLElement {
  constructor() {
    super();
    console.log('1. Constructor called');
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `<p>生命周期演示</p>`;
  }
  
  connectedCallback() {
    console.log('2. Component added to DOM');
    this._interval = setInterval(() => {
      this.dispatchEvent(new CustomEvent('timeupdate', {
        detail: { time: new Date() }
      }));
    }, 1000);
  }
  
  disconnectedCallback() {
    console.log('3. Component removed from DOM');
    clearInterval(this._interval);
  }
  
  adoptedCallback() {
    console.log('4. Component moved to new document');
  }
  
  attributeChangedCallback(name, oldValue, newValue) {
    console.log(`5. Attribute ${name} changed from ${oldValue} to ${newValue}`);
  }
}
customElements.define('lifecycle-demo', LifecycleDemo);

组件通信模式

事件通信

class CustomInput extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <input type="text">
    `;
    
    this.shadowRoot.querySelector('input').addEventListener('input', (e) => {
      this.dispatchEvent(new CustomEvent('custom-change', {
        detail: { value: e.target.value },
        bubbles: true,
        composed: true  // 允许事件穿过Shadow DOM边界
      }));
    });
  }
}
customElements.define('custom-input', CustomInput);

属性与属性反射

class ToggleSwitch extends HTMLElement {
  static get observedAttributes() {
    return ['checked'];
  }
  
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        .switch {
          position: relative;
          display: inline-block;
          width: 60px;
          height: 34px;
        }
        .slider {
          position: absolute;
          cursor: pointer;
          top: 0;
          left: 0;
          right: 0;
          bottom: 0;
          background-color: #ccc;
          transition: .4s;
          border-radius: 34px;
        }
        .slider:before {
          position: absolute;
          content: "";
          height: 26px;
          width: 26px;
          left: 4px;
          bottom: 4px;
          background-color: white;
          transition: .4s;
          border-radius: 50%;
        }
        input:checked + .slider {
          background-color: #2196F3;
        }
        input:checked + .slider:before {
          transform: translateX(26px);
        }
        input {
          display: none;
        }
      </style>
      <label class="switch">
        <input type="checkbox">
        <span class="slider"></span>
      </label>
    `;
    
    this._input = this.shadowRoot.querySelector('input');
    this._input.addEventListener('change', () => {
      this.checked = this._input.checked;
    });
  }
  
  get checked() {
    return this.hasAttribute('checked');
  }
  
  set checked(value) {
    if (value) {
      this.setAttribute('checked', '');
    } else {
      this.removeAttribute('checked');
    }
  }
  
  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'checked') {
      this._input.checked = this.checked;
    }
  }
}
customElements.define('toggle-switch', ToggleSwitch);

实战案例

可复用的模态对话框

class ModalDialog extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: none;
          position: fixed;
          top: 0;
          left: 0;
          width: 100%;
          height: 100%;
          background-color: rgba(0,0,0,0.5);
          z-index: 1000;
          align-items: center;
          justify-content: center;
        }
        
        .dialog {
          background: white;
          border-radius: 8px;
          padding: 20px;
          min-width: 300px;
          box-shadow: 0 4px 8px rgba(0,0,0,0.1);
        }
        
        .header {
          display: flex;
          justify-content: space-between;
          align-items: center;
          margin-bottom: 16px;
        }
        
        .close-btn {
          background: none;
          border: none;
          font-size: 20px;
          cursor: pointer;
        }
      </style>
      <div class="dialog">
        <div class="header">
          <h2><slot name="title">对话框标题</slot></h2>
          <button class="close-btn">&times;</button>
        </div>
        <div class="content">
          <slot></slot>
        </div>
      </div>
    `;
    
    this.shadowRoot.querySelector('.close-btn').addEventListener('click', () => {
      this.hide();
    });
  }
  
  show() {
    this.style.display = 'flex';
    this.dispatchEvent(new Event('show'));
  }
  
  hide() {
    this.style.display = 'none';
    this.dispatchEvent(new Event('hide'));
  }
}
customElements.define('modal-dialog', ModalDialog);

动态表单生成器

class FormBuilder extends HTMLElement {
  static get observedAttributes() {
    return ['schema'];
  }
  
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._schema = [];
  }
  
  get schema() {
    return this._schema;
  }
  
  set schema(value) {
    this._schema = value;
    this.renderForm();
  }
  
  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'schema') {
      this.schema = JSON.parse(newValue);
    }
  }
  
  renderForm() {
    this.shadowRoot.innerHTML = `
      <style>
        form {
          display: grid;
          gap: 16px;
          max-width: 600px;
          margin: 0 auto;
        }
        
        label {
          display: block;
          margin-bottom: 4px;
          font-weight: bold;
        }
        
        input, select, textarea {
          width: 100%;
          padding: 8px;
          border: 1px solid #ddd;
          border-radius: 4px;
          box-sizing: border-box;
        }
        
        button {
          background-color: #4CAF50;
          color: white;
          padding: 10px 15px;
          border: none;
          border-radius: 4px;
          cursor: pointer;
        }
      </style>
      <form>
        ${this.schema.map(field => `
          <div class="field">
            <label for="${field.id}">${field.label}</label>
            ${this.renderField(field)}
          </div>
        `).join('')}
        <button type="submit">提交</button>
      </form>
    `;
    
    this.shadowRoot.querySelector('form').addEventListener('submit', e => {
      e.preventDefault();
      const formData = {};
      this.schema.forEach(field => {
        formData[field.name] = this.shadowRoot.getElementById(field.id).value;
      });
      this.dispatchEvent(new CustomEvent('form-submit', {
        detail: formData
      }));
    });
  }
  
  renderField(field) {
    switch (field.type) {
      case 'select':
        return `
          <select id="${field.id}" name="${field.name}">
            ${field.options.map(opt => `
              <option value="${opt.value}">${opt.label}</option>
            `).join('')}
          </select>
        `;
      case 'textarea':
        return `<textarea id="${field.id}" name="${field.name}"></textarea>`;
      default:
        return `<input type="${field.type}" id="${field.id}" name="${field.name}">`;
    }
  }
}
customElements.define('form-builder', FormBuilder);

性能优化与最佳实践

使用模板元素

class TemplateDemo extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    
    // 获取模板
    const template = document.getElementById('template-demo');
    const content = template.content.cloneNode(true);
    this.shadowRoot.appendChild(content);
  }
}

// HTML中的模板
/*
<template id="template-demo">
  <style>
    /* 样式定义 */
  </style>
  <div class="container">
    <slot name="header"></slot>
    <div class="content">
      <slot></slot>
    </div>
  </div>
</template>
*/
customElements.define('template-demo', TemplateDemo);

延迟加载组件资源

class LazyComponent extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = '<div>加载中...</div>';
    
    // 使用IntersectionObserver延迟加载
    const observer = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting) {
        observer.unobserve(this);
        this.loadResources();
      }
    });
    
    observer.observe(this);
  }
  
  async loadResources() {
    // 动态加载CSS
    const style = document.createElement('link');
    style.rel = 'stylesheet';
    style.href = 'lazy-component.css';
    this.shadowRoot.appendChild(style);
    
    // 动态加载数据
    const response = await fetch('data.json');
    const data = await response.json();
    
    this.renderContent(data);
  }
  
  renderContent(data) {
    this.shadowRoot.innerHTML = `
      <style>
        /* 内联关键CSS */
      </style>
      <div class="component">
        <!-- 渲染数据 -->
      </div>
    `;
  }
}
customElements.define('lazy-component', LazyComponent);

现代Web组件生态

与框架集成

  1. React中使用Web Components

    function ReactComponent() {
      const [value, setValue] = useState('');
      
      return (
        <div>
          <custom-input 
            onCustomChange={(e) => setValue(e.detail.value)}
          ></custom-input>
          <p>当前值: {value}</p>
        </div>
      );
    }
    
  2. Vue中使用Web Components

    <template>
      <div>
        <custom-input @custom-change="handleChange"></custom-input>
        <p>当前值: {{ value }}</p>
      </div>
    </template>
    
    <script>
    export default {
      data() {
        return { value: '' }
      },
      methods: {
        handleChange(event) {
          this.value = event.detail.value;
        }
      }
    }
    </script>
    

开源工具与库

  1. LitElement - 轻量级Web组件基类

    import { LitElement, html, css } from 'lit-element';
    
    class LitDemo extends LitElement {
      static get properties() {
        return { count: { type: Number } };
      }
      
      static get styles() {
        return css`
          button { color: blue; }
        `;
      }
      
      constructor() {
        super();
        this.count = 0;
      }
      
      render() {
        return html`
          <button @click=${() => this.count++}>
            点击次数: ${this.count}
          </button>
        `;
      }
    }
    customElements.define('lit-demo', LitDemo);
    
  2. Stencil - 用于构建Web组件的编译器

  3. FAST - Microsoft的Web组件框架

浏览器兼容性与Polyfill

对于不支持Web Components的旧浏览器,可以使用polyfill:

<!-- 加载Web Components polyfill -->
<script src="https://unpkg.com/@webcomponents/[email protected]/webcomponents-bundle.js"></script>

<!-- 使用type="module"和nomodule实现渐进增强 -->
<script type="module">
  // 现代浏览器加载的代码
  import './modern-component.js';
</script>

<script nomodule>
  // 旧版浏览器加载的代码
  const script = document.createElement('script');
  script.src = './legacy-component.js';
  document.head.appendChild(script);
</script>
#前端开发 分享于 2025-03-25

【 内容由 AI 共享,不代表本站观点,请谨慎参考 】