Web Components:构建原生可复用组件

掌握 Custom Elements、Shadow DOM、HTML Templates 和跨框架组件开发

Web Components:构建原生可复用组件

Web Components 让你创建跨框架的原生组件。本文探讨其核心技术和最佳实践。

Web Components 概述

三大核心技术

Web Components 组成:
┌─────────────────────────────────────────────────────┐
│                                                     │
│   Custom Elements                                   │
│   └── 自定义 HTML 标签                              │
│                                                     │
│   Shadow DOM                                        │
│   └── 样式和 DOM 隔离                               │
│                                                     │
│   HTML Templates                                    │
│   └── 可复用的模板片段                              │
│                                                     │
└─────────────────────────────────────────────────────┘
特性优势应用场景
原生支持无需框架跨项目复用
样式隔离不污染全局微前端
框架无关通用性强设计系统

Custom Elements

基础组件

// 定义自定义元素
class MyButton extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  // 组件挂载时
  connectedCallback() {
    this.render();
    this.setupEventListeners();
  }

  // 组件卸载时
  disconnectedCallback() {
    this.cleanup();
  }

  // 观察的属性
  static get observedAttributes() {
    return ['variant', 'disabled'];
  }

  // 属性变化时
  attributeChangedCallback(name: string, oldValue: string, newValue: string) {
    if (oldValue !== newValue) {
      this.render();
    }
  }

  private render() {
    const variant = this.getAttribute('variant') || 'primary';
    const disabled = this.hasAttribute('disabled');

    this.shadowRoot!.innerHTML = `
      <style>
        :host {
          display: inline-block;
        }
        button {
          padding: 8px 16px;
          border: none;
          border-radius: 4px;
          cursor: pointer;
          font-size: 14px;
          transition: all 0.2s;
        }
        button.primary {
          background: #3b82f6;
          color: white;
        }
        button.primary:hover {
          background: #2563eb;
        }
        button.secondary {
          background: #e5e7eb;
          color: #374151;
        }
        button:disabled {
          opacity: 0.5;
          cursor: not-allowed;
        }
      </style>
      <button class="${variant}" ${disabled ? 'disabled' : ''}>
        <slot></slot>
      </button>
    `;
  }

  private setupEventListeners() {
    const button = this.shadowRoot!.querySelector('button');
    button?.addEventListener('click', (e) => {
      if (!this.hasAttribute('disabled')) {
        this.dispatchEvent(new CustomEvent('my-click', {
          bubbles: true,
          composed: true,
          detail: { timestamp: Date.now() }
        }));
      }
    });
  }

  private cleanup() {
    // 清理事件监听等
  }
}

// 注册组件
customElements.define('my-button', MyButton);

使用组件

<!-- HTML 中使用 -->
<my-button variant="primary">点击我</my-button>
<my-button variant="secondary" disabled>禁用状态</my-button>

<script>
  document.querySelector('my-button').addEventListener('my-click', (e) => {
    console.log('按钮被点击', e.detail);
  });
</script>

Shadow DOM

样式隔离

class StyledCard extends HTMLElement {
  constructor() {
    super();
    // 创建 Shadow DOM
    const shadow = this.attachShadow({ mode: 'open' });

    shadow.innerHTML = `
      <style>
        /* 这些样式只在组件内部生效 */
        :host {
          display: block;
          border: 1px solid #e5e7eb;
          border-radius: 8px;
          overflow: hidden;
        }

        /* 根据属性变化样式 */
        :host([elevated]) {
          box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
        }

        /* 上下文样式 */
        :host-context(.dark-theme) {
          border-color: #374151;
          background: #1f2937;
        }

        .header {
          padding: 16px;
          background: #f9fafb;
          border-bottom: 1px solid #e5e7eb;
        }

        .content {
          padding: 16px;
        }

        /* 穿透样式的插槽 */
        ::slotted(h3) {
          margin: 0;
          font-size: 18px;
        }
      </style>

      <div class="header">
        <slot name="header"></slot>
      </div>
      <div class="content">
        <slot></slot>
      </div>
    `;
  }
}

customElements.define('styled-card', StyledCard);

使用插槽

<styled-card elevated>
  <h3 slot="header">卡片标题</h3>
  <p>这是卡片的内容,会显示在默认插槽中。</p>
  <p>可以有多个元素。</p>
</styled-card>

HTML Templates

模板定义

<!-- 在 HTML 中定义模板 -->
<template id="user-card-template">
  <style>
    .user-card {
      display: flex;
      align-items: center;
      padding: 16px;
      border: 1px solid #e5e7eb;
      border-radius: 8px;
    }
    .avatar {
      width: 48px;
      height: 48px;
      border-radius: 50%;
      margin-right: 12px;
    }
    .info h4 {
      margin: 0 0 4px;
    }
    .info p {
      margin: 0;
      color: #6b7280;
      font-size: 14px;
    }
  </style>
  <div class="user-card">
    <img class="avatar" />
    <div class="info">
      <h4></h4>
      <p></p>
    </div>
  </div>
</template>

使用模板

class UserCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });

    // 克隆模板内容
    const template = document.getElementById('user-card-template') as HTMLTemplateElement;
    const content = template.content.cloneNode(true);
    this.shadowRoot!.appendChild(content);
  }

  connectedCallback() {
    this.updateCard();
  }

  static get observedAttributes() {
    return ['name', 'email', 'avatar'];
  }

  attributeChangedCallback() {
    this.updateCard();
  }

  private updateCard() {
    const name = this.getAttribute('name') || '';
    const email = this.getAttribute('email') || '';
    const avatar = this.getAttribute('avatar') || '';

    const root = this.shadowRoot!;
    root.querySelector('.avatar')?.setAttribute('src', avatar);
    root.querySelector('h4')!.textContent = name;
    root.querySelector('p')!.textContent = email;
  }
}

customElements.define('user-card', UserCard);

高级模式

响应式属性

class ReactiveComponent extends HTMLElement {
  private _count = 0;

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

  // Getter/Setter 实现响应式
  get count() {
    return this._count;
  }

  set count(value: number) {
    this._count = value;
    this.render();
    this.dispatchEvent(new CustomEvent('count-changed', {
      detail: { count: value }
    }));
  }

  connectedCallback() {
    this.render();
  }

  private render() {
    this.shadowRoot!.innerHTML = `
      <style>
        :host {
          display: inline-flex;
          align-items: center;
          gap: 8px;
        }
        button {
          width: 32px;
          height: 32px;
          border: none;
          border-radius: 4px;
          background: #3b82f6;
          color: white;
          cursor: pointer;
        }
        span {
          min-width: 40px;
          text-align: center;
          font-size: 18px;
        }
      </style>
      <button id="dec">-</button>
      <span>${this.count}</span>
      <button id="inc">+</button>
    `;

    this.shadowRoot!.getElementById('dec')!.onclick = () => this.count--;
    this.shadowRoot!.getElementById('inc')!.onclick = () => this.count++;
  }
}

customElements.define('reactive-counter', ReactiveComponent);

表单集成

class CustomInput extends HTMLElement {
  static formAssociated = true;
  private internals: ElementInternals;

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

  connectedCallback() {
    this.render();
  }

  private render() {
    this.shadowRoot!.innerHTML = `
      <style>
        input {
          width: 100%;
          padding: 8px 12px;
          border: 1px solid #d1d5db;
          border-radius: 4px;
          font-size: 14px;
        }
        input:focus {
          outline: none;
          border-color: #3b82f6;
          box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
        }
      </style>
      <input type="text" />
    `;

    const input = this.shadowRoot!.querySelector('input')!;
    input.addEventListener('input', (e) => {
      const value = (e.target as HTMLInputElement).value;
      this.internals.setFormValue(value);
    });
  }

  // 表单重置时
  formResetCallback() {
    this.shadowRoot!.querySelector('input')!.value = '';
    this.internals.setFormValue('');
  }

  // 表单验证
  get validity() {
    return this.internals.validity;
  }

  get validationMessage() {
    return this.internals.validationMessage;
  }
}

customElements.define('custom-input', CustomInput);

与框架集成

React 中使用

// React 包装器
import React, { useRef, useEffect } from 'react';

interface MyButtonProps {
  variant?: 'primary' | 'secondary';
  disabled?: boolean;
  onClick?: (e: CustomEvent) => void;
  children: React.ReactNode;
}

export function MyButtonWrapper({
  variant = 'primary',
  disabled = false,
  onClick,
  children
}: MyButtonProps) {
  const ref = useRef<HTMLElement>(null);

  useEffect(() => {
    const element = ref.current;
    if (element && onClick) {
      element.addEventListener('my-click', onClick as EventListener);
      return () => {
        element.removeEventListener('my-click', onClick as EventListener);
      };
    }
  }, [onClick]);

  return (
    <my-button
      ref={ref}
      variant={variant}
      disabled={disabled || undefined}
    >
      {children}
    </my-button>
  );
}

// 类型声明
declare global {
  namespace JSX {
    interface IntrinsicElements {
      'my-button': React.DetailedHTMLProps<
        React.HTMLAttributes<HTMLElement> & {
          variant?: string;
          disabled?: boolean;
        },
        HTMLElement
      >;
    }
  }
}

Vue 中使用

<template>
  <my-button
    :variant="variant"
    :disabled="disabled"
    @my-click="handleClick"
  >
    <slot></slot>
  </my-button>
</template>

<script setup lang="ts">
defineProps<{
  variant?: 'primary' | 'secondary';
  disabled?: boolean;
}>();

const emit = defineEmits<{
  click: [detail: { timestamp: number }];
}>();

function handleClick(e: CustomEvent) {
  emit('click', e.detail);
}
</script>

Lit 框架

import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';

@customElement('lit-button')
export class LitButton extends LitElement {
  static styles = css`
    :host {
      display: inline-block;
    }
    button {
      padding: 8px 16px;
      border: none;
      border-radius: 4px;
      cursor: pointer;
      background: var(--button-bg, #3b82f6);
      color: var(--button-color, white);
    }
    button:hover {
      filter: brightness(0.9);
    }
    :host([disabled]) button {
      opacity: 0.5;
      cursor: not-allowed;
    }
  `;

  @property({ type: String }) variant = 'primary';
  @property({ type: Boolean }) disabled = false;

  render() {
    return html`
      <button
        class=${this.variant}
        ?disabled=${this.disabled}
        @click=${this._handleClick}
      >
        <slot></slot>
      </button>
    `;
  }

  private _handleClick() {
    if (!this.disabled) {
      this.dispatchEvent(new CustomEvent('lit-click', {
        bubbles: true,
        composed: true
      }));
    }
  }
}

最佳实践总结

Web Components 最佳实践:
┌─────────────────────────────────────────────────────┐
│   组件设计                                          │
│   ├── 单一职责原则                                  │
│   ├── 使用有意义的标签名                            │
│   ├── 提供合理的默认值                              │
│   └── 支持属性和特性同步                            │
│                                                     │
│   样式处理                                          │
│   ├── 使用 CSS 变量支持定制                         │
│   ├── 合理使用 :host 和 ::slotted                   │
│   ├── 考虑外部样式穿透需求                          │
│   └── 提供主题支持                                  │
│                                                     │
│   性能优化                                          │
│   ├── 延迟加载非关键组件                            │
│   ├── 复用模板实例                                  │
│   ├── 批量更新 DOM                                  │
│   └── 清理事件监听器                                │
│                                                     │
│   可访问性                                          │
│   ├── 使用语义化标签                                │
│   ├── 添加 ARIA 属性                                │
│   ├── 支持键盘导航                                  │
│   └── 管理焦点                                      │
└─────────────────────────────────────────────────────┘
场景推荐方案
简单组件原生 API
复杂组件Lit 框架
设计系统Stencil
跨框架复用Web Components

Web Components 是构建可复用 UI 的原生标准。框架来来去去,Web 标准永存。