在现代前端开发中,Web组件(Web Components)是浏览器原生支持的组件化方案,包括自定义元素(Custom Elements)、影子DOM(Shadow DOM)和模板元素(Template)。本文通过构建一个可复用的UI组件库,完整演示如何使用纯HTML/CSS/JavaScript创建封装良好、可组合的组件。
一、为什么需要Web组件?
传统前端框架(React/Vue)虽然组件化成熟,但依赖运行时且存在框架锁定问题。Web组件是浏览器原生标准:
- 自定义元素:定义新的HTML标签
- 影子DOM:样式和DOM隔离
- 模板元素:声明式HTML片段
- 插槽:内容分发机制
二、项目目标:构建UI组件库
我们将创建三个组件:my-card(卡片)、my-button(按钮)、my-dialog(对话框),并组合使用。要求:
- 使用自定义元素注册组件
- 使用影子DOM隔离样式
- 使用模板元素定义组件结构
- 使用插槽支持内容分发
- 展示组件通信和属性反射
三、完整代码实现
1. 模板元素定义组件结构
<!-- 卡片组件模板 -->
<template id="card-template">
<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>
</template>
<!-- 按钮组件模板 -->
<template id="button-template">
<button class="my-button">
<slot>按钮</slot>
</button>
</template>
<!-- 对话框组件模板 -->
<template id="dialog-template">
<div class="dialog-overlay">
<div class="dialog-box">
<div class="dialog-header">
<slot name="title">提示</slot>
<button class="close-btn">×</button>
</div>
<div class="dialog-body">
<slot>内容</slot>
</div>
<div class="dialog-footer">
<slot name="actions">
<my-button variant="secondary">取消</my-button>
<my-button variant="primary">确认</my-button>
</slot>
</div>
</div>
</div>
</template>
2. 自定义元素实现(JavaScript)
// ========== my-button 组件 ==========
class MyButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
const template = document.getElementById('button-template');
this.shadowRoot.appendChild(template.content.cloneNode(true));
}
static get observedAttributes() {
return ['variant', 'disabled'];
}
attributeChangedCallback(name, oldValue, newValue) {
const button = this.shadowRoot.querySelector('.my-button');
if (name === 'variant') {
button.className = `my-button ${newValue || 'primary'}`;
} else if (name === 'disabled') {
button.disabled = newValue !== null;
}
}
connectedCallback() {
// 初始化样式
const variant = this.getAttribute('variant') || 'primary';
this.shadowRoot.querySelector('.my-button').className = `my-button ${variant}`;
}
}
// ========== my-card 组件 ==========
class MyCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
const template = document.getElementById('card-template');
this.shadowRoot.appendChild(template.content.cloneNode(true));
}
static get observedAttributes() {
return ['border-color', 'shadow'];
}
attributeChangedCallback(name, oldValue, newValue) {
const card = this.shadowRoot.querySelector('.card');
if (name === 'border-color') {
card.style.borderColor = newValue;
} else if (name === 'shadow') {
card.style.boxShadow = newValue === 'true' ? '0 4px 12px rgba(0,0,0,0.1)' : 'none';
}
}
}
// ========== my-dialog 组件 ==========
class MyDialog extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
const template = document.getElementById('dialog-template');
this.shadowRoot.appendChild(template.content.cloneNode(true));
// 绑定事件
this.shadowRoot.querySelector('.close-btn').addEventListener('click', () => this.close());
this.shadowRoot.querySelector('.dialog-overlay').addEventListener('click', (e) => {
if (e.target === this.shadowRoot.querySelector('.dialog-overlay')) {
this.close();
}
});
}
static get observedAttributes() {
return ['open'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'open') {
const overlay = this.shadowRoot.querySelector('.dialog-overlay');
overlay.style.display = newValue !== null ? 'flex' : 'none';
}
}
// 公开方法
open() {
this.setAttribute('open', '');
}
close() {
this.removeAttribute('open');
this.dispatchEvent(new CustomEvent('close'));
}
connectedCallback() {
// 初始状态
const isOpen = this.hasAttribute('open');
this.shadowRoot.querySelector('.dialog-overlay').style.display = isOpen ? 'flex' : 'none';
}
}
// 注册自定义元素
customElements.define('my-button', MyButton);
customElements.define('my-card', MyCard);
customElements.define('my-dialog', MyDialog);
3. 组件样式(影子DOM内部)
由于影子DOM隔离样式,我们需要在模板内联样式或通过CSSStyleSheet注入。这里使用内联样式方式:
<style>
/* 按钮样式 */
.my-button {
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: opacity 0.2s;
}
.my-button:hover { opacity: 0.85; }
.my-button.primary { background: #4f46e5; color: white; }
.my-button.secondary { background: #e5e7eb; color: #374151; }
.my-button.danger { background: #ef4444; color: white; }
.my-button:disabled { opacity: 0.5; cursor: not-allowed; }
/* 卡片样式 */
.card {
border: 1px solid #e5e7eb;
border-radius: 12px;
overflow: hidden;
background: white;
font-family: system-ui, sans-serif;
}
.card-header { padding: 12px 16px; font-weight: 600; border-bottom: 1px solid #f3f4f6; }
.card-body { padding: 16px; color: #374151; }
.card-footer { padding: 12px 16px; border-top: 1px solid #f3f4f6; }
/* 对话框样式 */
.dialog-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dialog-box {
background: white;
border-radius: 16px;
max-width: 400px;
width: 90%;
box-shadow: 0 20px 60px rgba(0,0,0,0.2);
}
.dialog-header {
padding: 16px;
font-weight: 600;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #f3f4f6;
}
.dialog-body { padding: 16px; min-height: 80px; }
.dialog-footer { padding: 12px 16px; display: flex; gap: 8px; justify-content: flex-end; border-top: 1px solid #f3f4f6; }
.close-btn { background: none; border: none; font-size: 20px; cursor: pointer; color: #9ca3af; }
.close-btn:hover { color: #374151; }
</style>
将上述样式放入每个模板的<style>标签中,即可实现样式隔离。
4. 使用组件(HTML页面)
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web组件实战演示</title>
</head>
<body>
<h1>Web组件库演示</h1>
<my-card border-color="#4f46e5" shadow="true">
<span slot="header">📦 卡片组件</span>
<p>这是卡片主体内容,支持任意HTML内容。</p>
<my-button slot="footer" variant="primary">了解更多</my-button>
</my-card>
<br>
<my-card>
<span slot="header">🔔 通知卡片</span>
<p>没有边框颜色和阴影的默认卡片。</p>
<div slot="footer">
<my-button variant="secondary" onclick="document.querySelector('my-dialog').open()">打开对话框</my-button>
</div>
</my-card>
<my-dialog id="demo-dialog">
<span slot="title">确认操作</span>
<p>你确定要执行这个操作吗?</p>
<div slot="actions">
<my-button variant="secondary" onclick="document.getElementById('demo-dialog').close()">取消</my-button>
<my-button variant="danger" onclick="alert('已确认!'); document.getElementById('demo-dialog').close()">确认删除</my-button>
</div>
</my-dialog>
<script src="components.js"></script>
</body>
</html>
四、核心机制详解
1. 自定义元素生命周期
每个自定义元素都有生命周期回调:constructor(初始化)、connectedCallback(挂载到DOM)、attributeChangedCallback(属性变化)、disconnectedCallback(移除)。我们在attributeChangedCallback中响应属性变化,实现组件状态同步。
2. 影子DOM隔离
使用attachShadow({ mode: 'open' })创建影子DOM,组件内部的样式和DOM不会影响外部。外部样式也无法穿透进入影子DOM,除非使用::part伪元素或CSS自定义属性。
3. 模板元素复用
<template>标签中的内容不会被渲染,直到被克隆到DOM中。我们使用template.content.cloneNode(true)创建模板的副本,每个组件实例获得独立的DOM树。
4. 插槽内容分发
使用<slot>元素实现内容分发。具名插槽(name="header")允许使用者将内容插入指定位置,默认插槽则接收未指定slot的内容。这使得组件高度可定制。
五、运行与测试
将代码保存为以下文件:
index.html(主页面)components.js(JavaScript逻辑)templates.html(模板定义,或直接内联在HTML中)
使用任意HTTP服务器(如npx serve)打开index.html。你会看到:
- 两个卡片组件,样式隔离互不影响
- 按钮组件支持variant属性切换样式
- 点击“打开对话框”按钮弹出模态框
- 对话框的样式完全封装,外部无法覆盖
六、浏览器兼容性
| 浏览器 | 自定义元素 | 影子DOM | 模板元素 |
|---|---|---|---|
| Chrome 80+ | ✅ 支持 | ✅ 支持 | ✅ 支持 |
| Firefox 80+ | ✅ 支持 | ✅ 支持 | ✅ 支持 |
| Safari 13.1+ | ✅ 支持 | ✅ 支持 | ✅ 支持 |
| Edge 80+ | ✅ 支持 | ✅ 支持 | ✅ 支持 |
所有现代浏览器均已完整支持Web组件标准,可以放心在生产环境使用。
七、扩展:组件通信与事件
Web组件通过自定义事件与外部通信:
// 在组件内部派发事件
this.dispatchEvent(new CustomEvent('button-click', {
detail: { timestamp: Date.now() },
bubbles: true,
composed: true // 允许穿越影子DOM边界
}));
// 外部监听
document.querySelector('my-button').addEventListener('button-click', (e) => {
console.log('按钮点击:', e.detail);
});
八、常见陷阱与最佳实践
- 模板复用:每个组件实例必须克隆模板,不能直接使用同一个模板节点
- 样式隔离:影子DOM内样式完全隔离,外部无法覆盖。如果需要主题化,使用CSS自定义属性(
--card-bg: white)穿透影子DOM - 属性反射:对于布尔属性(如
disabled),需要在attributeChangedCallback中处理null和空字符串的区别 - 性能:避免在
attributeChangedCallback中执行重操作,可以使用requestAnimationFrame批量处理
九、总结
通过构建UI组件库,我们深入实践了HTML Web组件的核心API:
- 自定义元素:封装行为和状态
- 影子DOM:样式和DOM隔离
- 模板元素:声明式结构定义
- 插槽:灵活的内容分发
Web组件是前端开发的未来方向之一,它让开发者摆脱框架依赖,构建真正可跨框架复用的组件。掌握这些原生API,你将能编写出更健壮、更持久的前端代码。
本文为原创技术教程,代码基于HTML5和ES6标准。建议在实际项目中结合Polyfill支持旧版浏览器。

