Web Components 深度解析:Shadow DOM、Custom Elements 与浏览器原生组件化
前端工程(更新于 2026年6月1日)
Web Components:浏览器原生的组件化方案
Web Components 是 W3C 推出的浏览器原生组件化标准,由三个核心 API 组成:
| API | 作用 | 状态 |
|---|---|---|
| Custom Elements | 定义新 HTML 标签 | 稳定,全浏览器支持 |
| Shadow DOM | 封装样式与 DOM | 稳定,全浏览器支持 |
| HTML Templates | 声明可复用 DOM 片段 | 稳定,全浏览器支持 |
另有 CSS Parts(
::part())用于穿透 Shadow DOM 样式定制。
Custom Elements:定义你的 HTML 标签
生命周期回调
class MyTooltip extends HTMLElement {
static get observedAttributes() {
return ['position', 'content'];
}
constructor() {
super();
console.log('1. constructor — 初始化');
}
connectedCallback() {
console.log('2. connectedCallback — 插入 DOM');
this.render();
}
disconnectedCallback() {
console.log('3. disconnectedCallback — 移除 DOM');
this.cleanup();
}
attributeChangedCallback(name: string, oldVal: string, newVal: string) {
console.log(`4. attributeChanged: ${name} ${oldVal} → ${newVal}`);
if (oldVal !== newVal) this.update(name, newVal);
}
adoptedCallback() {
console.log('5. adoptedCallback — 移入新 document');
}
}
customElements.define('my-tooltip', MyTooltip);
注册规则
// 标签名必须包含连字符
customElements.define('my-button', MyButton); // ✅
customElements.define('button', MyButton); // ❌ 不允许
// 不能重复注册
customElements.define('my-button', MyButton); // ✅
customElements.define('my-button', MyButton2); // ❌ 已注册
// 查询是否已注册
const MyBtn = customElements.get('my-button');
const defined = customElements.whenDefined('my-button');
defined.then(() => console.log('my-button 可用了'));
Shadow DOM:样式与结构的封装
开放与封闭模式
class ShadowComponent extends HTMLElement {
constructor() {
super();
// Open:外部可通过 element.shadowRoot 访问
this.attachShadow({ mode: 'open' });
// Closed:外部无法访问 shadowRoot
// this.attachShadow({ mode: 'closed' });
}
}
| 特性 | open | closed |
|---|---|---|
element.shadowRoot |
返回 Shadow Root | 返回 null |
| 外部查询内部元素 | 可以 | 不可以 |
querySelector 穿透 |
可以 | 不可以 |
| 实际使用频率 | 高 | 极低 |
样式封装实战
class StyledCard extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host {
display: block;
border-radius: 8px;
overflow: hidden;
background: #fff;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
:host([highlighted]) {
border: 2px solid #4f46e5;
}
::slotted(h2) {
margin: 0;
padding: 16px;
color: #1f2937;
}
.content {
padding: 0 16px 16px;
}
</style>
<slot name="header"></slot>
<div class="content">
<slot></slot>
</div>
`;
}
}
customElements.define('styled-card', StyledCard);
使用:
<styled-card highlighted>
<h2 slot="header">卡片标题</h2>
<p>卡片内容,样式完全封装</p>
</styled-card>
CSS Parts:穿透 Shadow DOM 定制
// 组件内部
shadow.innerHTML = `
<style>
.title { font-size: 18px; }
</style>
<h2 class="title" part="title">
<slot></slot>
</h2>
`;
styled-card::part(title) {
font-size: 24px;
color: #e11d48;
}
HTML Templates 与 Slot
Template:声明式 DOM 模板
<template id="card-template">
<style>
.card { padding: 16px; border: 1px solid #e5e7eb; border-radius: 8px; }
</style>
<div class="card">
<slot name="title"></slot>
<slot></slot>
</div>
</template>
class TemplateCard extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
const template = document.getElementById('card-template') as HTMLTemplateElement;
shadow.appendChild(template.content.cloneNode(true));
}
}
customElements.define('template-card', TemplateCard);
Slot 分发机制
<template-card>
<!-- 具名 Slot -->
<span slot="title">标题</span>
<!-- 默认 Slot -->
<p>内容</p>
</template-card>
与框架组件对比
| 维度 | Web Components | React | Vue |
|---|---|---|---|
| 标准 | W3C 标准 | 社区生态 | 社区生态 |
| 样式封装 | Shadow DOM 天然隔离 | CSS Modules / CSS-in-JS | Scoped CSS |
| 跨框架复用 | 原生支持 | 需包装层 | 需包装层 |
| 响应式 | 需手动(attributeChangedCallback) | 自动(setState/hooks) | 自动(ref/reactive) |
| 模板 | 字符串 / Template | JSX | SFC Template |
| 生态/工具链 | 较少 | 极丰富 | 丰富 |
| SSR | 有限支持 | 完善支持 | 完善支持 |
| 开发体验 | 偏底层 | 高层抽象 | 高层抽象 |
实战:构建一个评分组件
class StarRating extends HTMLElement {
private _value = 0;
private _max = 5;
static get observedAttributes() {
return ['value', 'max'];
}
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
this.setupEvents();
}
attributeChangedCallback(name: string, _: string, newVal: string) {
if (name === 'value') this._value = Number(newVal);
if (name === 'max') this._max = Number(newVal);
if (this.shadowRoot) this.render();
}
private render() {
const stars = Array.from({ length: this._max }, (_, i) => {
const filled = i < this._value;
return `<span class="star ${filled ? 'filled' : ''}" data-index="${i}">★</span>`;
}).join('');
this.shadowRoot!.innerHTML = `
<style>
:host { display: inline-flex; gap: 4px; cursor: pointer; }
.star { font-size: 24px; color: #d1d5db; transition: color 0.15s; }
.star.filled { color: #f59e0b; }
.star:hover { color: #fbbf24; }
</style>
${stars}
`;
}
private setupEvents() {
this.shadowRoot!.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (target.classList.contains('star')) {
const index = Number(target.dataset.index) + 1;
this.setAttribute('value', String(index));
this.dispatchEvent(new CustomEvent('rate', {
detail: { value: index },
bubbles: true,
composed: true
}));
}
});
}
}
customElements.define('star-rating', StarRating);
使用:
<star-rating value="3" max="5"></star-rating>
<script>
document.querySelector('star-rating')
.addEventListener('rate', (e) => {
console.log('评分:', e.detail.value);
});
</script>
浏览器兼容性
| API | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
| Custom Elements v1 | 54+ | 63+ | 10.1+ | 79+ |
| Shadow DOM v1 | 53+ | 63+ | 10.1+ | 79+ |
| HTML Templates | 35+ | 22+ | 9+ | 79+ |
CSS ::part() |
73+ | 72+ | 13.1+ | 79+ |
adoptedCallback |
73+ | 63+ | 15.4+ | 79+ |
2026 年主流浏览器均已完整支持,无需 polyfill。
最佳实践
- 命名规范:使用
org-component前缀避免冲突(如toolsku-color-picker) - 优先 open mode:closed mode 调试困难,收益有限
- 属性反射:关键属性同步到 DOM attribute,便于 CSS 选择器
- 事件 composed:需要穿透 Shadow DOM 的事件设置
composed: true - 懒注册:使用
customElements.whenDefined管理依赖顺序
总结
Web Components 提供了框架无关的组件化原语,适合构建跨项目复用的 UI 原子组件。虽然开发体验不如 React/Vue 高层抽象,但其样式封装和原生标准的优势在微前端、设计系统等场景中不可替代。
本站提供浏览器本地工具,免注册即可试用 →
#Web Components#Shadow DOM#Custom Elements#组件化#浏览器原生