Skip to content

Web Components 与现代框架集成

Web Components 作为浏览器原生的组件化解决方案,为我们提供了创建可复用、封装良好的自定义元素的能力。随着现代前端框架的普及,如何让 Web Components 与 Vue、React 等框架和谐共存,成为了一个重要的技术话题。本文将深入探讨这一领域的最佳实践。

目录

Web Components 基础回顾

核心技术栈

Web Components 由四个主要技术组成:

  1. Custom Elements:自定义 HTML 元素
  2. Shadow DOM:封装的 DOM 树
  3. HTML Templates:可复用的 HTML 模板
  4. ES Modules:JavaScript 模块化
javascript
// 基础 Web Component 示例
class MyButton extends HTMLElement {
  constructor() {
    super();
    
    // 创建 Shadow DOM
    this.attachShadow({ mode: 'open' });
    
    // 定义模板
    this.shadowRoot.innerHTML = `
      <style>
        button {
          background: var(--button-bg, #007bff);
          color: var(--button-color, white);
          border: none;
          padding: 8px 16px;
          border-radius: 4px;
          cursor: pointer;
          font-family: inherit;
        }
        
        button:hover {
          opacity: 0.9;
        }
        
        button:disabled {
          opacity: 0.5;
          cursor: not-allowed;
        }
      </style>
      <button>
        <slot></slot>
      </button>
    `;
    
    // 绑定事件
    this.button = this.shadowRoot.querySelector('button');
    this.button.addEventListener('click', this.handleClick.bind(this));
  }
  
  // 观察属性变化
  static get observedAttributes() {
    return ['disabled', 'variant'];
  }
  
  attributeChangedCallback(name, oldValue, newValue) {
    switch (name) {
      case 'disabled':
        this.button.disabled = newValue !== null;
        break;
      case 'variant':
        this.updateVariant(newValue);
        break;
    }
  }
  
  handleClick(event) {
    if (this.disabled) return;
    
    // 派发自定义事件
    this.dispatchEvent(new CustomEvent('my-click', {
      detail: { originalEvent: event },
      bubbles: true
    }));
  }
  
  updateVariant(variant) {
    const colors = {
      primary: { bg: '#007bff', color: 'white' },
      secondary: { bg: '#6c757d', color: 'white' },
      success: { bg: '#28a745', color: 'white' }
    };
    
    const color = colors[variant] || colors.primary;
    this.style.setProperty('--button-bg', color.bg);
    this.style.setProperty('--button-color', color.color);
  }
  
  get disabled() {
    return this.hasAttribute('disabled');
  }
  
  set disabled(value) {
    if (value) {
      this.setAttribute('disabled', '');
    } else {
      this.removeAttribute('disabled');
    }
  }
}

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

使用示例

html
<!-- 基本使用 -->
<my-button>点击我</my-button>

<!-- 带属性 -->
<my-button variant="success" disabled>成功按钮</my-button>

<!-- 监听事件 -->
<my-button id="myBtn">自定义事件</my-button>

<script>
document.getElementById('myBtn').addEventListener('my-click', (event) => {
  console.log('按钮被点击了!', event.detail);
});
</script>

与 Vue 集成

1. 在 Vue 中使用 Web Components

Vue 3 对 Web Components 有很好的支持,可以直接在模板中使用自定义元素。

javascript
// main.js - 配置 Vue 识别自定义元素
import { createApp } from 'vue';
import App from './App.vue';

const app = createApp(App);

// 配置自定义元素
app.config.compilerOptions.isCustomElement = (tag) => {
  return tag.startsWith('my-') || tag.includes('-');
};

app.mount('#app');
vue
<!-- Vue 组件中使用 Web Components -->
<template>
  <div class="vue-app">
    <h1>Vue 应用</h1>
    
    <!-- 直接使用 Web Component -->
    <my-button 
      :variant="buttonVariant"
      :disabled="isDisabled"
      @my-click="handleButtonClick"
    >
      {{ buttonText }}
    </my-button>
    
    <!-- 动态绑定属性 -->
    <my-button 
      v-for="btn in buttons"
      :key="btn.id"
      :variant="btn.variant"
      @my-click="handleClick(btn.id, $event)"
    >
      {{ btn.text }}
    </my-button>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue';

// 导入 Web Component
import './components/MyButton.js';

const buttonVariant = ref('primary');
const isDisabled = ref(false);
const buttonText = ref('Vue 中的按钮');

const buttons = ref([
  { id: 1, text: '按钮 1', variant: 'primary' },
  { id: 2, text: '按钮 2', variant: 'secondary' },
  { id: 3, text: '按钮 3', variant: 'success' }
]);

const handleButtonClick = (event) => {
  console.log('Web Component 事件:', event.detail);
};

const handleClick = (id, event) => {
  console.log(`按钮 ${id} 被点击:`, event.detail);
};

// 动态修改 Web Component 属性
const toggleVariant = () => {
  buttonVariant.value = buttonVariant.value === 'primary' ? 'success' : 'primary';
};
</script>

2. 将 Vue 组件转换为 Web Components

Vue 提供了 defineCustomElement API 来将 Vue 组件转换为 Web Components。

javascript
// VueButton.ce.vue - 专门用于转换为 Web Component 的 Vue 组件
<template>
  <button 
    :class="buttonClass"
    :disabled="disabled"
    @click="handleClick"
  >
    <slot></slot>
  </button>
</template>

<script setup>
import { computed } from 'vue';

// 定义 props(会自动转换为 attributes)
const props = defineProps({
  variant: {
    type: String,
    default: 'primary'
  },
  disabled: {
    type: Boolean,
    default: false
  },
  size: {
    type: String,
    default: 'medium'
  }
});

// 定义 emits(会自动转换为自定义事件)
const emit = defineEmits(['click']);

const buttonClass = computed(() => {
  return [
    'vue-button',
    `vue-button--${props.variant}`,
    `vue-button--${props.size}`,
    {
      'vue-button--disabled': props.disabled
    }
  ];
});

const handleClick = (event) => {
  if (props.disabled) return;
  emit('click', event);
};
</script>

<style scoped>
.vue-button {
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-family: inherit;
  transition: all 0.2s ease;
}

.vue-button--primary {
  background: #007bff;
  color: white;
}

.vue-button--secondary {
  background: #6c757d;
  color: white;
}

.vue-button--success {
  background: #28a745;
  color: white;
}

.vue-button--small {
  padding: 4px 8px;
  font-size: 0.875rem;
}

.vue-button--large {
  padding: 12px 24px;
  font-size: 1.125rem;
}

.vue-button:hover:not(.vue-button--disabled) {
  opacity: 0.9;
  transform: translateY(-1px);
}

.vue-button--disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
</style>
javascript
// 转换为 Web Component
import { defineCustomElement } from 'vue';
import VueButton from './VueButton.ce.vue';

// 转换并注册
const VueButtonElement = defineCustomElement(VueButton);
customElements.define('vue-button', VueButtonElement);

// 或者批量转换
import { defineCustomElement } from 'vue';

const components = {
  'vue-button': () => import('./VueButton.ce.vue'),
  'vue-card': () => import('./VueCard.ce.vue'),
  'vue-modal': () => import('./VueModal.ce.vue')
};

Object.entries(components).forEach(([name, component]) => {
  customElements.define(name, defineCustomElement(component));
});

3. Vue 与 Web Components 的双向通信

vue
<!-- 父 Vue 组件 -->
<template>
  <div class="communication-demo">
    <h2>Vue 与 Web Components 通信</h2>
    
    <!-- 传递数据到 Web Component -->
    <data-display 
      ref="dataDisplay"
      :data="JSON.stringify(sharedData)"
      @data-updated="handleDataUpdate"
    ></data-display>
    
    <!-- Vue 组件控制面板 -->
    <div class="controls">
      <input 
        v-model="newItem" 
        placeholder="添加新项目"
        @keyup.enter="addItem"
      />
      <button @click="addItem">添加</button>
      <button @click="clearData">清空</button>
    </div>
    
    <!-- 显示从 Web Component 接收的数据 -->
    <div class="received-data">
      <h3>从 Web Component 接收的数据:</h3>
      <pre>{{ JSON.stringify(receivedData, null, 2) }}</pre>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue';
import './components/DataDisplay.js';

const sharedData = ref({
  items: ['项目 1', '项目 2', '项目 3'],
  timestamp: Date.now()
});

const receivedData = ref(null);
const newItem = ref('');
const dataDisplay = ref(null);

const addItem = () => {
  if (newItem.value.trim()) {
    sharedData.value.items.push(newItem.value.trim());
    sharedData.value.timestamp = Date.now();
    newItem.value = '';
  }
};

const clearData = () => {
  sharedData.value.items = [];
  sharedData.value.timestamp = Date.now();
};

const handleDataUpdate = (event) => {
  receivedData.value = event.detail;
  console.log('接收到 Web Component 数据:', event.detail);
};

onMounted(() => {
  // 直接调用 Web Component 方法
  setTimeout(() => {
    if (dataDisplay.value && dataDisplay.value.refresh) {
      dataDisplay.value.refresh();
    }
  }, 1000);
});
</script>

与 React 集成

1. 在 React 中使用 Web Components

React 与 Web Components 的集成需要一些额外的处理,特别是事件和属性的绑定。

jsx
// React 中使用 Web Components
import React, { useRef, useEffect, useState } from 'react';

// 导入 Web Component
import './components/MyButton.js';

const ReactApp = () => {
  const [buttonVariant, setButtonVariant] = useState('primary');
  const [isDisabled, setIsDisabled] = useState(false);
  const buttonRef = useRef(null);

  // 处理 Web Component 事件
  useEffect(() => {
    const button = buttonRef.current;
    
    const handleClick = (event) => {
      console.log('Web Component 点击事件:', event.detail);
    };

    if (button) {
      button.addEventListener('my-click', handleClick);
      
      return () => {
        button.removeEventListener('my-click', handleClick);
      };
    }
  }, []);

  // 同步 React state 到 Web Component 属性
  useEffect(() => {
    if (buttonRef.current) {
      buttonRef.current.variant = buttonVariant;
      buttonRef.current.disabled = isDisabled;
    }
  }, [buttonVariant, isDisabled]);

  return (
    <div className="react-app">
      <h1>React 应用</h1>
      
      {/* 使用 Web Component */}
      <my-button 
        ref={buttonRef}
        variant={buttonVariant}
        disabled={isDisabled ? '' : null}
      >
        React 中的按钮
      </my-button>
      
      {/* React 控制面板 */}
      <div className="controls">
        <select 
          value={buttonVariant} 
          onChange={(e) => setButtonVariant(e.target.value)}
        >
          <option value="primary">Primary</option>
          <option value="secondary">Secondary</option>
          <option value="success">Success</option>
        </select>
        
        <label>
          <input 
            type="checkbox"
            checked={isDisabled}
            onChange={(e) => setIsDisabled(e.target.checked)}
          />
          禁用按钮
        </label>
      </div>
    </div>
  );
};

export default ReactApp;

2. React Hook 封装 Web Components

jsx
// useWebComponent Hook
import { useRef, useEffect, useCallback } from 'react';

const useWebComponent = (tagName, props = {}, events = {}) => {
  const elementRef = useRef(null);

  // 同步属性
  useEffect(() => {
    const element = elementRef.current;
    if (!element) return;

    Object.entries(props).forEach(([key, value]) => {
      if (element[key] !== value) {
        element[key] = value;
      }
    });
  }, [props]);

  // 绑定事件
  useEffect(() => {
    const element = elementRef.current;
    if (!element) return;

    const eventHandlers = [];

    Object.entries(events).forEach(([eventName, handler]) => {
      element.addEventListener(eventName, handler);
      eventHandlers.push([eventName, handler]);
    });

    return () => {
      eventHandlers.forEach(([eventName, handler]) => {
        element.removeEventListener(eventName, handler);
      });
    };
  }, [events]);

  // 调用方法的辅助函数
  const callMethod = useCallback((methodName, ...args) => {
    const element = elementRef.current;
    if (element && typeof element[methodName] === 'function') {
      return element[methodName](...args);
    }
  }, []);

  return [elementRef, callMethod];
};

// 使用 Hook
const MyComponent = () => {
  const [buttonRef, callButtonMethod] = useWebComponent(
    'my-button',
    {
      variant: 'primary',
      disabled: false
    },
    {
      'my-click': (event) => {
        console.log('按钮被点击:', event.detail);
      }
    }
  );

  const handleRefresh = () => {
    callButtonMethod('refresh');
  };

  return (
    <div>
      <my-button ref={buttonRef}>
        使用 Hook 的按钮
      </my-button>
      <button onClick={handleRefresh}>刷新 Web Component</button>
    </div>
  );
};

3. React 组件转换为 Web Components

jsx
// ReactButton.jsx - React 组件
import React, { useState } from 'react';
import ReactDOM from 'react-dom/client';

const ReactButton = ({ 
  variant = 'primary', 
  disabled = false, 
  size = 'medium',
  onClick,
  children 
}) => {
  const [isPressed, setIsPressed] = useState(false);

  const buttonClass = [
    'react-button',
    `react-button--${variant}`,
    `react-button--${size}`,
    disabled && 'react-button--disabled',
    isPressed && 'react-button--pressed'
  ].filter(Boolean).join(' ');

  const handleClick = (event) => {
    if (disabled) return;
    
    setIsPressed(true);
    setTimeout(() => setIsPressed(false), 150);
    
    if (onClick) {
      onClick(event);
    }
  };

  return (
    <button 
      className={buttonClass}
      disabled={disabled}
      onClick={handleClick}
    >
      {children}
    </button>
  );
};

// 转换为 Web Component
class ReactButtonElement extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.root = null;
  }

  static get observedAttributes() {
    return ['variant', 'disabled', 'size'];
  }

  connectedCallback() {
    this.render();
  }

  attributeChangedCallback() {
    if (this.root) {
      this.render();
    }
  }

  disconnectedCallback() {
    if (this.root) {
      this.root.unmount();
    }
  }

  render() {
    const props = {
      variant: this.getAttribute('variant') || 'primary',
      disabled: this.hasAttribute('disabled'),
      size: this.getAttribute('size') || 'medium',
      onClick: (event) => {
        this.dispatchEvent(new CustomEvent('react-click', {
          detail: { originalEvent: event },
          bubbles: true
        }));
      }
    };

    // 创建样式
    const style = document.createElement('style');
    style.textContent = `
      .react-button {
        padding: 8px 16px;
        border: none;
        border-radius: 4px;
        cursor: pointer;
        font-family: inherit;
        transition: all 0.2s ease;
      }
      
      .react-button--primary { background: #007bff; color: white; }
      .react-button--secondary { background: #6c757d; color: white; }
      .react-button--success { background: #28a745; color: white; }
      
      .react-button--small { padding: 4px 8px; font-size: 0.875rem; }
      .react-button--large { padding: 12px 24px; font-size: 1.125rem; }
      
      .react-button:hover:not(.react-button--disabled) {
        opacity: 0.9;
        transform: translateY(-1px);
      }
      
      .react-button--pressed {
        transform: translateY(0) scale(0.98);
      }
      
      .react-button--disabled {
        opacity: 0.5;
        cursor: not-allowed;
      }
    `;

    // 创建容器
    const container = document.createElement('div');
    
    // 清空 shadow root
    this.shadowRoot.innerHTML = '';
    this.shadowRoot.appendChild(style);
    this.shadowRoot.appendChild(container);

    // 渲染 React 组件
    if (this.root) {
      this.root.unmount();
    }
    
    this.root = ReactDOM.createRoot(container);
    this.root.render(
      React.createElement(ReactButton, props, this.textContent)
    );
  }
}

// 注册 Web Component
customElements.define('react-button', ReactButtonElement);

export default ReactButtonElement;

跨框架组件库开发

1. 设计原则

javascript
// 跨框架组件库的设计原则
class CrossFrameworkComponent extends HTMLElement {
  constructor() {
    super();
    
    // 1. 使用 Shadow DOM 确保样式隔离
    this.attachShadow({ mode: 'open' });
    
    // 2. 定义清晰的 API
    this.state = {};
    this.props = {};
    
    // 3. 事件系统标准化
    this.eventHandlers = new Map();
    
    // 4. 生命周期钩子
    this.lifecycle = {
      created: [],
      mounted: [],
      updated: [],
      destroyed: []
    };
  }
  
  // 标准化属性处理
  static get observedAttributes() {
    return this.attributes || [];
  }
  
  attributeChangedCallback(name, oldValue, newValue) {
    const prop = this.attributeToProp(name);
    const value = this.parseAttributeValue(newValue);
    
    if (this.props[prop] !== value) {
      this.props[prop] = value;
      this.onPropChange(prop, value, this.props[prop]);
      this.update();
    }
  }
  
  // 属性名转换(kebab-case 到 camelCase)
  attributeToProp(attribute) {
    return attribute.replace(/-([a-z])/g, (match, letter) => 
      letter.toUpperCase()
    );
  }
  
  // 属性值解析
  parseAttributeValue(value) {
    if (value === null) return null;
    if (value === '') return true;
    if (value === 'false') return false;
    if (value === 'true') return true;
    if (!isNaN(value)) return Number(value);
    
    try {
      return JSON.parse(value);
    } catch {
      return value;
    }
  }
  
  // 标准化事件派发
  emit(eventName, detail = {}) {
    const event = new CustomEvent(eventName, {
      detail,
      bubbles: true,
      cancelable: true
    });
    
    this.dispatchEvent(event);
    return event;
  }
  
  // 生命周期钩子
  connectedCallback() {
    this.runLifecycleHooks('created');
    this.render();
    this.runLifecycleHooks('mounted');
  }
  
  disconnectedCallback() {
    this.runLifecycleHooks('destroyed');
    this.cleanup();
  }
  
  runLifecycleHooks(phase) {
    this.lifecycle[phase].forEach(hook => hook.call(this));
  }
  
  // 添加生命周期钩子
  onCreated(callback) { this.lifecycle.created.push(callback); }
  onMounted(callback) { this.lifecycle.mounted.push(callback); }
  onUpdated(callback) { this.lifecycle.updated.push(callback); }
  onDestroyed(callback) { this.lifecycle.destroyed.push(callback); }
  
  // 抽象方法,子类需要实现
  render() {
    throw new Error('render() method must be implemented');
  }
  
  update() {
    this.render();
    this.runLifecycleHooks('updated');
  }
  
  onPropChange(prop, newValue, oldValue) {
    // 子类可以重写此方法来处理属性变化
  }
  
  cleanup() {
    // 清理资源
    this.eventHandlers.clear();
  }
}

2. 实际组件示例

javascript
// 跨框架数据表格组件
class DataTable extends CrossFrameworkComponent {
  static get attributes() {
    return ['data', 'columns', 'sortable', 'filterable', 'page-size'];
  }
  
  constructor() {
    super();
    
    this.state = {
      sortColumn: null,
      sortDirection: 'asc',
      filterText: '',
      currentPage: 1,
      filteredData: []
    };
    
    this.props = {
      data: [],
      columns: [],
      sortable: true,
      filterable: true,
      pageSize: 10
    };
    
    this.onCreated(() => {
      this.updateFilteredData();
    });
  }
  
  onPropChange(prop, newValue) {
    if (prop === 'data' || prop === 'columns') {
      this.updateFilteredData();
    }
  }
  
  updateFilteredData() {
    let data = [...this.props.data];
    
    // 过滤
    if (this.state.filterText) {
      data = data.filter(row => 
        Object.values(row).some(value => 
          String(value).toLowerCase().includes(
            this.state.filterText.toLowerCase()
          )
        )
      );
    }
    
    // 排序
    if (this.state.sortColumn) {
      data.sort((a, b) => {
        const aVal = a[this.state.sortColumn];
        const bVal = b[this.state.sortColumn];
        
        if (aVal < bVal) return this.state.sortDirection === 'asc' ? -1 : 1;
        if (aVal > bVal) return this.state.sortDirection === 'asc' ? 1 : -1;
        return 0;
      });
    }
    
    this.state.filteredData = data;
    this.update();
  }
  
  handleSort(column) {
    if (!this.props.sortable) return;
    
    if (this.state.sortColumn === column) {
      this.state.sortDirection = this.state.sortDirection === 'asc' ? 'desc' : 'asc';
    } else {
      this.state.sortColumn = column;
      this.state.sortDirection = 'asc';
    }
    
    this.updateFilteredData();
    this.emit('sort-change', {
      column,
      direction: this.state.sortDirection
    });
  }
  
  handleFilter(event) {
    this.state.filterText = event.target.value;
    this.state.currentPage = 1;
    this.updateFilteredData();
    
    this.emit('filter-change', {
      filterText: this.state.filterText
    });
  }
  
  handlePageChange(page) {
    this.state.currentPage = page;
    this.update();
    
    this.emit('page-change', {
      page,
      pageSize: this.props.pageSize
    });
  }
  
  render() {
    const { filteredData } = this.state;
    const { columns, pageSize } = this.props;
    
    const startIndex = (this.state.currentPage - 1) * pageSize;
    const endIndex = startIndex + pageSize;
    const pageData = filteredData.slice(startIndex, endIndex);
    const totalPages = Math.ceil(filteredData.length / pageSize);
    
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          font-family: -apple-system, BlinkMacSystemFont, sans-serif;
        }
        
        .data-table {
          width: 100%;
          border-collapse: collapse;
          margin: 1rem 0;
        }
        
        .data-table th,
        .data-table td {
          padding: 0.75rem;
          text-align: left;
          border-bottom: 1px solid #e1e5e9;
        }
        
        .data-table th {
          background-color: #f8f9fa;
          font-weight: 600;
          cursor: ${this.props.sortable ? 'pointer' : 'default'};
          user-select: none;
        }
        
        .data-table th:hover {
          background-color: ${this.props.sortable ? '#e9ecef' : '#f8f9fa'};
        }
        
        .sort-indicator {
          margin-left: 0.5rem;
          opacity: 0.5;
        }
        
        .sort-indicator.active {
          opacity: 1;
        }
        
        .filter-input {
          width: 100%;
          padding: 0.5rem;
          border: 1px solid #ced4da;
          border-radius: 0.25rem;
          margin-bottom: 1rem;
        }
        
        .pagination {
          display: flex;
          justify-content: center;
          gap: 0.5rem;
          margin-top: 1rem;
        }
        
        .pagination button {
          padding: 0.5rem 0.75rem;
          border: 1px solid #ced4da;
          background: white;
          cursor: pointer;
          border-radius: 0.25rem;
        }
        
        .pagination button:hover:not(:disabled) {
          background-color: #e9ecef;
        }
        
        .pagination button:disabled {
          opacity: 0.5;
          cursor: not-allowed;
        }
        
        .pagination button.active {
          background-color: #007bff;
          color: white;
          border-color: #007bff;
        }
      </style>
      
      ${this.props.filterable ? `
        <input 
          type="text" 
          class="filter-input"
          placeholder="搜索..."
          value="${this.state.filterText}"
        />
      ` : ''}
      
      <table class="data-table">
        <thead>
          <tr>
            ${columns.map(col => `
              <th data-column="${col.key}">
                ${col.title}
                ${this.props.sortable ? `
                  <span class="sort-indicator ${this.state.sortColumn === col.key ? 'active' : ''}">
                    ${this.state.sortColumn === col.key 
                      ? (this.state.sortDirection === 'asc' ? '↑' : '↓')
                      : '↕'
                    }
                  </span>
                ` : ''}
              </th>
            `).join('')}
          </tr>
        </thead>
        <tbody>
          ${pageData.map(row => `
            <tr>
              ${columns.map(col => `
                <td>${row[col.key] || ''}</td>
              `).join('')}
            </tr>
          `).join('')}
        </tbody>
      </table>
      
      ${totalPages > 1 ? `
        <div class="pagination">
          <button ${this.state.currentPage === 1 ? 'disabled' : ''} data-page="${this.state.currentPage - 1}">
            上一页
          </button>
          
          ${Array.from({ length: totalPages }, (_, i) => i + 1).map(page => `
            <button 
              class="${page === this.state.currentPage ? 'active' : ''}"
              data-page="${page}"
            >
              ${page}
            </button>
          `).join('')}
          
          <button ${this.state.currentPage === totalPages ? 'disabled' : ''} data-page="${this.state.currentPage + 1}">
            下一页
          </button>
        </div>
      ` : ''}
    `;
    
    // 绑定事件
    this.bindEvents();
  }
  
  bindEvents() {
    // 排序事件
    if (this.props.sortable) {
      this.shadowRoot.querySelectorAll('th[data-column]').forEach(th => {
        th.addEventListener('click', () => {
          this.handleSort(th.dataset.column);
        });
      });
    }
    
    // 过滤事件
    if (this.props.filterable) {
      const filterInput = this.shadowRoot.querySelector('.filter-input');
      if (filterInput) {
        filterInput.addEventListener('input', (e) => this.handleFilter(e));
      }
    }
    
    // 分页事件
    this.shadowRoot.querySelectorAll('.pagination button[data-page]').forEach(btn => {
      btn.addEventListener('click', () => {
        if (!btn.disabled) {
          this.handlePageChange(parseInt(btn.dataset.page));
        }
      });
    });
  }
}

// 注册组件
customElements.define('data-table', DataTable);

3. 框架适配器

javascript
// Vue 适配器
export const createVueAdapter = (webComponent) => {
  return {
    name: webComponent.tagName,
    props: webComponent.attributes || [],
    emits: webComponent.events || [],
    template: `<${webComponent.tagName} v-bind="$attrs" v-on="$listeners"><slot /></${webComponent.tagName}>`,
    inheritAttrs: false
  };
};

// React 适配器
export const createReactAdapter = (webComponent) => {
  return React.forwardRef((props, ref) => {
    const elementRef = useRef(null);
    
    useImperativeHandle(ref, () => elementRef.current);
    
    useEffect(() => {
      const element = elementRef.current;
      if (!element) return;
      
      // 同步属性
      Object.entries(props).forEach(([key, value]) => {
        if (key.startsWith('on') && typeof value === 'function') {
          // 事件处理
          const eventName = key.slice(2).toLowerCase();
          element.addEventListener(eventName, value);
          return () => element.removeEventListener(eventName, value);
        } else {
          // 属性设置
          element[key] = value;
        }
      });
    }, [props]);
    
    return React.createElement(webComponent.tagName, { ref: elementRef }, props.children);
  });
};

性能优化

1. 懒加载策略

javascript
// 组件懒加载管理器
class ComponentLazyLoader {
  constructor() {
    this.loadedComponents = new Set();
    this.loadingPromises = new Map();
    this.observer = null;
    
    this.setupIntersectionObserver();
  }
  
  setupIntersectionObserver() {
    this.observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          const tagName = entry.target.tagName.toLowerCase();
          this.loadComponent(tagName);
          this.observer.unobserve(entry.target);
        }
      });
    }, {
      rootMargin: '50px'
    });
  }
  
  async loadComponent(tagName) {
    if (this.loadedComponents.has(tagName)) {
      return;
    }
    
    if (this.loadingPromises.has(tagName)) {
      return this.loadingPromises.get(tagName);
    }
    
    const loadingPromise = this.dynamicImport(tagName);
    this.loadingPromises.set(tagName, loadingPromise);
    
    try {
      await loadingPromise;
      this.loadedComponents.add(tagName);
      this.loadingPromises.delete(tagName);
    } catch (error) {
      console.error(`加载组件 ${tagName} 失败:`, error);
      this.loadingPromises.delete(tagName);
    }
  }
  
  async dynamicImport(tagName) {
    const componentMap = {
      'data-table': () => import('./components/DataTable.js'),
      'chart-widget': () => import('./components/ChartWidget.js'),
      'media-player': () => import('./components/MediaPlayer.js')
    };
    
    const importer = componentMap[tagName];
    if (importer) {
      await importer();
    }
  }
  
  observeElement(element) {
    if (this.observer) {
      this.observer.observe(element);
    }
  }
  
  // 预加载关键组件
  preloadCriticalComponents() {
    const criticalComponents = ['data-table', 'user-profile'];
    
    criticalComponents.forEach(tagName => {
      this.loadComponent(tagName);
    });
  }
}

// 全局懒加载器
const lazyLoader = new ComponentLazyLoader();

// 自动检测页面中的未定义元素
const detectUndefinedElements = () => {
  const allElements = document.querySelectorAll('*');
  
  allElements.forEach(element => {
    const tagName = element.tagName.toLowerCase();
    
    if (tagName.includes('-') && !customElements.get(tagName)) {
      lazyLoader.observeElement(element);
    }
  });
};

// 页面加载完成后检测
document.addEventListener('DOMContentLoaded', detectUndefinedElements);

2. 内存管理

javascript
// Web Component 内存管理基类
class ManagedComponent extends HTMLElement {
  constructor() {
    super();
    
    this.cleanup = new Set();
    this.observers = new Set();
    this.timers = new Set();
    this.eventListeners = new Map();
  }
  
  // 添加需要清理的资源
  addCleanup(cleanupFn) {
    this.cleanup.add(cleanupFn);
  }
  
  // 添加观察者
  addObserver(observer) {
    this.observers.add(observer);
    this.addCleanup(() => observer.disconnect());
  }
  
  // 添加定时器
  addTimer(timerId) {
    this.timers.add(timerId);
    this.addCleanup(() => {
      clearTimeout(timerId);
      clearInterval(timerId);
    });
  }
  
  // 添加事件监听器
  addEventListenerManaged(target, event, handler, options) {
    target.addEventListener(event, handler, options);
    
    const key = `${target.constructor.name}-${event}`;
    if (!this.eventListeners.has(key)) {
      this.eventListeners.set(key, []);
    }
    
    this.eventListeners.get(key).push({ target, event, handler, options });
    
    this.addCleanup(() => {
      target.removeEventListener(event, handler, options);
    });
  }
  
  // 组件卸载时自动清理
  disconnectedCallback() {
    this.cleanup.forEach(cleanupFn => {
      try {
        cleanupFn();
      } catch (error) {
        console.error('清理资源时出错:', error);
      }
    });
    
    this.cleanup.clear();
    this.observers.clear();
    this.timers.clear();
    this.eventListeners.clear();
  }
  
  // 内存使用监控
  getMemoryUsage() {
    return {
      cleanup: this.cleanup.size,
      observers: this.observers.size,
      timers: this.timers.size,
      eventListeners: Array.from(this.eventListeners.values())
        .reduce((total, listeners) => total + listeners.length, 0)
    };
  }
}

// 使用示例
class OptimizedComponent extends ManagedComponent {
  connectedCallback() {
    this.attachShadow({ mode: 'open' });
    
    // 使用托管的事件监听器
    this.addEventListenerManaged(
      this, 
      'click', 
      this.handleClick.bind(this)
    );
    
    // 使用托管的观察者
    const resizeObserver = new ResizeObserver(this.handleResize.bind(this));
    resizeObserver.observe(this);
    this.addObserver(resizeObserver);
    
    // 使用托管的定时器
    const timerId = setInterval(() => {
      this.updateData();
    }, 5000);
    this.addTimer(timerId);
    
    this.render();
  }
  
  handleClick(event) {
    console.log('组件被点击');
  }
  
  handleResize(entries) {
    console.log('组件大小变化');
  }
  
  updateData() {
    console.log('更新数据');
  }
  
  render() {
    this.shadowRoot.innerHTML = `
      <div>优化的组件内容</div>
    `;
  }
}

customElements.define('optimized-component', OptimizedComponent);

测试策略

1. 单元测试

javascript
// Web Components 测试工具
class WebComponentTester {
  constructor(tagName, componentClass) {
    this.tagName = tagName;
    this.componentClass = componentClass;
    this.container = null;
  }
  
  setup() {
    // 创建测试容器
    this.container = document.createElement('div');
    document.body.appendChild(this.container);
    
    // 注册组件(如果尚未注册)
    if (!customElements.get(this.tagName)) {
      customElements.define(this.tagName, this.componentClass);
    }
  }
  
  teardown() {
    if (this.container) {
      document.body.removeChild(this.container);
      this.container = null;
    }
  }
  
  createElement(attributes = {}, content = '') {
    const element = document.createElement(this.tagName);
    
    Object.entries(attributes).forEach(([key, value]) => {
      element.setAttribute(key, value);
    });
    
    if (content) {
      element.innerHTML = content;
    }
    
    this.container.appendChild(element);
    
    // 等待组件初始化
    return new Promise(resolve => {
      if (element.shadowRoot) {
        resolve(element);
      } else {
        element.addEventListener('connected', () => resolve(element));
      }
    });
  }
  
  async waitForUpdate(element, timeout = 1000) {
    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => {
        reject(new Error('等待更新超时'));
      }, timeout);
      
      const observer = new MutationObserver(() => {
        clearTimeout(timer);
        observer.disconnect();
        resolve();
      });
      
      observer.observe(element.shadowRoot, {
        childList: true,
        subtree: true,
        attributes: true
      });
    });
  }
  
  queryInShadow(element, selector) {
    return element.shadowRoot.querySelector(selector);
  }
  
  queryAllInShadow(element, selector) {
    return element.shadowRoot.querySelectorAll(selector);
  }
}

// 测试示例
describe('DataTable Component', () => {
  let tester;
  
  beforeEach(() => {
    tester = new WebComponentTester('data-table', DataTable);
    tester.setup();
  });
  
  afterEach(() => {
    tester.teardown();
  });
  
  test('应该正确渲染表格', async () => {
    const data = [
      { id: 1, name: 'John', age: 30 },
      { id: 2, name: 'Jane', age: 25 }
    ];
    
    const columns = [
      { key: 'id', title: 'ID' },
      { key: 'name', title: '姓名' },
      { key: 'age', title: '年龄' }
    ];
    
    const element = await tester.createElement({
      data: JSON.stringify(data),
      columns: JSON.stringify(columns)
    });
    
    const table = tester.queryInShadow(element, '.data-table');
    expect(table).toBeTruthy();
    
    const rows = tester.queryAllInShadow(element, 'tbody tr');
    expect(rows.length).toBe(2);
  });
  
  test('应该支持排序功能', async () => {
    const element = await tester.createElement({
      data: JSON.stringify([
        { name: 'Charlie', age: 35 },
        { name: 'Alice', age: 28 }
      ]),
      columns: JSON.stringify([
        { key: 'name', title: '姓名' },
        { key: 'age', title: '年龄' }
      ]),
      sortable: 'true'
    });
    
    const nameHeader = tester.queryInShadow(element, 'th[data-column="name"]');
    
    // 模拟点击排序
    nameHeader.click();
    await tester.waitForUpdate(element);
    
    const firstRow = tester.queryInShadow(element, 'tbody tr:first-child td:first-child');
    expect(firstRow.textContent).toBe('Alice');
  });
  
  test('应该触发自定义事件', async () => {
    const element = await tester.createElement({
      data: JSON.stringify([{ name: 'Test' }]),
      columns: JSON.stringify([{ key: 'name', title: '姓名' }]),
      sortable: 'true'
    });
    
    let eventFired = false;
    element.addEventListener('sort-change', (event) => {
      eventFired = true;
      expect(event.detail.column).toBe('name');
    });
    
    const nameHeader = tester.queryInShadow(element, 'th[data-column="name"]');
    nameHeader.click();
    
    expect(eventFired).toBe(true);
  });
});

2. 集成测试

javascript
// 框架集成测试
describe('Vue Integration', () => {
  test('应该在 Vue 中正确工作', async () => {
    const { mount } = require('@vue/test-utils');
    
    const wrapper = mount({
      template: `
        <div>
          <data-table 
            :data="tableData"
            :columns="tableColumns"
            @sort-change="handleSort"
          />
        </div>
      `,
      data() {
        return {
          tableData: [{ name: 'Test', age: 30 }],
          tableColumns: [
            { key: 'name', title: '姓名' },
            { key: 'age', title: '年龄' }
          ],
          sortEvent: null
        };
      },
      methods: {
        handleSort(event) {
          this.sortEvent = event;
        }
      }
    });
    
    const dataTable = wrapper.find('data-table');
    expect(dataTable.exists()).toBe(true);
    
    // 测试事件传递
    await dataTable.trigger('sort-change', {
      detail: { column: 'name', direction: 'asc' }
    });
    
    expect(wrapper.vm.sortEvent).toBeTruthy();
  });
});

describe('React Integration', () => {
  test('应该在 React 中正确工作', () => {
    const { render, fireEvent } = require('@testing-library/react');
    
    const TestComponent = () => {
      const [sortData, setSortData] = React.useState(null);
      const tableRef = React.useRef(null);
      
      React.useEffect(() => {
        const table = tableRef.current;
        if (table) {
          table.addEventListener('sort-change', (event) => {
            setSortData(event.detail);
          });
        }
      }, []);
      
      return (
        <div>
          <data-table
            ref={tableRef}
            data={JSON.stringify([{ name: 'Test', age: 30 }])}
            columns={JSON.stringify([
              { key: 'name', title: '姓名' },
              { key: 'age', title: '年龄' }
            ])}
          />
          {sortData && <div data-testid="sort-info">{sortData.column}</div>}
        </div>
      );
    };
    
    const { getByTestId } = render(<TestComponent />);
    
    // 模拟排序事件
    const table = document.querySelector('data-table');
    fireEvent(table, new CustomEvent('sort-change', {
      detail: { column: 'name', direction: 'asc' }
    }));
    
    expect(getByTestId('sort-info')).toHaveTextContent('name');
  });
});

最佳实践总结

1. 设计原则

  • 封装性:使用 Shadow DOM 确保样式和行为隔离
  • 可复用性:设计通用的 API,避免框架特定的依赖
  • 可访问性:遵循 ARIA 标准,支持键盘导航
  • 性能:合理使用懒加载和内存管理

2. 开发建议

  • 属性命名:使用 kebab-case 属性名,camelCase 属性访问
  • 事件系统:使用自定义事件进行通信,保持事件名称一致性
  • 生命周期:正确处理组件的创建、更新和销毁
  • 错误处理:提供友好的错误信息和降级方案

3. 框架集成

  • Vue:利用 defineCustomElement 和配置选项
  • React:使用 ref 和 useEffect 处理属性和事件
  • 通用:创建适配器层简化集成过程

4. 测试策略

  • 单元测试:测试组件的核心功能和 API
  • 集成测试:验证与不同框架的兼容性
  • 端到端测试:确保在真实环境中的表现

总结

Web Components 与现代框架的集成为我们提供了构建真正可复用组件的能力。通过合理的设计和实现,我们可以创建既能独立工作,又能与各种框架无缝集成的组件库。

关键要点:

  1. 标准化 API:设计清晰、一致的组件接口
  2. 框架适配:为不同框架提供专门的适配层
  3. 性能优化:实施懒加载和内存管理策略
  4. 全面测试:确保组件在各种环境下的稳定性

随着 Web 标准的不断发展,Web Components 将在跨框架组件开发中发挥越来越重要的作用。

参考资源

vitepress开发指南