Web Components 完全指南:从 Custom Elements 到 Shadow DOM

一次掌握 Web Components 四大核心技术,打造真正可复用的组件

前言

Web Components 是一套原生浏览器技术标准,用于创建可复用的自定义组件。它不需要任何框架,零依赖,跨浏览器兼容,是前端组件化的终极方案。

很多开发者容易混淆 Custom ElementsWeb Components 的概念。简单来说:

Web Components 是总称,Custom Elements 是其核心组成部分。

css 复制代码
┌─────────────────────────────────────────────────────────────┐
│                    Web Components(总称)                    │
├─────────────────────────────────────────────────────────────┤
│   ┌──────────────┐  ┌──────────────┐  ┌──────────────┐     │
│   │ Custom      │  │ Shadow      │  │ HTML         │     │
│   │ Elements    │  │ DOM         │  │ Templates    │     │
│   └──────────────┘  └──────────────┘  └──────────────┘     │
│         ↓                  ↓                  ↓            │
│    定义组件行为        封装DOM结构        声明组件模板        │
└─────────────────────────────────────────────────────────────┘

本文将从零开始,带你全面掌握 Web Components 的核心技术。


一、Custom Elements:自定义元素的基石

1.1 什么是 Custom Elements

Custom Elements(自定义元素)允许开发者创建自定义的 HTML 标签,封装特定功能和样式。

核心价值

  • 语义化<user-card><div class="user-card"> 更清晰
  • 复用性:一次开发,多处复用
  • 封装性:配合 Shadow DOM,实现样式和 DOM 隔离
  • 互操作性:原生 API,不依赖特定框架

1.2 自定义元素分类

类型 说明 示例
独立自定义元素 全新的自定义标签 <my-element>
定制内置元素 继承原生元素 <button is="my-button">

1.3 快速上手:独立自定义元素

javascript 复制代码
class MyElement extends HTMLElement {
  constructor() {
    super(); // 必须调用
    // 初始化代码
  }

  // 生命周期回调
  connectedCallback() {
    // 元素被插入 DOM 时触发
    this.render();
  }

  disconnectedCallback() {
    // 元素从 DOM 移除时触发
    this.cleanup();
  }

  attributeChangedCallback(name, oldValue, newValue) {
    // 监听的属性变化时触发
  }

  // 指定要监听的属性
  static get observedAttributes() {
    return ['title', 'theme'];
  }
}

// 注册元素(必须包含连字符)
customElements.define('my-element', MyElement);

1.4 实战示例:可交互的计数器

html 复制代码
<counter-element count="0"></counter-element>

<script>
class CounterElement extends HTMLElement {
  static get observedAttributes() {
    return ['count'];
  }

  constructor() {
    super();
    this._count = 0;
  }

  connectedCallback() {
    this.render();
    this.addEventListener('click', this.increment.bind(this));
  }

  attributeChangedCallback(name, oldVal, newVal) {
    if (name === 'count') {
      this._count = parseInt(newVal, 10) || 0;
      this.render();
    }
  }

  increment() {
    this._count++;
    this.setAttribute('count', this._count);
  }

  render() {
    this.innerHTML = `
      <style>
        :host {
          display: inline-block;
          padding: 16px;
          background: #f0f0f0;
          border-radius: 8px;
          cursor: pointer;
          user-select: none;
        }
        :host(:hover) {
          background: #e0e0e0;
        }
      </style>
      <span>点击次数: ${this._count}</span>
    `;
  }
}

customElements.define('counter-element', CounterElement);
</script>

1.5 定制内置元素:继承原生元素

当你想扩展原生元素的行为时,使用定制内置元素:

javascript 复制代码
class ValidatedInput extends HTMLInputElement {
  static get observedAttributes() {
    return ['pattern', 'error-message'];
  }

  constructor() {
    super();
    this._errorMessage = '输入格式错误';
  }

  connectedCallback() {
    this.addEventListener('input', this.validate.bind(this));
  }

  validate() {
    const pattern = this.getAttribute('pattern');
    if (pattern && !new RegExp(pattern).test(this.value)) {
      this.style.borderColor = 'red';
      this.title = this._errorMessage;
    } else {
      this.style.borderColor = '';
      this.title = '';
    }
  }
}

// 注册时需指定 extends
customElements.define('validated-input', ValidatedInput, { extends: 'input' });
html 复制代码
<!-- 必须使用 is 属性 -->
<input is="validated-input" type="text" pattern="^\d{4}$" error-message="请输入4位数字">

⚠️ 注意:Safari 不支持定制内置元素,需使用 polyfill 或避免使用。

1.6 生命周期回调详解

回调方法 触发时机 典型用途
constructor() 元素实例化时 初始化状态、创建 Shadow DOM
connectedCallback() 元素插入 DOM 时 渲染内容、添加事件监听
disconnectedCallback() 元素从 DOM 移除时 清理事件、释放资源
adoptedCallback() 元素移至新文档时 文档迁移时重新初始化
attributeChangedCallback 监听的属性变化时 响应属性变化更新 UI

重要规则

  • constructor不应设置属性或添加子节点(规范要求)
  • disconnectedCallback 中移除事件监听,避免内存泄漏

二、Shadow DOM:真正的封装

2.1 什么是 Shadow DOM

Shadow DOM 允许将一个隐藏的、独立的 DOM 树附加到元素上,实现真正的封装。

xml 复制代码
┌─────────────────────────────────────────────────┐
│              Document (Light DOM)              │
│  ┌───────────────────────────────────────────┐ │
│  │        <my-component>                     │ │
│  │         ┌─────────────────────────────┐  │ │
│  │         │   Shadow Root (Shadow DOM)  │  │ │
│  │         │  ┌───────────────────────┐  │  │ │
│  │         │  │ <style>封装样式</style>│  │  │ │
│  │         │  │ <div>内部结构</div>    │  │  │ │
│  │         │  │ <slot>插槽</slot>      │  │  │ │
│  │         │  └───────────────────────┘  │  │ │
│  │         └─────────────────────────────┘  │ │
│  │   <p>外部内容(投射到 slot)</p>         │ │
│  └───────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘

2.2 创建 Shadow DOM

javascript 复制代码
class MyComponent extends HTMLElement {
  constructor() {
    super();
    // 创建 Shadow DOM
    this.attachShadow({ mode: 'open' });
    // mode: 'open'  - 外部可通过 element.shadowRoot 访问
    // mode: 'closed' - 外部无法访问,shadowRoot 返回 null
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        /* 样式仅作用于 Shadow DOM 内部 */
        :host {
          display: block;
          border: 1px solid #ccc;
          padding: 16px;
        }
        .title {
          color: blue;
          /* 外部 .title 样式不会影响这里 */
        }
      </style>
      <h2 class="title">组件标题</h2>
      <slot name="content"></slot>
      <slot>默认插槽</slot>
    `;
  }
}

customElements.define('my-component', MyComponent);

2.3 Shadow DOM 样式选择器

选择器 作用范围 示例
:host 组件本身 :host { display: block; }
:host(.class) 带特定类的组件 :host(.active) { ... }
:host-context(.class) 祖先匹配时生效 :host-context(.dark) { ... }
::slotted(selector) 插槽内容样式 ::slotted(p) { ... }
::part(name) 暴露给外部的部分 ::part(header) { ... }

2.4 样式隔离演示

html 复制代码
<!-- 外部样式不会影响 Shadow DOM 内部 -->
<style>
  .title { color: red !important; } /* 无效!被 Shadow DOM 阻断 */
</style>

<styled-card class="highlighted">
  <p slot="content">这是插槽内容</p>
  <span>这是默认插槽</span>
</styled-card>

<script>
class StyledCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          padding: 16px;
          background: white;
        }
        :host(.highlighted) {
          border: 2px solid gold;
        }
        .title {
          color: blue;
        }
      </style>
      <h2 class="title">组件标题</h2>
      <slot name="content"></slot>
      <slot></slot>
    `;
  }
}

customElements.define('styled-card', StyledCard);
</script>

2.5 事件模型

Shadow DOM 内部的事件会被重定向

javascript 复制代码
class EventComponent extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `<button id="inner">内部按钮</button>`;

    this.shadowRoot.querySelector('button').addEventListener('click', (e) => {
      // 发送自定义事件到外部
      this.dispatchEvent(new CustomEvent('custom-click', {
        bubbles: true,
        composed: true, // 允许穿透 Shadow DOM 边界
        detail: { message: '来自内部' }
      }));
    });
  }
}

// 外部监听
document.querySelector('event-component').addEventListener('click', (e) => {
  console.log(e.target); // <event-component>(重定向后)
  console.log(e.composedPath()); // 完整的事件路径(包含 Shadow DOM 内部)
});

三、HTML Templates:声明式模板

3.1 基本用法

<template> 元素内的内容是惰性的,不会渲染,但可以被 JavaScript 克隆使用。

html 复制代码
<template id="card-template">
  <style>
    .card {
      border: 1px solid #ddd;
      padding: 16px;
      border-radius: 8px;
    }
  </style>
  <div class="card">
    <div class="card-title">
      <slot name="title">默认标题</slot>
    </div>
    <div class="card-content">
      <slot>默认内容</slot>
    </div>
  </div>
</template>

<script>
class CardTemplate extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    // 获取模板并克隆
    const template = document.getElementById('card-template');
    const clone = template.content.cloneNode(true);
    this.shadowRoot.appendChild(clone);
  }
}

customElements.define('card-template', CardTemplate);
</script>

<!-- 使用 -->
<card-template>
  <h3 slot="title">自定义标题</h3>
  <p>这是卡片内容</p>
</card-template>

3.2 模板优势

特性 说明
惰性加载 模板内容不会渲染,图片不会加载,脚本不会执行
高效克隆 content.cloneNode(true) 比字符串拼接更高效
可复用 同一模板可多次克隆,创建多个实例
结构清晰 HTML 结构在模板中声明,逻辑与表现分离

四、Slot 插槽机制

4.1 具名插槽

html 复制代码
<template id="article-template">
  <article>
    <header>
      <slot name="header"></slot>
    </header>
    <main>
      <slot></slot> <!-- 默认插槽 -->
    </main>
    <footer>
      <slot name="footer"></slot>
    </footer>
  </article>
</template>

<!-- 使用 -->
<my-article>
  <h1 slot="header">文章标题</h1>
  <p>这是正文内容...</p>
  <small slot="footer">版权信息</small>
</my-article>

4.2 插槽事件

javascript 复制代码
class SlotComponent extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `<slot name="content"></slot>`;

    // 监听插槽内容变化
    this.shadowRoot.querySelector('slot').addEventListener('slotchange', (e) => {
      const assigned = e.target.assignedNodes();
      console.log('插槽内容变化:', assigned);
    });
  }
}

4.3 插槽样式

javascript 复制代码
this.shadowRoot.innerHTML = `
  <style>
    /* 插槽容器样式 */
    .slot-container {
      padding: 16px;
      background: #eee;
    }

    /* 插槽内容样式(有限支持) */
    ::slotted(p) {
      margin: 0;
      color: #333;
    }

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

    /* 注意:::slotted 只能选择直接子元素 */
    /* ::slotted(p span) 无效! */
  </style>
  <div class="slot-container">
    <slot></slot>
  </div>
`;

五、完整示例:可复用的标签组件

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>Tags Input 组件示例</title>
</head>
<body>
  <tags-input value="JavaScript,HTML5,CSS"></tags-input>

  <script>
    class TagsInput extends HTMLElement {
      static get observedAttributes() {
        return ['value', 'placeholder', 'readonly'];
      }

      constructor() {
        super();
        this.attachShadow({ mode: 'open' });
        this._tags = [];
        this._placeholder = '输入标签后按回车';
        this._readonly = false;
      }

      connectedCallback() {
        this.render();
      }

      attributeChangedCallback(name, oldVal, newVal) {
        if (name === 'value' && oldVal !== newVal) {
          this._tags = newVal ? newVal.split(',').filter(t => t.trim()) : [];
          if (this.isConnected) this.render();
        }
        if (name === 'placeholder') {
          this._placeholder = newVal || '输入标签后按回车';
        }
        if (name === 'readonly') {
          this._readonly = newVal !== null;
        }
      }

      handleKeydown(e) {
        if (this._readonly) return;
        const input = this.shadowRoot.querySelector('input');

        if (e.key === 'Enter' && input.value.trim()) {
          e.preventDefault();
          this.addTag(input.value.trim());
          input.value = '';
        } else if (e.key === 'Backspace' && !input.value) {
          this.removeLastTag();
        }
      }

      addTag(tag) {
        if (!this._tags.includes(tag)) {
          this._tags.push(tag);
          this.setAttribute('value', this._tags.join(','));
          this.render();
        }
      }

      removeTag(index) {
        if (this._readonly) return;
        this._tags.splice(index, 1);
        this.setAttribute('value', this._tags.join(','));
        this.render();
      }

      removeLastTag() {
        if (this._readonly || this._tags.length === 0) return;
        this._tags.pop();
        this.setAttribute('value', this._tags.join(','));
        this.render();
      }

      get value() {
        return this._tags;
      }

      set value(tags) {
        this.setAttribute('value', Array.isArray(tags) ? tags.join(',') : tags);
      }

      render() {
        this.shadowRoot.innerHTML = `
          <style>
            :host {
              display: block;
              font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            }
            .container {
              display: flex;
              flex-wrap: wrap;
              gap: 8px;
              padding: 8px;
              border: 1px solid #ddd;
              border-radius: 4px;
              background: white;
            }
            .container:focus-within {
              border-color: #007bff;
              box-shadow: 0 0 0 2px rgba(0,123,255,0.25);
            }
            .tag {
              display: inline-flex;
              align-items: center;
              padding: 4px 8px;
              background: #e9ecef;
              border-radius: 4px;
              font-size: 14px;
            }
            .tag button {
              margin-left: 6px;
              padding: 0;
              border: none;
              background: none;
              cursor: pointer;
              color: #666;
              font-size: 16px;
              line-height: 1;
            }
            .tag button:hover {
              color: #dc3545;
            }
            input {
              flex: 1;
              min-width: 120px;
              padding: 4px;
              border: none;
              outline: none;
              font-size: 14px;
            }
            :host([readonly]) .container {
              background: #f8f9fa;
            }
            :host([readonly]) button {
              display: none;
            }
          </style>
          <div class="container">
            ${this._tags.map((tag, i) => `
              <span class="tag">
                ${tag}
                <button type="button" data-index="${i}">&times;</button>
              </span>
            `).join('')}
            <input type="text" placeholder="${this._placeholder}" ${this._readonly ? 'readonly' : ''}>
          </div>
        `;

        // 绑定事件
        this.shadowRoot.querySelectorAll('.tag button').forEach(btn => {
          btn.addEventListener('click', () => {
            this.removeTag(parseInt(btn.dataset.index));
          });
        });

        this.shadowRoot.querySelector('input').addEventListener('keydown', (e) => {
          this.handleKeydown(e);
        });
      }
    }

    customElements.define('tags-input', TagsInput);
  </script>
</body>
</html>

六、框架集成

6.1 React 集成

jsx 复制代码
import React, { useRef, useEffect } from 'react';
import './components/my-modal.js';

function App() {
  const modalRef = useRef(null);

  useEffect(() => {
    const modal = modalRef.current;
    modal.addEventListener('modal-close', () => {
      console.log('模态框已关闭');
    });
    return () => modal.removeEventListener('modal-close', () => {});
  }, []);

  return (
    <div>
      <button onClick={() => modalRef.current.open()}>打开模态框</button>
      <my-modal ref={modalRef} title="React 集成">
        <p>React 内容</p>
      </my-modal>
    </div>
  );
}

6.2 Vue 集成

vue 复制代码
<template>
  <div>
    <button @click="openModal">打开模态框</button>
    <my-modal
      ref="modalRef"
      :title="modalTitle"
      @modal-close="onModalClose"
    >
      <p>Vue 内容</p>
    </my-modal>
  </div>
</template>

<script>
import './components/my-modal.js';

export default {
  data() {
    return { modalTitle: 'Vue 集成' };
  },
  methods: {
    openModal() {
      this.$refs.modalRef.open();
    },
    onModalClose() {
      console.log('模态框关闭');
    }
  }
};
</script>

6.3 框架兼容性配置

javascript 复制代码
// Vue 配置
app.config.compilerOptions.isCustomElement = (tag) => {
  return tag.startsWith('my-');
};

// React 19+ 对 Web Components 有更好支持

七、最佳实践与常见陷阱

7.1 设计原则

原则 说明
渐进增强 提供降级方案,确保无 JavaScript 时基本可用
可访问性 使用 ARIA 属性,支持键盘导航
性能优先 使用 <template>,避免频繁 DOM 操作
样式隔离 使用 CSS 自定义属性暴露主题接口
语义化 合理使用原生元素,继承正确的 ARIA 角色

7.2 常见陷阱

javascript 复制代码
// ❌ 错误:在 constructor 中操作 DOM
constructor() {
  super();
  this.innerHTML = '<p>内容</p>'; // 规范禁止
}

// ✅ 正确:在 connectedCallback 中操作
connectedCallback() {
  this.innerHTML = '<p>内容</p>';
}

// ❌ 错误:忘记清理事件监听
connectedCallback() {
  window.addEventListener('resize', this.handleResize);
}

// ✅ 正确:在 disconnectedCallback 中清理
disconnectedCallback() {
  window.removeEventListener('resize', this.handleResize);
}

7.3 调试技巧

javascript 复制代码
// 检查元素是否已注册
customElements.get('my-element'); // 返回构造函数或 undefined

// 等待元素定义完成
customElements.whenDefined('my-element').then(() => {
  console.log('元素已注册');
});

// 检查 Shadow DOM 模式
const element = document.querySelector('my-component');
console.log(element.shadowRoot); // null = closed 或未创建

// 查看事件路径
element.addEventListener('click', (e) => {
  console.log(e.composedPath()); // 完整路径
});

八、浏览器兼容性

特性 Chrome Firefox Safari Edge
Custom Elements v1 ✅ 67+ ✅ 63+ ✅ 13.1+ ✅ 79+
Shadow DOM v1 ✅ 67+ ✅ 63+ ✅ 13.1+ ✅ 79+
HTML Templates ✅ 全部 ✅ 全部 ✅ 全部 ✅ 全部
CSS ::part() ✅ 73+ ✅ 72+ ✅ 13.1+ ✅ 79+
ElementInternals ✅ 77+ ✅ 75+ ✅ 16.4+ ✅ 79+

Polyfill 方案

html 复制代码
<script src="https://unpkg.com/@webcomponents/webcomponentsjs/webcomponents-bundle.js"></script>

九、Custom Elements vs Web Components:如何选择?

场景 推荐方案
简单行为增强(如表单验证) 仅 Custom Elements
需要样式隔离的组件 Custom Elements + Shadow DOM
组件库开发 完整 Web Components
性能敏感场景 仅 Custom Elements

一句话总结

Custom Elements 定义组件的"行为",Web Components 还定义组件的"边界"(通过 Shadow DOM)。


参考资料


如果这篇文章对你有帮助,欢迎点赞收藏,有问题欢迎在评论区讨论!

相关推荐
kungggyoyoyo1 小时前
从0开发一套geo优化软件:数据模型与API设计
前端·vue.js·后端
Darling噜啦啦1 小时前
BEM 命名规范 + CSS Reset 实战:从微信按钮页面看专业前端开发
前端·css·代码规范
Dirty_Mouse1 小时前
基于langgraph + sentry的自动化前端性能监控日报 (直接上github链接)
前端
悟空瞎说1 小时前
React 项目一键部署至 GitHub Pages 实操教程
前端
To_OC1 小时前
写完这个微信风格按钮页面,我终于吃透了BEM命名+CSS重置
前端·css·html
万少1 小时前
如果你要自动化操作浏览器,Kimi-WebBridge可能适合你
前端·javascript·后端
倾颜2 小时前
React 自定义 Hook 实战:把 AI Chat 的会话流和滚动体验从组件中拆出来
前端·react.js·next.js
vipbic2 小时前
从一句话需求到可交互草图,我用 AI 设计了一个团队组件共享平台
前端
小小前端--可笑可笑2 小时前
【Web 流媒体三部曲之一】直播、点播与 WebRTC 是什么?
前端·webrtc