Web Components

深入理解 Web Components:Custom Elements 生命周期、Shadow DOM 封装、HTML Templates、slot 分发机制,以及与 React/Vue 的对比。

Web Components

一、Web Components 概述

1.1 什么是 Web Components

Web Components 是一组 Web 平台 API 的统称,允许开发者创建可复用的自定义元素:

  • Custom Elements:创建自定义 HTML 元素
  • Shadow DOM:封装 DOM 和样式
  • HTML Templates:模板内容的惰性解析
  • Slots:内容分发机制

Web Components 的核心理念是可复用性封装性——创建的组件可以在任何框架或原生 HTML 中使用。

1.2 与框架组件的区别

特性 Web Components React/Vue
运行环境 原生浏览器 需要框架
样式封装 Shadow DOM CSS Modules/Styled
生命周期 浏览器定义 框架定义
跨框架使用 原生支持 需要适配
生态 较小 丰富

二、Custom Elements

2.1 自定义元素的分类

Autonomous custom elements:独立元素,继承自 HTMLElement

class MyButton extends HTMLElement {
  constructor() {
    super();
    // ...
  }
}

customElements.define('my-button', MyButton);

Customized built-in elements:继承自现有元素

class MyButton extends HTMLButtonElement {
  constructor() {
    super();
    // ...
  }
}

customElements.define('my-button', MyButton, { extends: 'button' });
// 使用:<button is="my-button">Click</button>

2.2 生命周期回调

class MyElement extends HTMLElement {
  constructor() {
    super();
    // 元素被创建时调用,但元素还未挂载到文档
    console.log('constructor');
  }

  connectedCallback() {
    // 元素首次被插入到文档时调用
    console.log('connectedCallback');
  }

  disconnectedCallback() {
    // 元素从文档中删除时调用
    console.log('disconnectedCallback');
  }

  attributeChangedCallback(name, oldValue, newValue) {
    // 元素的属性变化时调用
    console.log(`Attribute ${name} changed from ${oldValue} to ${newValue}`);
  }

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

  static get observedAttributes() {
    // 返回需要观察的属性列表
    return ['title', 'disabled'];
  }
}

2.3 响应式属性

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

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

  connectedCallback() {
    this.render();
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'count') {
      this._count = parseInt(newValue, 10) || 0;
      this.update();
    }
  }

  // 允许 JavaScript 属性访问
  get count() {
    return this._count;
  }

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

  update() {
    const span = this.shadowRoot.querySelector('span');
    span.textContent = this._count;
  }

  render() {
    this.shadowRoot.innerHTML = `
      <button id="dec">-</button>
      <span>${this._count}</span>
      <button id="inc">+</button>
    `;

    this.shadowRoot.querySelector('#inc').onclick = () => {
      this.count++;
    };
    this.shadowRoot.querySelector('#dec').onclick = () => {
      this.count--;
    };
  }
}

customElements.define('counter-element', CounterElement);

三、Shadow DOM

3.1 attachShadow 模式

// open 模式:可以从外部访问 shadowRoot
const shadow = element.attachShadow({ mode: 'open' });

// closed 模式:无法从外部访问
const shadow = element.attachShadow({ mode: 'closed' });
// shadowRoot 返回 null

建议使用 open 模式。closed 模式可以通过 Element.attachShadow() 的内部引用绕过,而且会阻止工具和框架访问。

3.2 Shadow DOM 的样式封装

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

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          padding: 16px;
          background: white;
          border-radius: 8px;
          box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        }

        :host([disabled]) {
          opacity: 0.5;
          pointer-events: none;
        }

        :host-context(.dark-theme) {
          background: #333;
          color: white;
        }

        .content {
          color: inherit;
        }
      </style>
      <div class="content">
        <slot></slot>
      </div>
    `;
  }
}

样式封装规则

  • :host 选择器:选择 Shadow DOM 的根元素
  • :host([attr]):当元素有特定属性时匹配
  • :host-context(selector):当祖先元素匹配选择器时匹配
  • ::slotted(selector):选择被分发到 slot 的元素

3.3 ::slotted() 伪元素

<!-- 组件定义 -->
<template id="my-card">
  <style>
    .header { background: #f0f0f0; padding: 10px; }
    ::slotted(h2) { margin: 0; color: blue; }
  </style>
  <div class="header">
    <slot name="title"></slot>
  </div>
  <div class="body">
    <slot></slot>
  </div>
</template>
<!-- 使用组件 -->
<my-card>
  <h2 slot="title">Card Title</h2>
  <p>Card content</p>
</my-card>

四、HTML Templates

4.1 template 元素

<template id="my-template">
  <style>
    p { color: blue; }
  </style>
  <p>This is a template</p>
</template>

<script>
  const template = document.getElementById('my-template');
  const clone = template.content.cloneNode(true);
  document.body.appendChild(clone);
</script>

template 的特点

  • 内容是惰性解析的,不会被执行或渲染
  • 内部的脚本不会运行,样式不会应用
  • 可以被多次克隆使用

4.2 与 customElements 结合

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

  connectedCallback() {
    const template = document.getElementById('my-card-template');
    const clone = template.content.cloneNode(true);
    this.shadowRoot.appendChild(clone);

    // 绑定事件
    this.shadowRoot.querySelector('.close').onclick = () => {
      this.remove();
    };
  }
}

customElements.define('my-card', MyCard);

五、Slots 内容分发

5.1 匿名 slot

<!-- 组件 -->
<div class="container">
  <slot></slot>
</div>

<!-- 使用 -->
<my-element>
  <p>这段内容会分发到匿名 slot</p>
</my-element>

5.2 命名 slot

<!-- 组件 -->
<div class="card">
  <header><slot name="header"></slot></header>
  <main><slot></slot></main>
  <footer><slot name="footer"></slot></footer>
</div>

<!-- 使用 -->
<my-card>
  <h1 slot="header">Title</h1>
  <p>Main content</p>
  <span slot="footer">Footer content</span>
</my-card>

5.3 slotchange 事件

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

  connectedCallback() {
    this.shadowRoot.innerHTML = `<slot></slot>`;

    // 监听 slot 内容变化
    this.shadowRoot.querySelector('slot').addEventListener('slotchange', (e) => {
      const nodes = e.target.assignedNodes();
      console.log(`Slot changed, ${nodes.length} nodes`);
    });
  }
}

六、与 React/Vue 的对比

6.1 Web Components 的优势

  • 原生支持:不需要任何框架,可以在任何 HTML 中使用
  • 真正的封装:Shadow DOM 提供真正的样式隔离
  • 跨框架使用:同一个组件可以在 React、Vue、Angular 中使用

6.2 Web Components 的劣势

  • 生态较小:没有 React/Vue 那么丰富的社区组件
  • 开发体验:没有热更新、DevTools 调试等现代化工具
  • 响应式系统:需要自己实现,没有框架的响应式系统方便
  • 浏览器支持:旧浏览器需要 polyfills

6.3 混合使用

// 在 React 中使用 Web Component
class MyCard extends HTMLElement {
  connectedCallback() {
    this.innerHTML = `<div>Card Content</div>`;
  }
}
customElements.define('my-card', MyCard);

// React 使用
function App() {
  return <my-card />;
}

延展阅读