Skip to content

Web Components ​

什么是 Web Components? ​

官方定义 ​

Web Components 是一套 Web 原生技术,允许你创建可复用的自定义元素,并在 Web 应用中使用它们,实现组件封装和复用。

通俗理解 ​

想象你在玩乐高积木 —— 每个积木块都是独立的、可复用的。Web Components 就像让你自己设计乐高积木,然后在任何网页中使用,而不依赖任何框架。

三大核心技术 ​

技术作用说明
Custom Elements自定义元素定义新的 HTML 标签
Shadow DOM影子 DOM封装样式和结构,隔离组件
HTML TemplatesHTML 模板定义可复用的 HTML 片段

Custom Elements(自定义元素) ​

基础用法 ​

javascript
// 定义自定义元素
class MyButton extends HTMLElement {
  constructor() {
    super();  // 必须调用 super()

    // 初始化组件
    this.innerHTML = `
      <button class="my-button">
        <slot></slot>
      </button>
    `;
  }

  // 元素被添加到 DOM 时调用
  connectedCallback() {
    console.log('元素已添加到页面');
    this.addEventListener('click', this.handleClick);
  }

  // 元素从 DOM 移除时调用
  disconnectedCallback() {
    console.log('元素已从页面移除');
    this.removeEventListener('click', this.handleClick);
  }

  // 元素被移动到新文档时调用
  adoptedCallback() {
    console.log('元素被移动到新文档');
  }

  // 属性变化时调用
  attributeChangedCallback(name, oldValue, newValue) {
    console.log(`属性 ${name} 从 ${oldValue} 变为 ${newValue}`);
  }

  // 声明需要观察的属性
  static get observedAttributes() {
    return ['disabled', 'type', 'size'];
  }

  handleClick = () => {
    this.dispatchEvent(new CustomEvent('my-click', {
      detail: { message: '按钮被点击了' },
      bubbles: true,
      composed: true  // 允许事件穿透 Shadow DOM
    }));
  }
}

// 注册自定义元素
customElements.define('my-button', MyButton);
html
<!-- 使用自定义元素 -->
<my-button>点击我</my-button>
<my-button disabled>禁用状态</my-button>

生命周期回调 ​

回调触发时机常见用途
constructor元素创建时初始化状态、绑定方法
connectedCallback添加到 DOM 时设置监听器、获取数据
disconnectedCallback从 DOM 移除时清理监听器、取消请求
attributeChangedCallback属性变化时响应属性变化
adoptedCallback移动到新文档时较少使用

属性和特性 ​

javascript
class MyInput extends HTMLElement {
  constructor() {
    super();
    this._value = '';
  }

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

  // 属性变化回调
  attributeChangedCallback(name, oldValue, newValue) {
    switch (name) {
      case 'value':
        this._value = newValue;
        this.render();
        break;
      case 'disabled':
        this.input.disabled = newValue !== null;
        break;
    }
  }

  // 属性 getter/setter(反映到 HTML 属性)
  get value() {
    return this.getAttribute('value') || '';
  }

  set value(val) {
    if (val) {
      this.setAttribute('value', val);
    } else {
      this.removeAttribute('value');
    }
  }

  get disabled() {
    return this.hasAttribute('disabled');
  }

  set disabled(val) {
    if (val) {
      this.setAttribute('disabled', '');
    } else {
      this.removeAttribute('disabled');
    }
  }

  connectedCallback() {
    this.render();
  }

  render() {
    this.innerHTML = `
      <input
        type="text"
        value="${this.value}"
        placeholder="${this.getAttribute('placeholder') || ''}"
        ${this.disabled ? 'disabled' : ''}
      >
    `;
    this.input = this.querySelector('input');
  }
}

customElements.define('my-input', MyInput);

Shadow DOM(影子 DOM) ​

什么是 Shadow DOM? ​

Shadow DOM 提供了封装能力,让组件的内部结构、样式与外部隔离。

javascript
class MyCard extends HTMLElement {
  constructor() {
    super();

    // 创建 Shadow DOM
    const shadow = this.attachShadow({ mode: 'open' });

    // mode: 'open' - 外部可以访问 shadowRoot
    // mode: 'closed' - 外部无法访问 shadowRoot

    shadow.innerHTML = `
      <style>
        /* 样式被封装在 Shadow DOM 内 */
        :host {
          display: block;
          border: 1px solid #ddd;
          border-radius: 8px;
          padding: 16px;
        }

        :host([theme="dark"]) {
          background: #333;
          color: #fff;
        }

        :host(:hover) {
          box-shadow: 0 2px 8px rgba(0,0,0,0.1);
        }

        .title {
          font-size: 18px;
          font-weight: bold;
          margin-bottom: 8px;
        }

        .content {
          color: #666;
        }

        /* 外部样式无法影响这里 */
        p {
          margin: 0;
        }
      </style>

      <div class="card">
        <div class="title">
          <slot name="title">默认标题</slot>
        </div>
        <div class="content">
          <slot>默认内容</slot>
        </div>
      </div>
    `;
  }
}

customElements.define('my-card', MyCard);
html
<!-- 使用 -->
<my-card>
  <span slot="title">自定义标题</span>
  <p>这是卡片内容</p>
</my-card>

<my-card theme="dark">
  <span slot="title">暗色主题</span>
  <p>暗色主题内容</p>
</my-card>

<style>
  /* 这些样式不会影响 Shadow DOM 内部 */
  p { color: red; }
  .title { font-size: 100px; }
</style>

:host 选择器 ​

css
/* 选择宿主元素本身 */
:host {
  display: block;
}

/* 选择带有特定属性的宿主 */
:host([disabled]) {
  opacity: 0.5;
  pointer-events: none;
}

/* 选择带有特定类的宿主 */
:host(.primary) {
  background: blue;
  color: white;
}

/* 选择特定上下文中的宿主 */
:host-context(.dark-theme) {
  background: #333;
  color: #fff;
}

/* 选择宿主的伪类状态 */
:host(:hover) {
  border-color: blue;
}

:host(:focus-within) {
  box-shadow: 0 0 0 2px blue;
}

::slotted 选择器 ​

css
/* 选择插入到 slot 中的元素 */
::slotted(*) {
  margin: 0;
}

::slotted(p) {
  color: #333;
}

::slotted(.highlight) {
  background: yellow;
}

/* 注意:只能选择直接子元素 */
::slotted(div p) {  /* 无效! */
  color: red;
}

CSS 变量穿透 ​

javascript
class ThemedButton extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });

    shadow.innerHTML = `
      <style>
        button {
          /* 使用 CSS 变量,允许外部自定义 */
          background: var(--btn-bg, #007bff);
          color: var(--btn-color, white);
          padding: var(--btn-padding, 8px 16px);
          border: none;
          border-radius: var(--btn-radius, 4px);
          font-size: var(--btn-font-size, 14px);
          cursor: pointer;
        }

        button:hover {
          background: var(--btn-bg-hover, #0056b3);
        }
      </style>
      <button><slot></slot></button>
    `;
  }
}

customElements.define('themed-button', ThemedButton);
html
<!-- 通过 CSS 变量自定义样式 -->
<style>
  themed-button {
    --btn-bg: #28a745;
    --btn-bg-hover: #1e7e34;
    --btn-radius: 20px;
  }

  .danger {
    --btn-bg: #dc3545;
    --btn-bg-hover: #c82333;
  }
</style>

<themed-button>默认按钮</themed-button>
<themed-button class="danger">危险按钮</themed-button>

HTML Templates(HTML 模板) ​

template 元素 ​

html
<!-- 定义模板(不会被渲染) -->
<template id="card-template">
  <style>
    .card {
      border: 1px solid #ddd;
      border-radius: 8px;
      padding: 16px;
      margin: 8px;
    }
    .card-title {
      font-size: 18px;
      font-weight: bold;
    }
    .card-content {
      color: #666;
      margin-top: 8px;
    }
  </style>
  <div class="card">
    <div class="card-title"></div>
    <div class="card-content"></div>
  </div>
</template>

<script>
// 使用模板
const template = document.getElementById('card-template');

function createCard(title, content) {
  // 克隆模板内容
  const clone = template.content.cloneNode(true);

  // 填充数据
  clone.querySelector('.card-title').textContent = title;
  clone.querySelector('.card-content').textContent = content;

  return clone;
}

// 添加到页面
document.body.appendChild(createCard('标题1', '内容1'));
document.body.appendChild(createCard('标题2', '内容2'));
</script>

在自定义元素中使用模板 ​

javascript
// 定义模板
const template = document.createElement('template');
template.innerHTML = `
  <style>
    :host {
      display: inline-block;
    }
    .counter {
      display: flex;
      align-items: center;
      gap: 8px;
    }
    button {
      width: 32px;
      height: 32px;
      border: 1px solid #ddd;
      background: white;
      cursor: pointer;
      font-size: 18px;
    }
    button:hover {
      background: #f5f5f5;
    }
    .value {
      min-width: 40px;
      text-align: center;
      font-size: 18px;
    }
  </style>
  <div class="counter">
    <button class="decrement">-</button>
    <span class="value">0</span>
    <button class="increment">+</button>
  </div>
`;

class MyCounter extends HTMLElement {
  constructor() {
    super();

    // 使用模板创建 Shadow DOM
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.appendChild(template.content.cloneNode(true));

    // 获取元素引用
    this.valueEl = shadow.querySelector('.value');
    this.decrementBtn = shadow.querySelector('.decrement');
    this.incrementBtn = shadow.querySelector('.increment');

    this._count = 0;
  }

  connectedCallback() {
    this.decrementBtn.addEventListener('click', () => this.decrement());
    this.incrementBtn.addEventListener('click', () => this.increment());
    this.render();
  }

  get count() {
    return this._count;
  }

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

  increment() {
    this.count++;
  }

  decrement() {
    this.count--;
  }

  render() {
    this.valueEl.textContent = this._count;
  }
}

customElements.define('my-counter', MyCounter);

Slot(插槽) ​

默认插槽 ​

javascript
class MyPanel extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });

    shadow.innerHTML = `
      <style>
        .panel {
          border: 1px solid #ddd;
          border-radius: 4px;
        }
        .header {
          background: #f5f5f5;
          padding: 12px;
          font-weight: bold;
        }
        .body {
          padding: 12px;
        }
      </style>
      <div class="panel">
        <div class="header">面板标题</div>
        <div class="body">
          <slot>默认内容</slot>
        </div>
      </div>
    `;
  }
}

customElements.define('my-panel', MyPanel);
html
<my-panel>
  <p>自定义内容1</p>
  <p>自定义内容2</p>
</my-panel>

<my-panel></my-panel>  <!-- 显示"默认内容" -->

具名插槽 ​

javascript
class MyDialog extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });

    shadow.innerHTML = `
      <style>
        .dialog {
          position: fixed;
          top: 50%;
          left: 50%;
          transform: translate(-50%, -50%);
          background: white;
          border-radius: 8px;
          box-shadow: 0 4px 20px rgba(0,0,0,0.2);
          min-width: 300px;
        }
        .header {
          padding: 16px;
          border-bottom: 1px solid #eee;
          display: flex;
          justify-content: space-between;
          align-items: center;
        }
        .body {
          padding: 16px;
        }
        .footer {
          padding: 16px;
          border-top: 1px solid #eee;
          display: flex;
          justify-content: flex-end;
          gap: 8px;
        }
        .close-btn {
          background: none;
          border: none;
          font-size: 20px;
          cursor: pointer;
        }
      </style>
      <div class="dialog">
        <div class="header">
          <slot name="header">对话框标题</slot>
          <button class="close-btn">&times;</button>
        </div>
        <div class="body">
          <slot>对话框内容</slot>
        </div>
        <div class="footer">
          <slot name="footer">
            <button>确定</button>
          </slot>
        </div>
      </div>
    `;
  }
}

customElements.define('my-dialog', MyDialog);
html
<my-dialog>
  <h3 slot="header">确认删除</h3>
  <p>确定要删除这条记录吗?此操作不可恢复。</p>
  <div slot="footer">
    <button class="cancel">取消</button>
    <button class="confirm">确认删除</button>
  </div>
</my-dialog>

监听插槽变化 ​

javascript
class SlotDemo extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });

    shadow.innerHTML = `
      <div>
        <slot name="items"></slot>
      </div>
    `;

    const slot = shadow.querySelector('slot');

    // 监听插槽内容变化
    slot.addEventListener('slotchange', (e) => {
      const assignedElements = slot.assignedElements();
      console.log('插槽内容变化:', assignedElements);
      console.log('元素数量:', assignedElements.length);
    });
  }
}

customElements.define('slot-demo', SlotDemo);

实战组件示例 ​

自定义 Tabs 组件 ​

javascript
class MyTabs extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });

    shadow.innerHTML = `
      <style>
        :host {
          display: block;
        }
        .tabs-header {
          display: flex;
          border-bottom: 2px solid #eee;
        }
        .tab-btn {
          padding: 12px 24px;
          border: none;
          background: none;
          cursor: pointer;
          font-size: 14px;
          color: #666;
          border-bottom: 2px solid transparent;
          margin-bottom: -2px;
          transition: all 0.3s;
        }
        .tab-btn:hover {
          color: #1890ff;
        }
        .tab-btn.active {
          color: #1890ff;
          border-bottom-color: #1890ff;
        }
        .tabs-content {
          padding: 16px 0;
        }
        ::slotted([slot^="panel"]) {
          display: none;
        }
        ::slotted([slot^="panel"].active) {
          display: block;
        }
      </style>
      <div class="tabs">
        <div class="tabs-header"></div>
        <div class="tabs-content">
          <slot></slot>
        </div>
      </div>
    `;

    this.header = shadow.querySelector('.tabs-header');
  }

  connectedCallback() {
    this.render();
  }

  render() {
    // 获取所有 tab 面板
    const panels = Array.from(this.querySelectorAll('[slot^="panel"]'));

    // 创建 tab 按钮
    this.header.innerHTML = '';
    panels.forEach((panel, index) => {
      const btn = document.createElement('button');
      btn.className = 'tab-btn' + (index === 0 ? ' active' : '');
      btn.textContent = panel.getAttribute('data-label') || `Tab ${index + 1}`;
      btn.addEventListener('click', () => this.switchTab(index));
      this.header.appendChild(btn);

      // 设置初始显示状态
      panel.classList.toggle('active', index === 0);
    });
  }

  switchTab(index) {
    // 更新按钮状态
    const buttons = this.header.querySelectorAll('.tab-btn');
    buttons.forEach((btn, i) => {
      btn.classList.toggle('active', i === index);
    });

    // 更新面板显示
    const panels = this.querySelectorAll('[slot^="panel"]');
    panels.forEach((panel, i) => {
      panel.classList.toggle('active', i === index);
    });

    // 触发事件
    this.dispatchEvent(new CustomEvent('tab-change', {
      detail: { index }
    }));
  }
}

customElements.define('my-tabs', MyTabs);
html
<my-tabs>
  <div slot="panel-1" data-label="基本信息">
    <p>这是基本信息面板</p>
  </div>
  <div slot="panel-2" data-label="详细设置">
    <p>这是详细设置面板</p>
  </div>
  <div slot="panel-3" data-label="高级选项">
    <p>这是高级选项面板</p>
  </div>
</my-tabs>

自定义 Modal 组件 ​

javascript
class MyModal extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });

    shadow.innerHTML = `
      <style>
        :host {
          display: none;
        }
        :host([open]) {
          display: block;
        }
        .overlay {
          position: fixed;
          top: 0;
          left: 0;
          right: 0;
          bottom: 0;
          background: rgba(0, 0, 0, 0.5);
          display: flex;
          align-items: center;
          justify-content: center;
          z-index: 1000;
        }
        .modal {
          background: white;
          border-radius: 8px;
          max-width: 500px;
          width: 90%;
          max-height: 90vh;
          overflow: auto;
          animation: fadeIn 0.3s ease;
        }
        @keyframes fadeIn {
          from {
            opacity: 0;
            transform: scale(0.9);
          }
          to {
            opacity: 1;
            transform: scale(1);
          }
        }
        .header {
          padding: 16px 20px;
          border-bottom: 1px solid #eee;
          display: flex;
          justify-content: space-between;
          align-items: center;
        }
        .title {
          font-size: 18px;
          font-weight: 600;
          margin: 0;
        }
        .close-btn {
          background: none;
          border: none;
          font-size: 24px;
          cursor: pointer;
          color: #999;
          line-height: 1;
        }
        .close-btn:hover {
          color: #333;
        }
        .body {
          padding: 20px;
        }
        .footer {
          padding: 16px 20px;
          border-top: 1px solid #eee;
          display: flex;
          justify-content: flex-end;
          gap: 12px;
        }
      </style>
      <div class="overlay">
        <div class="modal" role="dialog" aria-modal="true">
          <div class="header">
            <h2 class="title"><slot name="title">标题</slot></h2>
            <button class="close-btn" aria-label="关闭">&times;</button>
          </div>
          <div class="body">
            <slot></slot>
          </div>
          <div class="footer">
            <slot name="footer"></slot>
          </div>
        </div>
      </div>
    `;

    this.overlay = shadow.querySelector('.overlay');
    this.closeBtn = shadow.querySelector('.close-btn');
  }

  connectedCallback() {
    this.closeBtn.addEventListener('click', () => this.close());
    this.overlay.addEventListener('click', (e) => {
      if (e.target === this.overlay) {
        this.close();
      }
    });

    document.addEventListener('keydown', this.handleKeydown);
  }

  disconnectedCallback() {
    document.removeEventListener('keydown', this.handleKeydown);
  }

  handleKeydown = (e) => {
    if (e.key === 'Escape' && this.hasAttribute('open')) {
      this.close();
    }
  }

  open() {
    this.setAttribute('open', '');
    document.body.style.overflow = 'hidden';
    this.dispatchEvent(new CustomEvent('open'));
  }

  close() {
    this.removeAttribute('open');
    document.body.style.overflow = '';
    this.dispatchEvent(new CustomEvent('close'));
  }

  static get observedAttributes() {
    return ['open'];
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'open') {
      if (newValue !== null) {
        document.body.style.overflow = 'hidden';
      } else {
        document.body.style.overflow = '';
      }
    }
  }
}

customElements.define('my-modal', MyModal);
html
<button id="open-modal">打开弹窗</button>

<my-modal id="modal">
  <span slot="title">确认操作</span>
  <p>确定要执行此操作吗?</p>
  <div slot="footer">
    <button onclick="modal.close()">取消</button>
    <button onclick="handleConfirm()">确认</button>
  </div>
</my-modal>

<script>
  const modal = document.getElementById('modal');

  document.getElementById('open-modal').addEventListener('click', () => {
    modal.open();
  });

  function handleConfirm() {
    console.log('确认');
    modal.close();
  }
</script>

与框架集成 ​

在 React 中使用 ​

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

function MyButton({ children, onClick, disabled }) {
  const ref = useRef();

  useEffect(() => {
    const element = ref.current;
    const handler = (e) => onClick?.(e.detail);

    element.addEventListener('my-click', handler);
    return () => element.removeEventListener('my-click', handler);
  }, [onClick]);

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

// 使用
function App() {
  return (
    <MyButton onClick={(detail) => console.log(detail)}>
      点击我
    </MyButton>
  );
}

在 Vue 中使用 ​

vue
<template>
  <my-counter
    :count="count"
    @change="handleChange"
  ></my-counter>
</template>

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

const count = ref(0);

function handleChange(e) {
  count.value = e.detail.count;
}
</script>

<script>
// vue.config.js 或 vite.config.js
// 告诉 Vue 忽略自定义元素
export default {
  compilerOptions: {
    isCustomElement: (tag) => tag.startsWith('my-')
  }
}
</script>

常见面试题 ​

Q1: Web Components 的优势是什么?

答案:

  1. 原生支持 - 无需框架依赖,浏览器原生支持
  2. 样式隔离 - Shadow DOM 提供完美的样式封装
  3. 可复用性 - 跨框架使用,React/Vue/Angular 都能用
  4. 标准化 - W3C 标准,长期维护有保障
  5. 互操作性 - 与现有 HTML/JS/CSS 无缝集成
Q2: Shadow DOM 和 Virtual DOM 的区别?

答案:

特性Shadow DOMVirtual DOM
本质浏览器原生 APIJavaScript 抽象层
目的样式和 DOM 封装性能优化
可见性DOM 树中真实存在内存中的 JS 对象
框架无需框架React/Vue 等框架实现
使用场景组件封装高效 DOM 更新
Q3: 如何在 Web Components 中实现双向绑定?

答案:

javascript
class TwoWayInput extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });

    shadow.innerHTML = `
      <input type="text">
    `;

    this.input = shadow.querySelector('input');
  }

  static get observedAttributes() {
    return ['value'];
  }

  connectedCallback() {
    this.input.addEventListener('input', (e) => {
      // 更新属性
      this.setAttribute('value', e.target.value);

      // 触发自定义事件
      this.dispatchEvent(new CustomEvent('value-change', {
        detail: { value: e.target.value },
        bubbles: true
      }));
    });
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'value' && this.input.value !== newValue) {
      this.input.value = newValue || '';
    }
  }

  get value() {
    return this.getAttribute('value') || '';
  }

  set value(val) {
    this.setAttribute('value', val);
  }
}

customElements.define('two-way-input', TwoWayInput);

总结 ​

核心要点
  1. Custom Elements - 定义自定义 HTML 元素
  2. Shadow DOM - 封装样式和结构
  3. HTML Templates - 定义可复用模板
  4. Slots - 内容分发机制
适用场景
  • 跨框架的 UI 组件库
  • 微前端架构中的组件共享
  • 设计系统的底层实现
  • 需要长期维护的基础组件
注意事项
  • IE 不支持,需要 polyfill
  • 部分框架需要额外配置
  • SSR 支持有限
  • 学习曲线相对较高