Skip to content

深入理解 Web Components

Custom Elements、Shadow DOM、Template/Slot、Declarative Shadow DOM 完整 API 与最佳实践

什么是 Web Components?

定义:Web Components 是一组浏览器原生的组件化标准,由 Custom Elements(自定义元素)、Shadow DOM(影子 DOM)和 HTML Templates(模板与插槽)三项核心技术组成。它允许开发者创建可复用、封装良好、与框架无关的自定义 HTML 元素。

涉及场景

  • 跨框架组件库:同一套组件可在 React、Vue、Angular 甚至原生页面中使用
  • 设计系统:企业级设计系统(如 Shoelace、Spectrum)用 Web Components 保证一致性
  • 微前端:不同技术栈的子应用通过 Web Components 集成,样式互不污染
  • 第三方嵌入:嵌入到客户页面的 Widget(聊天窗口、支付按钮)用 Shadow DOM 隔离样式
  • SSR 友好:Declarative Shadow DOM 让 Web Components 支持服务端渲染
  • 表单增强ElementInternals 让自定义元素无缝参与原生表单验证和提交

作用

  1. 真正的封装:Shadow DOM 隔离 CSS 和 DOM,组件内部样式不会泄漏或被外部覆盖
  2. 浏览器原生:无需框架支持,浏览器直接解析和渲染
  3. 长期稳定:作为 Web 标准,不会像框架一样频繁变更 API
  4. 面试考点:Shadow DOM 的样式隔离原理、事件穿透(composed)、::part() 穿透方式是常见考题

Web Components 三大核心技术

Web Components = Custom Elements + Shadow DOM + HTML Templates

┌──────────────────────────────────────────────┐
│  Custom Elements                              │
│  定义自定义 HTML 元素及其行为                    │
├──────────────────────────────────────────────┤
│  Shadow DOM                                   │
│  封装组件的 DOM 和样式,与外部隔离               │
├──────────────────────────────────────────────┤
│  HTML Templates (<template> + <slot>)         │
│  定义可复用的 HTML 片段和内容插槽                │
└──────────────────────────────────────────────┘

Custom Elements

定义自定义元素

javascript
// 自主定制元素(Autonomous Custom Element)
class MyButton extends HTMLElement {
  // 1. 声明需要监听的属性
  static get observedAttributes() {
    return ['variant', 'size', 'disabled'];
  }

  // 2. 表单关联(可选)
  static get formAssociated() {
    return true;
  }

  constructor() {
    super();
    // 不要在构造函数中访问属性或子元素
    // 可以:设置初始状态、创建 Shadow DOM、添加事件监听
    this.attachShadow({ mode: 'open' });
    this._internals = this.attachInternals(); // 表单内部状态
  }

  // 3. 生命周期回调
  connectedCallback() {
    // 元素被添加到 DOM 时调用
    // 可以安全地访问属性和子元素
    this.render();
    this.addEventListener('click', this._handleClick);
  }

  disconnectedCallback() {
    // 元素从 DOM 中移除时调用
    // 清理:移除事件监听、取消定时器、断开观察者
    this.removeEventListener('click', this._handleClick);
  }

  adoptedCallback() {
    // 元素被移动到新文档时调用(document.adoptNode)
    // 很少使用
  }

  attributeChangedCallback(name, oldValue, newValue) {
    // observedAttributes 中声明的属性变化时调用
    if (oldValue !== newValue) {
      this.render();
    }
  }

  // 4. 自定义方法
  _handleClick = (e) => {
    if (this.hasAttribute('disabled')) {
      e.preventDefault();
      e.stopPropagation();
      return;
    }
    this.dispatchEvent(new CustomEvent('my-click', {
      bubbles: true,
      composed: true, // 穿透 Shadow DOM 边界
      detail: { originalEvent: e }
    }));
  };

  render() {
    const variant = this.getAttribute('variant') || 'default';
    const size = this.getAttribute('size') || 'medium';

    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: inline-block;
        }
        :host([disabled]) {
          opacity: 0.5;
          pointer-events: none;
        }
        button {
          padding: ${size === 'small' ? '4px 8px' : size === 'large' ? '12px 24px' : '8px 16px'};
          border: 1px solid;
          border-radius: 4px;
          cursor: pointer;
          font-size: inherit;
          background: ${variant === 'primary' ? '#1890ff' : '#fff'};
          color: ${variant === 'primary' ? '#fff' : '#333'};
        }
      </style>
      <button part="button">
        <slot></slot>
      </button>
    `;
  }
}

// 注册自定义元素(名称必须包含连字符)
customElements.define('my-button', MyButton);
html
<!-- 使用 -->
<my-button variant="primary" size="large">点击我</my-button>
<my-button disabled>禁用状态</my-button>

自定义内置元素(Customized Built-in Element)

javascript
// 扩展已有的 HTML 元素
class FancyButton extends HTMLButtonElement {
  constructor() {
    super();
    this.addEventListener('click', () => {
      this.style.transform = 'scale(0.95)';
      setTimeout(() => this.style.transform = '', 150);
    });
  }

  connectedCallback() {
    this.style.transition = 'transform 0.15s';
  }
}

customElements.define('fancy-button', FancyButton, { extends: 'button' });
html
<!-- 使用 is 属性 -->
<button is="fancy-button">Fancy!</button>
<!-- ⚠️ Safari 不支持自定义内置元素 -->

customElements API

javascript
// 定义
customElements.define('my-element', MyElement);
customElements.define('fancy-btn', FancyBtn, { extends: 'button' });

// 获取已注册的构造函数
const MyElement = customElements.get('my-element');

// 等待元素被定义
await customElements.whenDefined('my-element');
// 返回 Promise,元素定义后 resolve

// 升级未定义的元素
customElements.upgrade(element);

// 检查元素名称是否有效
// 必须包含连字符 (-)
// 不能以数字开头
// 全小写

Shadow DOM

创建 Shadow DOM

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

    // mode: 'open' — 外部可通过 element.shadowRoot 访问
    // mode: 'closed' — 外部无法访问(更安全)
    const shadow = this.attachShadow({ mode: 'open' });

    // 其他选项
    this.attachShadow({
      mode: 'open',
      delegatesFocus: true,  // 焦点委托:点击 shadow 内部时自动聚焦
      slotAssignment: 'manual' // 手动分配 slot(而非按 name 自动匹配)
    });
  }
}

Shadow DOM 的样式隔离

javascript
class StyledComponent extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        /* :host 选择宿主元素本身 */
        :host {
          display: block;
          border: 1px solid #ddd;
          padding: 16px;
        }

        /* :host() 条件选择 */
        :host(.dark) {
          background: #333;
          color: #fff;
        }

        /* :host-context() 根据祖先元素匹配 */
        :host-context(.theme-dark) {
          background: #1a1a1a;
        }

        /* 内部样式不会泄漏到外部 */
        p {
          color: blue; /* 只影响 shadow 内部的 p */
        }

        /* ::slotted() 选择被分配到 slot 的元素 */
        ::slotted(h2) {
          color: red;
          /* 只能选择直接分配的顶级元素,不能选子元素 */
        }

        /* ::part() 配合 part 属性,允许外部自定义样式 */
        /* 在外部使用:my-component::part(title) { color: blue; } */
      </style>

      <div part="header">
        <slot name="title"></slot>
      </div>
      <div part="body">
        <slot></slot>
      </div>
    `;
  }
}
css
/* 外部样式(穿透 Shadow DOM 的方式) */

/* 1. CSS 自定义属性可以穿透 */
my-component {
  --primary-color: blue;
  /* shadow 内部可以用 var(--primary-color) */
}

/* 2. ::part() 选择器 */
my-component::part(title) {
  font-size: 24px;
  color: navy;
}

/* 3. 可继承属性自然穿透 */
my-component {
  font-family: Arial; /* 会继承到 shadow 内部 */
  color: #333;        /* 会继承 */
}

事件与 Shadow DOM

javascript
// 事件穿透 Shadow DOM 边界需要 composed: true
this.dispatchEvent(new CustomEvent('my-event', {
  bubbles: true,     // 冒泡
  composed: true,    // 穿透 Shadow DOM 边界
  detail: { data: 'value' }
}));

// 原生事件的 composed 属性
// composed: true(可穿透): click, focus, blur, input, keydown, mousedown ...
// composed: false(不穿透): scroll, load, unload, abort, error, select ...

// 事件重定向(retargeting)
// 在 Shadow DOM 外部,event.target 会被重定向为宿主元素
// 在 Shadow DOM 内部,event.target 是实际的目标元素
shadow.querySelector('button').addEventListener('click', (e) => {
  console.log(e.target);       // <button>(shadow 内部)
  console.log(e.composedPath()); // 完整的事件路径,包括 shadow 内部元素
});

document.addEventListener('click', (e) => {
  console.log(e.target); // <my-component>(被重定向为宿主元素)
});

HTML Templates

<template> 元素

html
<template id="card-template">
  <style>
    .card {
      border: 1px solid #ddd;
      border-radius: 8px;
      padding: 16px;
    }
  </style>
  <div class="card">
    <slot name="title"></slot>
    <slot></slot>
    <slot name="footer"></slot>
  </div>
</template>

<script>
class MyCard extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    const template = document.getElementById('card-template');
    // cloneNode(true) 深克隆模板内容
    shadow.appendChild(template.content.cloneNode(true));
  }
}
customElements.define('my-card', MyCard);
</script>

<!-- 使用 -->
<my-card>
  <h2 slot="title">卡片标题</h2>
  <p>默认 slot 的内容</p>
  <div slot="footer">底部信息</div>
</my-card>

Slot(插槽)

javascript
// 监听 slot 变化
const slot = this.shadowRoot.querySelector('slot');

slot.addEventListener('slotchange', (e) => {
  const assigned = slot.assignedNodes({ flatten: true });
  console.log('slot 内容变化:', assigned);
});

// 获取 slot 分配的元素
slot.assignedElements();           // 只返回元素节点
slot.assignedNodes();              // 返回所有节点(包括文本)
slot.assignedNodes({ flatten: true }); // 展平回退内容

// 手动分配 slot(slotAssignment: 'manual')
const shadow = this.attachShadow({ mode: 'open', slotAssignment: 'manual' });
const slot = shadow.querySelector('slot');
slot.assign(element1, element2); // 手动指定哪些元素分配到此 slot

Declarative Shadow DOM(声明式 Shadow DOM)

html
<!-- 服务端渲染 Web Components!不需要 JS 就能有 Shadow DOM -->
<my-card>
  <template shadowrootmode="open">
    <style>
      .card { border: 1px solid #ddd; padding: 16px; }
    </style>
    <div class="card">
      <slot></slot>
    </div>
  </template>
  <p>这是卡片内容</p>
</my-card>

<!-- shadowrootmode 值 -->
<!-- "open" — 可通过 element.shadowRoot 访问 -->
<!-- "closed" — 外部不可访问 -->

<!-- 
浏览器解析到 <template shadowrootmode> 时:
1. 自动创建 Shadow Root
2. 将 template 内容作为 shadow tree
3. 不需要 JavaScript!
4. 适合 SSR / SSG
-->
javascript
// JS 中获取声明式 Shadow DOM
const host = document.querySelector('my-card');
console.log(host.shadowRoot); // ShadowRoot(如果 mode 是 open)

// 服务端序列化
const html = element.getHTML({ serializableShadowRoots: true });
// 包含 <template shadowrootmode> 的 HTML 字符串

ElementInternals API

javascript
class MyInput extends HTMLElement {
  static get formAssociated() { return true; }
  static get observedAttributes() { return ['value', 'required']; }

  constructor() {
    super();
    this._internals = this.attachInternals();
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <input type="text" part="input" />
    `;

    const input = this.shadowRoot.querySelector('input');
    input.addEventListener('input', (e) => {
      this._internals.setFormValue(e.target.value);
      this._validate(e.target.value);
    });
  }

  _validate(value) {
    if (this.hasAttribute('required') && !value) {
      this._internals.setValidity(
        { valueMissing: true },
        '此字段必填',
        this.shadowRoot.querySelector('input')
      );
    } else {
      this._internals.setValidity({});
    }
  }

  // 表单相关属性
  get form() { return this._internals.form; }
  get validity() { return this._internals.validity; }
  get validationMessage() { return this._internals.validationMessage; }
  checkValidity() { return this._internals.checkValidity(); }
  reportValidity() { return this._internals.reportValidity(); }

  // CustomStateSet(自定义状态)
  // this._internals.states.add('checked');
  // CSS 中用 :state(checked) 选择
}

customElements.define('my-input', MyInput);
html
<form>
  <my-input name="username" required></my-input>
  <button type="submit">提交</button>
</form>

Adopted Stylesheets(共享样式表)

javascript
// 创建可复用的样式表
const sheet = new CSSStyleSheet();
sheet.replaceSync(`
  :host { display: block; }
  .container { padding: 16px; }
`);

// 多个 Shadow DOM 共享同一个样式表(性能优化)
class ComponentA extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.adoptedStyleSheets = [sheet];
  }
}

class ComponentB extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.adoptedStyleSheets = [sheet]; // 共享,不会重复创建
  }
}

// 也可以用于 document
document.adoptedStyleSheets = [sheet];

// 异步加载
const sheet2 = new CSSStyleSheet();
await sheet2.replace('@import url("styles.css")');

总结

Web Components 核心知识点:
┌──────────────────────────────────────────────────────────┐
│ Custom Elements                                           │
│ • customElements.define() 注册自定义元素                    │
│ • 生命周期:constructor → connected → attributeChanged     │
│ •          → disconnected → adopted                       │
│ • observedAttributes 声明要监听的属性                       │
│ • 名称必须包含连字符(my-component)                        │
├──────────────────────────────────────────────────────────┤
│ Shadow DOM                                                │
│ • attachShadow({ mode: 'open'|'closed' })                │
│ • 样式隔离:内部样式不泄漏,外部样式不侵入                    │
│ • 穿透方式:CSS变量、::part()、可继承属性                    │
│ • 事件:composed: true 穿透边界,target 被重定向             │
├──────────────────────────────────────────────────────────┤
│ Template & Slot                                           │
│ • <template> 惰性 DOM 片段,cloneNode 使用                 │
│ • <slot name="x"> 命名插槽,默认 slot 接收未指定的内容       │
│ • ::slotted() 选择 slot 中分配的元素                       │
├──────────────────────────────────────────────────────────┤
│ 新特性                                                    │
│ • Declarative Shadow DOM:SSR 友好,无需 JS                │
│ • ElementInternals:表单关联、自定义验证、状态               │
│ • Adopted Stylesheets:多组件共享样式表                     │
└──────────────────────────────────────────────────────────┘