欢迎光临

Web Components 深度实战:用 Custom Elements 和 Shadow DOM 构建框架无关的可复用组件

Web Components 组件化开发

为什么需要 Web Components?

在前端开发领域,组件化早已成为主流范式。React 有 JSX 组件,Vue 有 SFC 单文件组件,Angular 有 @Component 装饰器——每个框架都有自己的组件模型,但它们彼此互不兼容。你用 React 写的按钮组件无法直接在 Vue 项目里使用,Angular 的指令也无法移植到 Svelte 中。这种框架锁定让跨团队、跨项目的组件复用变得极其困难。

Web Components 是 W3C 制定的一套浏览器原生标准,它提供了一种框架无关的组件化方案。基于 Custom Elements、Shadow DOM、HTML Templates 和 ES Module Imports 四大核心 API,你可以在任何前端框架甚至纯 HTML 中使用同一个组件。Google、Mozilla、Apple、Microsoft 共同推动的这一标准,现已获得所有主流浏览器的完整支持(包括 Safari 16.4+ 和 Firefox 63+)。

本文将从零开始,带你深入 Web Components 的每个核心 API,并最终构建一个完整的企业级可复用组件库。你将掌握:

  • Custom Elements 的生命周期与最佳实践
  • Shadow DOM 的样式隔离与插槽机制
  • HTML Templates 与动态渲染
  • 表单关联(Form-associated Custom Elements)
  • 与 React/Vue/Angular 的互操作
  • 组件库的工程化与发布策略

Custom Elements:定义你的第一个原生组件

Custom Elements API 允许你注册全新的 HTML 标签,或扩展现有标签。注册后的标签在浏览器中与原生

1
<div>

1
<input>

没有本质区别——它们同样参与 DOM 解析、事件冒泡、CSS 选择器匹配。

Autonomous Custom Elements

创建全新的 HTML 元素,标签名必须包含连字符(

1
-

),以避免与未来 HTML 标准冲突:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class MyButton extends HTMLElement {
  constructor() {
    super();
    // 初始化:创建 Shadow DOM 等
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: inline-block;
        }
        button {
          padding: 10px 24px;
          border: 2px solid #4a90d9;
          border-radius: 6px;
          background: #4a90d9;
          color: #fff;
          font-size: 14px;
          cursor: pointer;
          transition: all 0.2s ease;
        }
        button:hover {
          background: #357abd;
          transform: translateY(-1px);
          box-shadow: 0 4px 12px rgba(74, 144, 217, 0.3);
        }
        button:active {
          transform: translateY(0);
        }
      </style>
      <button><slot>点击我</slot></button>
    `;
  }
}

// 注册组件,标签名为 <my-button>
customElements.define('my-button', MyButton);

使用时只需在 HTML 中写:


1
2
<my-button>提交表单</my-button>
<my-button>取消操作</my-button>

生命周期回调

Custom Elements 提供了完整的生命周期钩子,让你在组件的关键阶段执行逻辑:

回调 触发时机 典型用途
1
constructor()
元素创建或升级时 初始化 Shadow DOM、内部状态
1
connectedCallback()
元素插入 DOM 时 启动定时器、订阅事件、请求数据
1
disconnectedCallback()
元素从 DOM 移除时 清理定时器、取消订阅、释放资源
1
adoptedCallback()
元素移到新 document 时 处理 iframe 迁移场景
1
attributeChangedCallback()
observed 属性变化时 响应属性变更,更新渲染

一个典型的完整生命周期示例:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
class DataFetcher extends HTMLElement {
  // 声明需要监听的属性
  static get observedAttributes() {
    return ['url', 'method', 'headers'];
  }

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._abortController = null;
    this._data = null;
    this._loading = false;
    this._error = null;
  }

  connectedCallback() {
    // 元素进入 DOM 时自动获取数据
    if (this.url) this.fetchData();
  }

  disconnectedCallback() {
    // 元素移除时中止进行中的请求
    if (this._abortController) {
      this._abortController.abort();
    }
  }

  attributeChangedCallback(name, oldVal, newVal) {
    if (oldVal === newVal) return;
    switch (name) {
      case 'url':
        // URL 变化时重新获取数据
        if (this.isConnected) this.fetchData();
        break;
      case 'method':
        this._method = newVal || 'GET';
        break;
    }
  }

  get url() { return this.getAttribute('url'); }
  set url(v) { this.setAttribute('url', v); }

  async fetchData() {
    this._abortController?.abort();
    this._abortController = new AbortController();
    this._loading = true;
    this._error = null;
    this.render();

    try {
      const resp = await fetch(this.url, {
        method: this._method || 'GET',
        signal: this._abortController.signal,
      });
      this._data = await resp.json();
      this._loading = false;
      this.dispatchEvent(new CustomEvent('data-loaded', {
        detail: this._data,
        bubbles: true,
        composed: true,  // 允许事件穿透 Shadow DOM
      }));
    } catch (err) {
      if (err.name !== 'AbortError') {
        this._error = err.message;
      }
    } finally {
      this._loading = false;
      this.render();
    }
  }

  render() {
    this.shadowRoot.innerHTML = `
      <style>
        :host { display: block; }
        .loader { color: #888; font-style: italic; }
        .error { color: #d32f2f; }
        pre { background: #f5f5f5; padding: 16px; overflow: auto; }
      </style>
      ${this._loading ? '<div class="loader">加载中...</div>' : ''}
      ${this._error ? '<div class="error">错误: ' + this._error + '</div>' : ''}
      ${this._data ? '<pre>' + JSON.stringify(this._data, null, 2) + '</pre>' : ''}
    `;
  }
}

customElements.define('data-fetcher', DataFetcher);

Shadow DOM:真正的样式隔离

Shadow DOM 是 Web Components 最强大的特性之一。它为组件创建一个独立的 DOM 子树,外部 CSS 无法渗透进去,内部样式也不会泄漏出来。这意味着你再也不用担心全局 CSS 污染、选择器优先级冲突、或 BEM 命名约定的维护噩梦。

Shadow DOM 样式隔离示意图

open 与 closed 模式

创建 Shadow DOM 时需要选择模式:

  • 1
    mode: 'open'

    — 外部可以通过

    1
    element.shadowRoot

    访问 Shadow DOM 内部(调试友好,大多数场景推荐)

  • 1
    mode: 'closed'

    1
    element.shadowRoot

    返回

    1
    null

    ,外部无法直接访问(安全隔离更严格,但调试困难)


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class IsolatedCard extends HTMLElement {
  constructor() {
    super();
    // open 模式:推荐用于可调试、可测试的组件
    this.attachShadow({ mode: 'open' });
  }
}

class SecureWidget extends HTMLElement {
  constructor() {
    super();
    // closed 模式:严格隔离,第三方嵌入场景
    this.attachShadow({ mode: 'closed' });
    // closed 模式下需要保存引用
    this._shadow = this.shadowRoot; // null!
  }
}

样式封装与 :host 选择器

Shadow DOM 内的样式完全封装。你需要使用特殊的 CSS 伪类来控制宿主元素和外部交互:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class ThemedBadge extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        /* :host 选择组件自身 */
        :host {
          display: inline-block;
          padding: 4px 12px;
          border-radius: 12px;
          font-size: 12px;
          font-weight: 600;
        }

        /* :host() 根据宿主属性匹配 */
        :host([type="success"]) {
          background: #e8f5e9;
          color: #2e7d32;
        }
        :host([type="warning"]) {
          background: #fff3e0;
          color: #e65100;
        }
        :host([type="error"]) {
          background: #ffebee;
          color: #c62828;
        }

        /* :host-context() 根据祖先元素匹配(Safari 不支持) */
        :host-context(.dark-theme) {
          filter: brightness(0.8);
        }

        /* ::slotted() 选择通过 slot 传入的内容 */
        ::slotted(span) {
          font-weight: 700;
        }
      </style>
      <slot></slot>
    `;
  }
}

customElements.define('themed-badge', ThemedBadge);

使用方式:


1
2
3
<themed-badge type="success">已上线</themed-badge>
<themed-badge type="warning">审核中</themed-badge>
<themed-badge type="error">部署失败</themed-badge>

CSS Custom Properties 穿透 Shadow DOM

虽然普通 CSS 无法穿透 Shadow DOM 边界,但CSS 自定义属性(CSS Variables)可以。这是实现组件主题定制的核心机制:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class CustomProgress extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          /* 声明默认值,外部可覆盖 */
          --progress-height: 8px;
          --progress-bg: #e0e0e0;
          --progress-fill: #4a90d9;
          --progress-radius: 4px;
        }
        .track {
          height: var(--progress-height);
          background: var(--progress-bg);
          border-radius: var(--progress-radius);
          overflow: hidden;
        }
        .fill {
          height: 100%;
          width: var(--value, 0%);
          background: var(--progress-fill);
          border-radius: var(--progress-radius);
          transition: width 0.3s ease;
        }
      </style>
      <div class="track">
        <div class="fill"></div>
      </div>
    `;
  }

  static get observedAttributes() {
    return ['value'];
  }

  attributeChangedCallback(name, oldVal, newVal) {
    if (name === 'value') {
      this.style.setProperty('--value', newVal + '%');
    }
  }
}

customElements.define('custom-progress', CustomProgress);

外部轻松定制主题:


1
2
3
4
5
6
7
8
9
10
11
12
/* 深色主题定制 */
custom-progress.dark {
  --progress-bg: #333;
  --progress-fill: #00e676;
  --progress-height: 12px;
  --progress-radius: 6px;
}

/* 警告色主题 */
custom-progress.warning {
  --progress-fill: #ff9800;
}

Slots:内容分发与组合

Slots(插槽)是 Web Components 实现组合模式的关键机制。它允许使用者将自定义内容插入组件内部的指定位置,类似 React 的 children 或 Vue 的 slot。

默认插槽与命名插槽


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class CardLayout extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          --card-radius: 12px;
          --card-shadow: 0 2px 12px rgba(0,0,0,0.08);
        }
        .card {
          border-radius: var(--card-radius);
          box-shadow: var(--card-shadow);
          background: #fff;
          overflow: hidden;
        }
        .card-header {
          padding: 16px 20px;
          border-bottom: 1px solid #eee;
          font-size: 18px;
          font-weight: 600;
        }
        .card-body {
          padding: 20px;
        }
        .card-footer {
          padding: 12px 20px;
          border-top: 1px solid #eee;
          display: flex;
          justify-content: flex-end;
          gap: 8px;
        }
      </style>
      <div class="card">
        <div class="card-header">
          <slot name="header">默认标题</slot>
        </div>
        <div class="card-body">
          <slot>默认内容</slot>
        </div>
        <div class="card-footer">
          <slot name="footer"></slot>
        </div>
      </div>
    `;
  }
}

customElements.define('card-layout', CardLayout);

使用命名插槽填充内容:


1
2
3
4
5
6
7
8
9
<card-layout>
  <h3 slot="header">用户信息</h3>
  <p>这里是卡片主体内容,可以放任何 HTML。</p>
  <p>支持多个段落。</p>
  <div slot="footer">
    <my-button>保存</my-button>
    <my-button>取消</my-button>
  </div>
</card-layout>

slotchange 事件监听

当插槽内容变化时,你可以监听

1
slotchange

事件来做出响应:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class DynamicList extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        :host { display: block; }
        .count { color: #888; font-size: 13px; margin-bottom: 8px; }
        ul { list-style: none; padding: 0; }
        li { padding: 8px 12px; border-bottom: 1px solid #f0f0f0; }
      </style>
      <div class="count"></div>
      <ul><slot></slot></ul>
    `;
  }

  connectedCallback() {
    const slot = this.shadowRoot.querySelector('slot');
    slot.addEventListener('slotchange', () => {
      const items = slot.assignedElements();
      const countEl = this.shadowRoot.querySelector('.count');
      countEl.textContent = `共 ${items.length} 项`;
    });
    // 初始触发
    slot.dispatchEvent(new Event('slotchange'));
  }
}

customElements.define('dynamic-list', DynamicList);

Form-Associated Custom Elements:让组件融入表单

Web Components 的一个长期痛点是自定义元素无法原生参与表单提交。ES2023 引入的

1
ElementInternals

API 彻底解决了这个问题——你的组件现在可以像

1
<input>

一样拥有 name、value、validity,甚至支持约束验证 API。

表单关联组件示意


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
class RatingInput extends HTMLElement {
  // 声明这是一个表单关联元素
  static formAssociated = true;

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

  connectedCallback() {
    this.render();
  }

  // 必须在构造后获取 internals
  formAssociatedCallback(form) {
    console.log('关联到表单:', form);
  }

  formDisabledCallback(disabled) {
    this.shadowRoot.querySelectorAll('span')
      .forEach(s => s.style.opacity = disabled ? 0.5 : 1);
  }

  formResetCallback() {
    this._value = 0;
    this._internals.setFormValue('0');
    this._internals.setValidity({});
    this.render();
  }

  // 值的 getter/setter
  get value() { return String(this._value); }
  set value(v) {
    this._value = Math.max(0, Math.min(5, Number(v)));
    this._internals?.setFormValue(String(this._value));
    this._internals?.setValidity(
      this._value === 0 ? { valueMissing: true } : {},
      this._value === 0 ? '请选择评分' : ''
    );
    this.render();
  }

  render() {
    const stars = Array.from({ length: 5 }, (_, i) => {
      const filled = i < this._value;
      return `&lt;span data-index="${i + 1}" style="
        cursor: pointer; font-size: 24px;
        color: ${filled ? '#ffb300' : '#ddd'};
        user-select: none;
      "&gt;★&lt;/span&gt;`;
    }).join('');

    this.shadowRoot.innerHTML = `
      &lt;style&gt;:host { display: inline-block; }&lt;/style&gt;
      &lt;div class="rating"&gt;${stars}&lt;/div&gt;
    `;

    this.shadowRoot.querySelectorAll('span').forEach(star => {
      star.addEventListener('click', () => {
        this.value = star.dataset.index;
        this.dispatchEvent(new Event('change', { bubbles: true }));
      });
    });
  }

  // ElementInternals 在 connectedCallback 之后可用
  connectedCallback() {
    this._internals = this.attachInternals();
    this._internals.setFormValue(String(this._value));
    this.render();
  }
}

customElements.define('rating-input', RatingInput);

现在你的评分组件可以原生参与表单:


1
2
3
4
5
6
7
8
9
10
11
12
13
&lt;form id="reviewForm"&gt;
  &lt;label&gt;评分:&lt;/label&gt;
  &lt;rating-input name="score" required&gt;&lt;/rating-input&gt;
  &lt;button type="submit"&gt;提交&lt;/button&gt;
&lt;/form&gt;

&lt;script&gt;
document.getElementById('reviewForm').addEventListener('submit', (e) =&gt; {
  e.preventDefault();
  const formData = new FormData(e.target);
  console.log('评分:', formData.get('score')); // 输出: 4
});
&lt;/script&gt;

与主流框架的互操作

Web Components 的核心价值在于框架无关性。然而,各框架对 Custom Elements 的集成方式不同,需要注意一些关键的集成细节。

React 集成

React 对 Web Components 的支持在 React 19 中得到了重大改善。但在 React 18 及更早版本中,存在一个已知问题:React 将自定义元素的属性全部作为字符串传递,而不是作为属性(property)设置。这会导致对象、函数等非字符串类型的 props 无法正确传入。

解决方案是使用

1
ref

手动设置属性:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import React, { useRef, useEffect } from 'react';

// 确保 Web Component 已注册
import './components/my-chart.js';

function Dashboard() {
  const chartRef = useRef(null);

  useEffect(() => {
    if (chartRef.current) {
      // 直接设置 property(非 attribute)
      chartRef.current.data = [
        { x: 1, y: 20 },
        { x: 2, y: 45 },
        { x: 3, y: 32 },
      ];
      chartRef.current.onPointClick = (point) =&gt; {
        console.log('点击了数据点:', point);
      };
    }
  }, []);

  return &lt;my-chart ref={chartRef}&gt;&lt;/my-chart&gt;;
}

// React 19 中可以直接传递 props
// &lt;my-chart data={data} onPointClick={handleClick} /&gt;

Vue 集成

Vue 3 对 Web Components 的支持比较完善,通过

1
v-is

或直接使用自定义元素名称即可:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
&lt;template&gt;
  &lt;card-layout&gt;
    &lt;template #header&gt;
      &lt;h3&gt;{{ title }}&lt;/h3&gt;
    &lt;/template&gt;
    &lt;p&gt;{{ content }}&lt;/p&gt;
    &lt;template #footer&gt;
      &lt;my-button @click="handleSave"&gt;保存&lt;/my-button&gt;
    &lt;/template&gt;
  &lt;/card-layout&gt;
&lt;/template&gt;

&lt;script setup&gt;
// Vue 会自动配置 Vue 忽略包含连字符的标签(视为自定义元素)
// 在 vite.config.js 中设置:
// vue({ template: { compilerOptions: { isCustomElement: tag =&gt; tag.includes('-') } } })
import './components/card-layout.js';
import './components/my-button.js';

const title = '数据分析报告';
const content = '本月活跃用户增长 15%...';

const handleSave = () =&gt; {
  console.log('保存');
};
&lt;/script&gt;

Angular 集成

Angular 默认不认识自定义元素标签,需要在模块中声明:


1
2
3
4
5
6
7
8
9
10
11
12
// app.module.ts
import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';

@NgModule({
  schemas: [CUSTOM_ELEMENTS_SCHEMA],  // 允许自定义元素标签
})
export class AppModule {}

// 组件中使用
// &lt;my-button [attr.type]="'success'" (click)="handleClick()"&gt;
//   确认操作
// &lt;/my-button&gt;

组件库工程化:从组件到可发布包

单个 Web Component 很容易编写,但构建一个企业级组件库需要解决打包、版本管理、按需加载和文档生成等工程化问题。

项目结构


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
my-components/
├── packages/
│   ├── button/
│   │   ├── src/
│   │   │   ├── MyButton.ts      # 组件实现
│   │   │   ├── my-button.css    # Shadow DOM 样式
│   │   │   └── my-button.test.ts
│   │   ├── package.json
│   │   └── index.ts             # 导出
│   ├── card/
│   ├── progress/
│   └── rating/
├── scripts/
│   └── build.ts                 # 构建脚本
├── demo/
│   └── index.html              # 演示页面
├── package.json
├── tsconfig.json
└── vite.config.ts

用 Vite 构建组件库

Vite 对 Web Components 的构建支持非常优秀,配合

1
@vitejs/plugin-vue

或纯 TypeScript 插件即可:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// vite.config.ts
import { defineConfig } from 'vite';
import { resolve } from 'path';

export default defineConfig({
  build: {
    lib: {
      entry: resolve(__dirname, 'packages/index.ts'),
      formats: ['es'],
      fileName: 'my-components',
    },
    rollupOptions: {
      // 确保外部化不需要打包的依赖
      external: [],
    },
  },
  // 开发模式:HMR 支持
  server: {
    port: 3000,
  open: '/demo/index.html',
  },
});

按需加载与注册

组件库应该支持按需加载,避免全量引入导致包体积膨胀。推荐的模式是为每个组件提供独立的注册入口:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// packages/button/index.ts
export { MyButton } from './src/MyButton.js';

// 便捷注册函数
export function registerButton() {
  if (!customElements.get('my-button')) {
    customElements.define('my-button', MyButton);
  }
}

// packages/index.ts — 全量注册
export * from './button/index.js';
export * from './card/index.js';
export * from './progress/index.js';
export * from './rating/index.js';

export function registerAll() {
  registerButton();
  registerCard();
  registerProgress();
  registerRating();
}

// 使用:
// 全量引入
import { registerAll } from 'my-components';
registerAll();

// 按需引入
import { registerButton } from 'my-components/button';
registerButton();

TypeScript 类型声明

为组件提供完整的类型声明,让使用者获得 IDE 智能提示:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// packages/button/src/my-button.d.ts
declare global {
  interface HTMLElementTagNameMap {
    'my-button': MyButton;
  }
}

export class MyButton extends HTMLElement {
  type: 'primary' | 'secondary' | 'danger';
  disabled: boolean;
  loading: boolean;
  click(): void;
}

// 让 JSX 也能识别
declare module 'react' {
  namespace JSX {
    interface IntrinsicElements {
      'my-button': React.DetailedHTMLProps&lt;
        React.HTMLAttributes&lt;HTMLElement&gt; &amp; {
          type?: 'primary' | 'secondary' | 'danger';
          disabled?: boolean;
          loading?: boolean;
        },
        HTMLElement
      &gt;;
    }
  }
}

实战:构建完整的 Toast 通知组件

让我们把前面学到的所有知识综合起来,构建一个生产级的 Toast 通知组件。它需要支持:

  • 多种类型(success / warning / error / info)
  • 自动消失与手动关闭
  • 堆叠布局与动画
  • CSS Variables 主题定制
  • 命令式 API(
    1
    Toast.show()


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
class ToastNotification extends HTMLElement {
  static observedAttributes = ['type', 'duration', 'message'];

  static stack = []; // 全局堆栈管理
  static container = null;

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._timer = null;
  }

  connectedCallback() {
    this.render();
    this.setupAutoClose();
    this.animateIn();
  }

  disconnectedCallback() {
    clearTimeout(this._timer);
  }

  attributeChangedCallback(name, oldVal, newVal) {
    if (oldVal !== newVal && this.isConnected) {
      this.render();
    }
  }

  render() {
    const type = this.getAttribute('type') || 'info';
    const message = this.getAttribute('message') || '';
    const icons = {
      success: '✓', warning: '⚠', error: '✕', info: 'ℹ'
    };

    this.shadowRoot.innerHTML = `
      &lt;style&gt;
        :host {
          display: block;
          --toast-bg: #fff;
          --toast-radius: 8px;
          --toast-shadow: 0 4px 20px rgba(0,0,0,0.12);
          --toast-padding: 14px 20px;
        }
        .toast {
          display: flex;
          align-items: center;
          gap: 10px;
          background: var(--toast-bg);
          border-radius: var(--toast-radius);
          box-shadow: var(--toast-shadow);
          padding: var(--toast-padding);
          font-size: 14px;
          position: relative;
          overflow: hidden;
          animation: slideIn 0.3s ease;
        }
        .toast::after {
          content: '';
          position: absolute;
          bottom: 0; left: 0;
          height: 3px;
          background: var(--toast-accent, #4a90d9);
          animation: countdown var(--duration, 3s) linear forwards;
        }
        .icon {
          width: 20px;
          height: 20px;
          border-radius: 50%;
          display: flex;
          align-items: center;
          justify-content: center;
          font-size: 12px;
          font-weight: bold;
          flex-shrink: 0;
        }
        .icon.success { background: #e8f5e9; color: #2e7d32; }
        .icon.warning { background: #fff3e0; color: #e65100; }
        .icon.error   { background: #ffebee; color: #c62828; }
        .icon.info    { background: #e3f2fd; color: #1565c0; }
        .message { flex: 1; color: #333; }
        .close {
          background: none; border: none;
          color: #999; cursor: pointer;
          font-size: 18px; padding: 0; line-height: 1;
        }
        .close:hover { color: #333; }
        @keyframes slideIn {
          from { transform: translateY(-20px); opacity: 0; }
          to   { transform: translateY(0); opacity: 1; }
        }
        @keyframes slideOut {
          from { transform: translateY(0); opacity: 1; }
          to   { transform: translateY(-20px); opacity: 0; }
        }
        @keyframes countdown {
          from { width: 100%; }
          to   { width: 0%; }
        }
      &lt;/style&gt;
      &lt;div class="toast" style="--toast-accent: var(--accent-${type}); --duration: ${this.duration}ms"&gt;
        &lt;span class="icon ${type}"&gt;${icons[type]}&lt;/span&gt;
        &lt;span class="message"&gt;${message}&lt;/span&gt;
        &lt;button class="close" aria-label="关闭"&gt;×&lt;/button&gt;
      &lt;/div&gt;
    `;

    this.shadowRoot.querySelector('.close')
      .addEventListener('click', () =&gt; this.dismiss());
  }

  get duration() {
    return Number(this.getAttribute('duration') || 3000);
  }

  setupAutoClose() {
    this._timer = setTimeout(() =&gt; this.dismiss(), this.duration);
  }

  animateIn() {
    const toast = this.shadowRoot.querySelector('.toast');
    toast.style.animation = 'slideIn 0.3s ease';
  }

  dismiss() {
    const toast = this.shadowRoot.querySelector('.toast');
    toast.style.animation = 'slideOut 0.3s ease forwards';
    setTimeout(() =&gt; {
      this.remove();
      const idx = ToastNotification.stack.indexOf(this);
      if (idx &gt; -1) ToastNotification.stack.splice(idx, 1);
    }, 300);
  }

  // 命令式 API
  static show(message, type = 'info', duration = 3000) {
    if (!ToastNotification.container) {
      const c = document.createElement('div');
      c.style.cssText = 'position:fixed;top:20px;right:20px;z-index:9999;display:flex;flex-direction:column;gap:8px;pointer-events:none;';
      document.body.appendChild(c);
      ToastNotification.container = c;
    }

    const toast = document.createElement('toast-notification');
    toast.setAttribute('message', message);
    toast.setAttribute('type', type);
    toast.setAttribute('duration', String(duration));
    toast.style.pointerEvents = 'auto';

    ToastNotification.container.appendChild(toast);
    ToastNotification.stack.push(toast);
    return toast;
  }

  static success(msg, dur) { return ToastNotification.show(msg, 'success', dur); }
  static warning(msg, dur) { return ToastNotification.show(msg, 'warning', dur); }
  static error(msg, dur)   { return ToastNotification.show(msg, 'error', dur); }
  static info(msg, dur)     { return ToastNotification.show(msg, 'info', dur); }
}

customElements.define('toast-notification', ToastNotification);

使用方式:


1
2
3
4
5
6
7
8
9
10
11
12
13
// 声明式
&lt;toast-notification
  message="保存成功"
  type="success"
  duration="5000"
>&lt;/toast-notification&gt;

// 命令式(推荐)
ToastNotification.success('操作成功!');
ToastNotification.error('网络连接失败,请重试');
ToastNotification.warning('磁盘空间不足', 5000);

// 3秒后自动关闭,也可手动点击 × 关闭

浏览器兼容性与 Polyfill 策略

截至 2026 年,Web Components 的核心 API 已在所有主流浏览器中获得原生支持。但如果你需要兼容旧版浏览器(如 Safari 15 之前),可以使用以下 polyfill:

API Chrome Firefox Safari Polyfill
Custom Elements v1 67+ 63+ 16.4+
1
webcomponents/custom-elements
Shadow DOM v1 67+ 63+ 16.4+
1
webcomponents/shadydom
ES Modules 61+ 60+ 11+ 无需 polyfill
Form-Associated 77+ 93+ 16.4+

1
element-internals-polyfill
CSS Parts (

1
::part()

)

73+ 72+ 16.4+ 无可靠 polyfill

推荐的 polyfill 加载策略——仅在需要时加载:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
&lt;script&gt;
// 检测原生支持
const supportsCustomElements = 'customElements' in window;
const supportsShadowDOM = !!HTMLElement.prototype.attachShadow;
const supportsInternals = !!HTMLElement.prototype.attachInternals;

if (!supportsCustomElements || !supportsShadowDOM) {
  // 动态加载 polyfill
  const script = document.createElement('script');
  script.src = 'https://unpkg.com/@webcomponents/webcomponentsjs@2.8.0/webcomponents-bundle.js';
  script.onload = () =&gt; {
    // polyfill 加载完成后再注册组件
    import('./my-components.js');
  };
  document.head.appendChild(script);
} else {
  // 原生支持,直接加载
  import('./my-components.js');
}
&lt;/script&gt;

最佳实践总结

在长期实践中,以下原则能帮助你构建出高质量的 Web Components:

  • 始终使用
    1
    customElements.define()

    前检查

    1
    customElements.get('my-tag')

    避免重复注册报错

  • 优先使用 CSS Variables 而非 ::part() — CSS Variables 的浏览器支持更广,且不需要使用者了解组件内部结构
  • 所有对外事件使用
    1
    composed: true

    — 确保自定义事件能穿透 Shadow DOM 边界被外部监听

  • constructor 中只做初始化 — DOM 操作和副作用放在
    1
    connectedCallback()

  • 断开连接时清理资源
    1
    disconnectedCallback()

    中清除定时器、AbortController、事件监听等

  • 提供声明式和命令式两种 API — HTML 属性用于模板场景,静态方法用于代码调用场景
  • 使用 ARIA 属性保证可访问性
    1
    role

    1
    aria-label

    1
    aria-disabled

  • 1
    :host

    定义 display — 默认 inline 的组件容易导致布局问题

Web Components 不是要取代 React 或 Vue,而是提供了一个跨框架的组件标准层。当你的按钮、弹窗、数据图表需要在不同项目、不同团队、不同框架间共享时,Web Components 是最可靠的选择。掌握它,你就掌握了前端组件化的底层原理和未来发展方向。

【本站文章皆为原创,未经允许不得转载】:汤不热吧 » Web Components 深度实战:用 Custom Elements 和 Shadow DOM 构建框架无关的可复用组件
分享到: 更多 (0)