Web Components跨框架组件开发实战:自定义元素与Shadow DOM封装

Web Components是W3C标准化的浏览器原生组件技术,通过Custom Elements、Shadow DOM和HTML Templates三套规范实现样式与逻辑的完整封装。与React/Vue等框架组件不同,Web Components不依赖任何运行时框架,可在任意框架或纯HTML页面中直接使用。本文演示从自定义元素定义、Shadow DOM样式隔离到跨框架集成的完整开发流程。

Custom Elements自定义元素定义

自定义元素通过继承HTMLElement或其子类创建,生命周期回调包括connectedCallback(插入DOM)、disconnectedCallback(移除DOM)、attributeChangedCallback(属性变更)和adoptedCallback(跨文档移动)。实现一个带数据绑定的任务卡片组件:

class TaskCard extends HTMLElement {
  static get observedAttributes() {
    return ['title', 'priority', 'assignee', 'status'];
  }

  constructor() {
    super();
    this._shadow = this.attachShadow({ mode: 'open' });
    this._data = {};
    this._render();
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue !== newValue) {
      this._data[name] = newValue;
      this._updateView(name);
    }
  }

  connectedCallback() {
    this._setupEventListeners();
  }

  disconnectedCallback() {
    this._cleanup();
  }

  _render() {
    this._shadow.innerHTML = `
      <style>
        :host {
          display: block;
          font-family: system-ui, sans-serif;
          border-radius: 8px;
          overflow: hidden;
          margin-bottom: 12px;
        }
        .card {
          padding: 16px;
          border: 1px solid #e0e0e0;
          border-left: 4px solid #ccc;
          border-radius: 8px;
          background: #fff;
          transition: box-shadow 0.2s;
        }
        .card:hover {
          box-shadow: 0 2px 12px rgba(0,0,0,0.08);
        }
        .card[data-priority="high"] { border-left-color: #e53935; }
        .card[data-priority="medium"] { border-left-color: #fb8c00; }
        .card[data-priority="low"] { border-left-color: #43a047; }
        .title {
          font-size: 15px;
          font-weight: 600;
          margin: 0 0 8px;
          color: #1a1a1a;
        }
        .meta {
          display: flex;
          gap: 12px;
          font-size: 13px;
          color: #666;
        }
        .status-badge {
          padding: 2px 8px;
          border-radius: 4px;
          font-size: 12px;
          font-weight: 500;
        }
        .status-badge[data-status="todo"] { background: #e3f2fd; color: #1565c0; }
        .status-badge[data-status="doing"] { background: #fff3e0; color: #e65100; }
        .status-badge[data-status="done"] { background: #e8f5e9; color: #2e7d32; }
        .btn-delete {
          float: right;
          border: none;
          background: transparent;
          cursor: pointer;
          color: #999;
          font-size: 18px;
          padding: 0 4px;
        }
        .btn-delete:hover { color: #e53935; }
      </style>
      <div class="card">
        <button class="btn-delete">&times;</button>
        <h3 class="title">${this._data.title || 'Untitled'}</h3>
        <div class="meta">
          <span>Priority: ${this._data.priority || 'low'}</span>
          <span>Assignee: ${this._data.assignee || 'Unassigned'}</span>
          <span class="status-badge" data-status="${this._data.status || 'todo'}">${this._data.status || 'todo'}</span>
        </div>
      </div>
    `;
  }

  _updateView(attr) {
    const root = this._shadow.querySelector('.card');
    if (!root) return;
    if (attr === 'priority') {
      root.dataset.priority = this._data.priority || 'low';
    }
    if (attr === 'status') {
      const badge = root.querySelector('.status-badge');
      if (badge) {
        badge.dataset.status = this._data.status || 'todo';
        badge.textContent = this._data.status || 'todo';
      }
    }
    if (attr === 'title') {
      const title = root.querySelector('.title');
      if (title) title.textContent = this._data.title || 'Untitled';
    }
  }

  _setupEventListeners() {
    const deleteBtn = this._shadow.querySelector('.btn-delete');
    this._deleteHandler = (e) => {
      e.stopPropagation();
      this.dispatchEvent(new CustomEvent('task-delete', {
        detail: { id: this.id, title: this._data.title },
        bubbles: true,
        composed: true
      }));
    };
    if (deleteBtn) {
      deleteBtn.addEventListener('click', this._deleteHandler);
    }
  }

  _cleanup() {
    if (this._deleteHandler) {
      const deleteBtn = this._shadow.querySelector('.btn-delete');
      if (deleteBtn) {
        deleteBtn.removeEventListener('click', this._deleteHandler);
      }
    }
  }
}

customElements.define('task-card', TaskCard);

CustomEvent的composed: true允许事件穿越Shadow DOM边界,使外部框架能够监听组件内部触发的自定义事件。

Shadow DOM样式隔离机制

Shadow DOM创建独立的样式作用域,外部CSS无法穿透Shadow边界影响内部元素,内部样式也不会泄漏到外部文档。:host选择器用于设置组件宿主元素的样式:

/* 外部样式 - 不影响Shadow DOM内部 */
.card {
  color: red; /* 无效,无法穿透Shadow DOM */
}

/* 组件内部 :host 伪类 */
:host {
  --card-bg: #ffffff;
  --card-border: #e0e0e0;
  display: block;
  contain: content;
}
:host([theme="dark"]) {
  --card-bg: #1e1e1e;
  --card-border: #333;
}
.card {
  background: var(--card-bg);
  border-color: var(--card-border);
}

通过CSS自定义属性(CSS Variables)可实现外部主题注入,这是跨Shadow DOM传递样式的标准机制。外部框架只需设置CSS变量值即可控制组件外观:

/* 外部框架控制主题 */
task-card {
  --card-bg: #f5f5f5;
  --card-border: #ddd;
}
task-card[theme="dark"] {
  --card-bg: #1a1a2e;
  --card-border: #333;
}

HTML Templates与Slot内容分发

使用template元素定义组件模板,通过slot实现内容分发,使组件支持外部内容嵌入:

const template = document.createElement('template');
template.innerHTML = `
  <style>
    .panel { border: 1px solid #ddd; border-radius: 8px; padding: 16px; }
    .panel-header { font-weight: 600; margin-bottom: 12px; }
    .panel-body { font-size: 14px; line-height: 1.6; }
    ::slotted(h2) { margin: 0; font-size: 16px; }
  </style>
  <div class="panel">
    <div class="panel-header">
      <slot name="header">Default Header</slot>
    </div>
    <div class="panel-body">
      <slot>Default content</slot>
    </div>
  </div>
`;

class ContentPanel extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.appendChild(template.content.cloneNode(true));
  }
}
customElements.define('content-panel', ContentPanel);

使用方式,slot属性匹配对应插槽:

<content-panel>
  <h2 slot="header">API网关配置指南</h2>
  <p>API网关通过路由规则将请求分发到后端微服务...</p>
  <p>支持限流、认证、日志等横切关注点...</p>
</content-panel>

跨框架集成与React/Vue桥接

React对Web Components的属性传递需要特殊处理,React 19+原生支持自定义元素属性绑定;React 18及以下版本需要通过ref手动设置属性:

// React 18 桥接方案
import { useRef, useEffect } from 'react';

function TaskList({ tasks, onDelete }) {
  const containerRef = useRef(null);

  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const handleDelete = (e) => {
      onDelete(e.detail.id);
    };
    container.addEventListener('task-delete', handleDelete);
    return () => container.removeEventListener('task-delete', handleDelete);
  }, [onDelete]);

  return (
    <div ref={containerRef}>
      {tasks.map(task => (
        <task-card
          key={task.id}
          id={task.id}
          ref={el => {
            if (el) {
              el.setAttribute('title', task.title);
              el.setAttribute('priority', task.priority);
              el.setAttribute('assignee', task.assignee);
              el.setAttribute('status', task.status);
            }
          }}
        />
      ))}
    </div>
  );
}

Vue 3通过v-is或直接使用自定义元素标签名即可集成,需在vue.config中配置compilerOptions.isCustomElement:

// vite.config.js
export default {
  plugins: [
    vue({
      template: {
        compilerOptions: {
          isCustomElement: (tag) => tag.includes('-')
        }
      }
    })
  ]
}
<!-- Vue 3 SFC -->
<template>
  <div class="task-board">
    <task-card
      v-for="task in tasks"
      :key="task.id"
      :title="task.title"
      :priority="task.priority"
      :assignee="task.assignee"
      :status="task.status"
      @task-delete="handleDelete"
    />
  </div>
</template>

Vue通过属性绑定自动将props传递给Web Components,事件监听器通过@前缀即可绑定自定义事件。

原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/webcomponents-kua-kuang-jia-zu-jian-kai-fa-shi-zhan-zi-ding/

(0)
小编小编
上一篇 1小时前
下一篇 1小时前

相关推荐