Skip to content

Web Components

一、核心概念

Web Components 是一套原生Web技术规范,用于创建可复用、封装性强、跨框架兼容的自定义HTML组件,无需依赖React、Vue等框架,直接在浏览器中运行,实现组件化开发的原生解决方案。

核心三大技术

  1. Custom Elements(自定义元素):定义自定义HTML标签的行为,注册专属组件,浏览器可识别并渲染。
  2. Shadow DOM(影子DOM):实现组件内部DOM结构、样式、事件的完全隔离,避免全局样式污染、DOM冲突,是组件封装的核心。
  3. HTML Templates(HTML模板):通过<slot>标签定义组件结构模板,内容不直接渲染,<slot>实现内容插槽分发。

二、核心技术详解

1. Custom Elements(自定义元素)

分为自主自定义元素(继承HTMLElement)和自定义内置元素(继承现有元素如HTMLButtonElement),日常开发以自主自定义元素为主。

核心生命周期钩子

  • constructor():组件创建时调用,初始化状态、挂载Shadow DOM,必须先调用super()
  • connectedCallback():组件插入DOM时调用,可执行渲染、事件绑定
  • disconnectedCallback():组件从DOM移除时调用,用于清理事件、定时器
  • attributeChangedCallback():组件属性变化时调用,需先在static get observedAttributes()声明监听属性
  • adoptedCallback():组件被移动到新文档时调用,极少使用

基础语法

javascript
// 定义组件类
class MyComponent extends HTMLElement {
  // 声明监听的属性
  static get observedAttributes() {
    return ['title', 'count']
  }

  constructor() {
    super()
    // 通常在此处挂载Shadow DOM
  }
}
// 注册自定义元素(标签名必须含短横线,避免与原生标签冲突)
customElements.define('my-custom-component', MyComponent)

2. Shadow DOM(影子DOM)

核心作用

  • 样式隔离:组件内部CSS只作用于自身,外部样式无法穿透,内部样式也不会污染全局
  • DOM隔离:内部DOM节点不会被全局document.querySelector获取,避免DOM冲突
  • 封装性:组件内部结构私有化,对外只暴露属性和事件

关键API

  • element.attachShadow({ mode: 'open' | 'closed' }):创建Shadow Root
    • open:可通过element.shadowRoot访问内部DOM
    • closed:无法外部访问,安全性更高
  • 样式相关伪类
    • :host:选中组件自身(自定义元素标签)
    • :host(.className):选中带指定类名的组件
    • ::slotted(selector):选中外部插入的插槽内容

3. HTML Templates <template>

定义组件模板,内容不会直接渲染,通过JS克隆后使用

  • <slot>:插槽,允许外部传入内容到组件指定位置,支持具名插槽默认插槽

三、完整实战示例

1. 基础可复用组件(含三大核心技术)

html
<!-- 定义模板 -->
<template id="card-template">
  <style>
    /* 组件内部样式,完全隔离 */
    .card {
      border: 1px solid #eee;
      border-radius: 8px;
      padding: 16px;
      width: 300px;
      box-shadow: 0 2px 8px rgba(0,0,0,0.1);
    }
    .card-title {
      font-size: 18px;
      color: #333;
      margin: 0 0 12px;
    }
    .card-content {
      font-size: 14px;
      color: #666;
    }
    /* 选中组件自身 */
    :host {
      display: inline-block;
      margin: 8px;
    }
  </style>

  <div class="card">
    <h3 class="card-title"></h3>
    <p class="card-content">
      <!-- 具名插槽 -->
      <slot name="content"></slot>
    </p>
    <!-- 默认插槽 -->
    <slot></slot>
  </div>
</template>

<script>
  const cardTemplate = document.getElementById('card-template')

  class CustomCard extends HTMLElement {
    static get observedAttributes() {
      return ['title']
    }

    constructor() {
      super()
      this.attachShadow({ mode: 'open' })
      this.shadowRoot.appendChild(cardTemplate.content.cloneNode(true))
      this.titleEl = this.shadowRoot.querySelector('.card-title')
    }

    attributeChangedCallback(name, oldValue, newValue) {
      if (name === 'title' && this.titleEl) {
        this.titleEl.textContent = newValue
      }
    }
  }

  customElements.define('custom-card', CustomCard)
</script>

<!-- 使用 -->
<custom-card title="我的自定义卡片">
  <div slot="content">插槽内容</div>
  <p>默认插槽内容</p>
</custom-card>

2. 带交互和属性的组件

javascript
class CounterComponent extends HTMLElement {
  static get observedAttributes() {
    return ['init-count']
  }

  constructor() {
    super()
    this.count = 0
    this.attachShadow({ mode: 'open' })
    this.shadowRoot.innerHTML = `
      <style>
        .counter { padding: 12px; border: 1px solid #ccc; border-radius: 4px; }
        button { margin: 0 4px; padding: 4px 8px; cursor: pointer; }
      </style>
      <div class="counter">
        <p>计数:<span class="count-num">0</span></p>
        <button class="add-btn">+</button>
        <button class="minus-btn">-</button>
      </div>
    `
    this.countEl = this.shadowRoot.querySelector('.count-num')
    this.addBtn = this.shadowRoot.querySelector('.add-btn')
    this.minusBtn = this.shadowRoot.querySelector('.minus-btn')
  }

  connectedCallback() {
    this.addBtn.addEventListener('click', () => this.updateCount(1))
    this.minusBtn.addEventListener('click', () => this.updateCount(-1))
    this.count = Number(this.getAttribute('init-count')) || 0
    this.renderCount()
  }

  updateCount(num) {
    this.count += num
    this.renderCount()
    this.dispatchEvent(new CustomEvent('count-change', {
      detail: this.count,
      bubbles: true,
      composed: true
    }))
  }

  renderCount() {
    this.countEl.textContent = this.count
  }

  disconnectedCallback() {
    // 注意:原匿名函数无法正确移除,这里仅演示写法
    this.addBtn.removeEventListener('click', () => this.updateCount(1))
    this.minusBtn.removeEventListener('click', () => this.updateCount(-1))
  }
}

customElements.define('counter-component', CounterComponent)

使用+监听事件

html
<counter-component init-count="0"></counter-component>

<script>
  document.querySelector('counter-component').addEventListener('count-change', (e) => {
    console.log('计数变化:', e.detail)
  })
</script>

四、组件通信方式

  1. 属性传值:通过HTML属性传递数据,配合observedAttributes监听变化
  2. 插槽传内容:使用<slot>传入DOM结构,支持具名、默认、作用域插槽
  3. 自定义事件:通过dispatchEvent触发CustomEvent,父组件监听事件接收数据,需设置composed: true穿透Shadow DOM
  4. 属性/方法调用:获取组件DOM实例,直接调用组件内部方法或修改属性(仅open模式Shadow DOM)

五、兼容性

  • 现代浏览器:Chrome、Firefox、Edge、Safari 10.1+ 完全支持
  • IE浏览器:完全不支持,可通过polyfill兼容(@webcomponents/webcomponentsjs)
  • 兼容处理
html
<script src="https://unpkg.com/@webcomponents/webcomponentsjs@2.8.0/webcomponents-bundle.js"></script>

六、Web Components 与框架结合

1. 与Vue结合

Vue组件可直接使用Web Components,需在配置中声明自定义元素,避免Vue将其当作未知组件:

javascript
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue({
    template: {
      compilerOptions: {
        isCustomElement: (tag) => tag.includes('-')
      }
    }
  })]
})

2. 与React结合

直接在React中引入并使用,和原生HTML标签用法一致,注意事件监听需通过ref获取组件实例。

七、常见问题与注意事项

  1. 自定义标签命名:必须包含短横线(-),如my-component,禁止使用mycomponentdiv等原生标签名
  2. Shadow DOM样式隔离:外部样式无法穿透,如需修改内部样式,可通过CSS变量::part()属性暴露内部节点
  3. 事件穿透:自定义事件需设置composed: true,才能让事件从Shadow DOM冒泡到外部文档
  4. 性能优化:避免频繁创建/销毁Shadow DOM,合理清理事件和定时器,减少重渲染
  5. 与框架组件区别:Web Components是原生组件,无虚拟DOM,适合跨框架通用组件;框架组件有自身生态和优化,适合项目内业务组件