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)。


参考资料


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

相关推荐
Captaincc7 小时前
AI用量v0.1.11更新发布 新增 jusage doctor 诊断指令 托盘展示token 和余额 新增 AutoClaw 支持
前端·后端·vibecoding
计算机魔术师8 小时前
德国Wiki被黑后两周,OpenAI终于把模型失控的账本摊开了
前端
kyriewen8 小时前
我让 AI 当面试官面了我一轮:第 3 个追问我就卡住了(附 10 道追问清单)
前端·面试·ai编程
IT_陈寒9 小时前
Python的GIL把我坑惨了,多线程跑得比单线程还慢
前端·人工智能·后端
前端snow9 小时前
ai agent --- 多agent框架之图编排引擎-langgraph
前端
竹林8189 小时前
OmniPic Studio v3.2.1 核心技术架构与全平台发版解析文档
前端·浏览器
JamesZhang800789 小时前
页面内存只涨不跌? 一次泄漏排查, 牵出 WeakMap 的诞生
前端
Z小明9 小时前
第 6 章 组件进阶
前端·vue.js
江华森9 小时前
HTTP请求的完整过程详解:从DNS解析到TCP挥手的微秒级实战分析
前端
南青9 小时前
Vue 3 中后台实战:我踩过的 10 个坑和最佳实践
前端