在前端生态被 React、Vue、Angular 三大框架统治的今天,很多开发者忽略了浏览器原生提供的组件化方案——Web Components。它不依赖任何框架,由 W3C 标准定义,可以在任何环境中使用,甚至可以跨框架复用。本文将深入讲解 Web Components 的核心技术:Custom Elements、Shadow DOM、HTML Templates 与 Slots,并通过实战案例带你构建一个完整可用的组件库。
一、Web Components 是什么?为什么需要它?
Web Components 是一组浏览器原生技术的统称,它让开发者能够创建可复用、封装良好的自定义元素。这套技术包含三个核心规范:
- Custom Elements:定义新的 HTML 标签及其行为逻辑
- Shadow DOM:提供 CSS 和 DOM 的样式与结构隔离
- HTML Templates & Slots:定义可复用的 HTML 片段和内容分发插槽
与 React/Vue 组件相比,Web Components 最大的优势在于框架无关性。一个编写良好的 Web Component 可以同时在 React、Vue、Angular 甚至纯 HTML 项目中使用,无需任何适配层。此外,因为它是浏览器原生 API,不存在框架运行时开销,也无需打包编译。
适用场景
Web Components 并非要取代框架,而是在特定场景下发挥独特价值:
| 场景 | 是否适合 Web Components | 说明 |
|---|---|---|
| 跨团队/跨框架组件库 | 非常适合 | 如 Design System,多团队多技术栈共用 |
| 微前端架构 | 非常适合 | 各子应用用不同框架,用 WC 做集成层 |
| 独立嵌入组件 | 非常适合 | 如第三方评论框、支付组件 |
| 复杂状态管理应用 | 不太适合 | 框架的响应式系统更适合 |
| SSR 密集型应用 | 需评估 | SSR 支持有限,需配合 Declarative Shadow DOM |
二、Custom Elements:定义你的第一个自定义元素
Custom Elements 是 Web Components 的灵魂。通过
1 | customElements.define() |
,你可以注册一个全新的 HTML 标签,并为其绑定生命周期回调。
2.1 基本写法
自定义元素的类必须继承
1 | HTMLElement |
(或其子类如
1 | HTMLButtonElement |
)。元素名称必须包含连字符(如
1 | my-button |
),以避免与原生 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
37
38
39
40
41
42
43
44
45
46
47 class MyButton extends HTMLElement {
constructor() {
super();
this._count = 0;
}
// 元素被插入 DOM 时触发
connectedCallback() {
this.render();
this.addEventListener('click', this.handleClick);
}
// 元素从 DOM 移除时触发
disconnectedCallback() {
this.removeEventListener('click', this.handleClick);
}
// 监听属性变化
static get observedAttributes() {
return ['variant', 'disabled'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this.render();
}
}
handleClick = () => {
this._count++;
this.dispatchEvent(new CustomEvent('my-click', {
detail: { count: this._count },
bubbles: true,
composed: true,
}));
};
render() {
const variant = this.getAttribute('variant') || 'primary';
const disabled = this.hasAttribute('disabled');
this.innerHTML = `<button class="btn btn-${variant}" ${disabled ? 'disabled' : ''}>
<slot></slot>
</button>`;
}
}
customElements.define('my-button', MyButton);
使用时就像普通 HTML 标签一样:
1
2 <my-button variant="primary">点击我</my-button>
<my-button variant="danger" disabled>禁用按钮</my-button>
2.2 生命周期回调详解
Custom Elements 提供了丰富的生命周期回调,与 React 组件生命周期有相似之处但语义不同:
| 回调方法 | 触发时机 | 常见用途 | ||
|---|---|---|---|---|
|
元素创建时(尚不在 DOM 中) | 初始化状态、绑定 Shadow DOM | ||
|
元素插入 DOM | 启动定时器、事件监听、数据请求 | ||
|
元素从 DOM 移除 | 清理定时器、移除监听、释放资源 | ||
|
元素被移到新 document | iframe 场景处理 | ||
|
observedAttributes 中属性变化 | 响应外部属性更新,触发重渲染 |
关键陷阱:
1 | constructor |
中不能访问子元素或读取属性(此时可能还没设置),DOM 操作应放到
1 | connectedCallback |
中。
三、Shadow DOM:真正的样式与 DOM 隔离
Shadow DOM 是 Web Components 实现封装的核心机制。它将一棵独立的 DOM 子树挂载到宿主元素上,这棵子树与外部文档的 CSS 和 JavaScript 完全隔离。

3.1 创建 Shadow DOM
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 class MyCard extends HTMLElement {
constructor() {
super();
// open: 外部可通过 element.shadowRoot 访问
// closed: 外部无法访问(element.shadowRoot 为 null)
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host {
display: block;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 16px;
margin: 8px 0;
font-family: sans-serif;
}
.title {
font-size: 18px;
font-weight: 600;
color: #1a1a1a;
margin: 0 0 8px;
}
.body {
color: #555;
line-height: 1.6;
}
</style>
<div class="card">
<div class="title"><slot name="title">默认标题</slot></div>
<div class="body"><slot>默认内容</slot></div>
</div>
`;
}
}
customElements.define('my-card', MyCard);
使用方式:
1
2
3
4 <my-card>
<span slot="title">Shadow DOM 实战</span>
<p>这段内容会被分发到默认插槽中。</p>
</my-card>
3.2 样式隔离与穿透
Shadow DOM 内的样式不会泄漏到外部,外部样式也不会影响 Shadow DOM 内部(除了可继承属性如
1 | color |
、
1 | font-family |
)。这带来了几个关键技巧:
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 /* :host 选择器指向宿主元素本身 */
:host {
display: block;
}
/* :host() 可以根据宿主属性匹配 */
:host([theme="dark"]) {
background: #1a1a1a;
color: #fff;
}
/* :host-context() 根据外部祖先元素匹配 */
:host-context(.dark-mode) {
background: #222;
}
/* ::slotted() 选择被分发到 slot 的元素 */
::slotted(img) {
max-width: 100%;
border-radius: 4px;
}
/* CSS 变量可以从外部穿透 Shadow DOM */
:host {
--card-bg: #ffffff;
background: var(--card-bg);
}
/* 外部可以覆盖:my-card { --card-bg: #f5f5f5; } */
3.3 open vs closed 模式
1 | attachShadow({ mode: 'open' }) |
允许通过
1 | element.shadowRoot |
访问内部 DOM,方便调试和外部交互。
1 | closed |
模式则完全封闭。生产环境建议用
1 | open |
,因为
1 | closed |
并不能提供真正的安全保障,反而给调试带来困难。
四、Templates 与 Slots:内容分发机制
1 | <template> |
标签定义了一段不会被渲染的 HTML 片段,可以通过 JavaScript 克隆并插入文档。
1 | <slot> |
则实现了内容分发,让组件使用者可以将自定义内容投射到组件内部的指定位置。
4.1 使用 template 优化性能
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21 <template id="row-template">
<tr>
<td class="name"></td>
<td class="age"></td>
<td class="action">
<button class="delete">删除</button>
</td>
</tr>
</template>
<script>
class DataTable extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: 'open' });
const tpl = document.getElementById('row-template');
// 克隆 template 内容,比 innerHTML 字符串解析更快
shadow.appendChild(tpl.content.cloneNode(true));
}
}
</script>
1 | template.content.cloneNode(true) |
比字符串拼接后
1 | innerHTML |
赋值更高效,因为浏览器在解析
1 | <template> |
时已经构建好了 DOM 树,克隆时无需重新解析 HTML。
4.2 具名插槽与默认插槽
一个组件可以包含多个插槽,通过
1 | name |
属性区分:
1
2
3
4
5
6
7
8
9
10
11
12
13 <!-- 组件内部 -->
<article class="layout">
<header><slot name="header"></slot></header>
<main><slot></slot></main> <!-- 默认插槽 -->
<footer><slot name="footer">默认页脚</slot></footer>
</article>
<!-- 使用方式 -->
<my-layout>
<h1 slot="header">页面标题</h1>
<p>主体内容会被放入默认插槽。</p>
<p>多段内容都会进入默认插槽。</p>
</my-layout>
五、实战:构建一个可复用的 Modal 对话框组件
下面我们将所学知识整合,构建一个生产级的 Modal 组件。它包含:属性驱动的显示/隐藏、事件回调、动画、键盘交互、Shadow DOM 样式隔离。
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 class MyModal extends HTMLElement {
constructor() {
super();
this._shadow = this.attachShadow({ mode: 'open' });
this._shadow.innerHTML = `
<style>
:host {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
display: none;
z-index: 9999;
}
:host([open]) {
display: flex;
align-items: center;
justify-content: center;
}
.overlay {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5);
opacity: 0;
transition: opacity 0.3s;
}
:host([open]) .overlay {
opacity: 1;
}
.dialog {
position: relative;
background: var(--modal-bg, #fff);
border-radius: 12px;
padding: 24px;
min-width: 320px;
max-width: 90vw;
max-height: 85vh;
overflow: auto;
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
transform: scale(0.9);
transition: transform 0.3s;
}
:host([open]) .dialog {
transform: scale(1);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.close-btn {
border: none;
background: none;
font-size: 20px;
cursor: pointer;
color: #999;
padding: 4px 8px;
}
.close-btn:hover { color: #333; }
</style>
<div class="overlay"></div>
<div class="dialog">
<div class="header">
<slot name="title"><span>对话框</span></slot>
<button class="close-btn">&times;</button>
</div>
<slot></slot>
</div>
`;
}
connectedCallback() {
this._shadow.querySelector('.overlay')
.addEventListener('click', () => this.close());
this._shadow.querySelector('.close-btn')
.addEventListener('click', () => this.close());
document.addEventListener('keydown', this._onKeyDown);
}
disconnectedCallback() {
document.removeEventListener('keydown', this._onKeyDown);
}
_onKeyDown = (e) => {
if (e.key === 'Escape' && this.hasAttribute('open')) {
this.close();
}
};
open() {
this.setAttribute('open', '');
this.dispatchEvent(new CustomEvent('my-modal-open', {
bubbles: true, composed: true
}));
}
close() {
this.removeAttribute('open');
this.dispatchEvent(new CustomEvent('my-modal-close', {
bubbles: true, composed: true
}));
}
}
customElements.define('my-modal', MyModal);
使用方式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 <my-modal id="confirm-modal">
<span slot="title">确认操作</span>
<p>你确定要执行此操作吗?</p>
<button onclick="document.getElementById('confirm-modal').close()">
取消
</button>
</my-modal>
<button onclick="document.getElementById('confirm-modal').open()">
打开对话框
</button>
<script>
document.addEventListener('my-modal-close', (e) => {
console.log('Modal 已关闭', e.target);
});
</script>
六、进阶技巧与最佳实践
6.1 响应式属性与 Proxy
Web Components 没有内置响应式系统,但可以借助 ES6 Proxy 或 getter/setter 实现:
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 class ReactiveElement extends HTMLElement {
#state = {};
set state(value) {
this.#state = new Proxy(value, {
set: (target, key, val) => {
target[key] = val;
this.update();
return true;
}
});
this.update();
}
get state() {
return this.#state;
}
update() {
// 触发重新渲染
this.render();
}
render() { /* ... */ }
}
6.2 在 React/Vue 中使用 Web Components
在 React 中使用 Web Components 时,注意 React 对自定义元素的属性传递需要特殊处理。React 19 已原生支持 Web Components,旧版 React 需要用 ref 手动设置属性:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21 // React 19+ 可以直接使用
function App() {
return (
<my-modal open={true} onMyModalClose={() => console.log('closed')}>
<span slot="title">React 集成</span>
<p>来自 React 的内容</p>
</my-modal>
);
}
// React 18 及以下需要 ref
function OldReactApp() {
const modalRef = useRef();
useEffect(() => {
const modal = modalRef.current;
modal.addEventListener('my-modal-close', handleClose);
return () => modal.removeEventListener('my-modal-close', handleClose);
}, []);
return <my-modal ref={modalRef}>...</my-modal>;
}
在 Vue 中则更加自然,Vue 原生支持自定义元素:
1
2
3
4
5
6 <template>
<my-modal :open="visible" @my-modal-close="visible = false">
<span slot="title">Vue 集成</span>
<p>来自 Vue 的内容</p>
</my-modal>
</template>
6.3 使用 Lit 简化开发
原生 Web Components 的模板代码比较冗长。Google 开源的
1 | Lit |
库提供了响应式属性声明和模板字面量语法,大幅简化开发:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21 import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
@customElement('lit-counter')
class LitCounter extends LitElement {
static styles = css`
:host { display: block; padding: 20px; }
button { font-size: 16px; padding: 8px 16px; }
`;
@property({ type: Number })
count = 0;
render() {
return html`
<button @click=${() => this.count++}>
点击次数:${this.count}
</button>
`;
}
}
Lit 的核心优势:声明式响应式属性、模板自动更新、CSS 模板隔离、约 5KB 的运行时体积。对于构建大型组件库,强烈推荐使用 Lit。
七、浏览器兼容性与 polyfill
截至 2025 年,Web Components 的核心 API 在所有主流浏览器中均已原生支持:
| 特性 | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
| Custom Elements v1 | 67+ | 63+ | 10.1+ | 79+ |
| Shadow DOM v1 | 53+ | 63+ | 10+ | 79+ |
| HTML Templates | 26+ | 22+ | 8+ | 79+ |
| Declarative Shadow DOM | 112+ | 123+ | 17+ | 112+ |
对于需要支持旧浏览器的项目,可以使用
1 | @webcomponents/webcomponentsjs |
polyfill,但会增加约 60KB 的体积。现代项目基本无需 polyfill。
八、总结
Web Components 提供了一套浏览器原生的组件化方案,其框架无关性使其在跨技术栈场景中具有不可替代的价值。核心要点回顾:
- Custom Elements 定义自定义标签和生命周期,元素名必须含连字符
- Shadow DOM 实现 CSS 和 DOM 隔离,
1:host
、
1::slotted()、CSS 变量是关键穿透技巧
- Templates & Slots 提供高效的 HTML 复用和内容分发机制
- 事件通信 使用
1CustomEvent
+
1bubbles: true, composed: true穿透 Shadow DOM
- Lit 库可大幅简化开发,适合构建大型组件库
Web Components 不是要取代 React 或 Vue,而是为特定场景提供补充方案。在设计系统、微前端、第三方嵌入组件等领域,它往往是比框架组件更好的选择。掌握 Web Components,你就拥有了一套跨越所有框架的组件化能力,这在技术栈频繁更迭的前端世界,是一笔宝贵的资产。
汤不热吧