跳到主要内容

Web Components

什么是 Web Components?

Web Components 是浏览器原生支持的一套组件化技术方案,它允许开发者创建可复用、封装良好的自定义 HTML 元素。你可以把它理解为:不依赖任何框架,用浏览器自带的能力造出自己的标签

例如,你可以创建一个 <user-card> 标签,它像 <div> 一样可以直接在 HTML 中使用,但拥有自定义的外观和行为。

Web Components 由三大核心技术组成:

为什么需要 Web Components?

在没有 Web Components 之前,我们要实现组件化,必须依赖 React、Vue 等框架。Web Components 提供了浏览器层面的原生方案:

特性框架组件(React/Vue)Web Components
依赖需要框架运行时浏览器原生支持
跨框架复用不行,绑定特定框架可以,任何环境都能用
样式隔离需要 CSS Modules 等方案Shadow DOM 天然隔离
标准化各框架各有标准W3C 标准规范
学习曲线需学习框架语法基于 HTML/JS 原生 API
生态工具生态丰富生态相对较小
什么时候适合用 Web Components?
  • 需要跨框架复用的 UI 组件(如设计系统、组件库)
  • 微前端架构中的独立模块
  • 需要严格样式隔离的第三方嵌入组件
  • 构建不依赖框架的通用组件

一、Custom Elements(自定义元素)

Custom Elements 是 Web Components 的核心,它允许你定义全新的 HTML 标签。

1.1 定义自定义元素

自定义元素的类必须继承 HTMLElement,并通过 customElements.define() 注册:

// 定义自定义元素
class MyButton extends HTMLElement {
constructor() {
super(); // 必须调用 super()
this.innerHTML = `<button>点击我</button>`;
}
}

// 注册自定义元素,标签名必须包含连字符 `-`
customElements.define('my-button', MyButton);
<!-- 使用自定义元素 -->
<my-button></my-button>
命名规则

自定义元素的标签名必须包含连字符-),例如 my-buttonuser-card。这是为了与原生 HTML 标签区分,避免命名冲突。以下命名都是无效的:

// ❌ 错误:没有连字符
customElements.define('mybutton', MyButton);

// ❌ 错误:不能以连字符开头
customElements.define('-my-button', MyButton);

// ✅ 正确:包含连字符
customElements.define('my-button', MyButton);
customElements.define('app-header', AppHeader);

1.2 自定义元素的两种类型

自主元素(Autonomous Custom Elements):

// 创建全新的标签
class MyButton extends HTMLElement {
constructor() {
super();
this.innerHTML = '<button>自定义按钮</button>';
}
}
customElements.define('my-button', MyButton);
<my-button></my-button>

扩展内置元素(Customized Built-in Elements):

// 扩展已有的 <button> 标签
class FancyButton extends HTMLButtonElement {
constructor() {
super();
this.style.background = 'linear-gradient(45deg, #ff6b6b, #feca57)';
this.style.color = '#fff';
this.style.border = 'none';
this.style.padding = '10px 20px';
this.style.borderRadius = '8px';
}
}
customElements.define('fancy-button', FancyButton, { extends: 'button' });
<!-- 使用 is 属性指定扩展类型 -->
<button is="fancy-button">漂亮按钮</button>
Safari 不支持扩展内置元素

截至目前,Safari 浏览器不支持 Customized Built-in Elements(is 属性方式)。如果需要兼容所有主流浏览器,建议优先使用自主元素。

1.3 生命周期回调

自定义元素拥有一套完整的生命周期系统,类似于 React/Vue 的组件生命周期:

生命周期回调触发时机用途
constructor()元素实例被创建时初始化状态、设置 Shadow DOM
connectedCallback()元素插入到 DOM 时获取数据、添加事件监听、启动定时器
disconnectedCallback()元素从 DOM 移除时清理事件监听、取消定时器、释放资源
attributeChangedCallback()被观察的属性变化时响应属性更新、更新 UI
adoptedCallback()元素被移到新文档时较少使用,用于 iframe 场景

完整示例:

class UserCard extends HTMLElement {
// 声明需要观察的属性
static get observedAttributes() {
return ['name', 'avatar', 'role'];
}

constructor() {
super();
console.log('1. constructor:元素被创建');
// 只做基本的初始化,不要访问属性或子元素
this._data = {};
}

connectedCallback() {
console.log('2. connectedCallback:元素插入 DOM');
// 此时可以安全地访问属性和设置 DOM
this.render();
}

disconnectedCallback() {
console.log('3. disconnectedCallback:元素从 DOM 移除');
// 清理工作
this.cleanup();
}

attributeChangedCallback(name, oldValue, newValue) {
console.log(`4. attributeChangedCallback:属性 ${name}${oldValue} 变为 ${newValue}`);
// 属性变化时重新渲染
if (oldValue !== newValue) {
this._data[name] = newValue;
this.render();
}
}

render() {
const name = this.getAttribute('name') || '未知用户';
const role = this.getAttribute('role') || '成员';
this.innerHTML = `
<div class="user-card">
<h3>${name}</h3>
<span>${role}</span>
</div>
`;
}

cleanup() {
// 清理定时器、事件监听等
}
}

customElements.define('user-card', UserCard);
<user-card name="张三" role="前端工程师"></user-card>
constructor 中的注意事项

constructor() 中:

  • 必须首先调用 super()
  • 不要读取属性(此时可能还没设置)
  • 不要添加子元素(此时可能还没插入 DOM)
  • 推荐只做状态初始化和 Shadow DOM 绑定

DOM 操作和属性读取应放在 connectedCallback() 中。


二、Shadow DOM(影子 DOM)

Shadow DOM 是 Web Components 实现样式和 DOM 隔离的关键技术。它可以将一棵 DOM 树"隐藏"在元素内部,外部的 CSS 和 JavaScript 无法直接访问。

2.1 理解 Shadow DOM

你可以把 Shadow DOM 想象成一个"隔离舱":

  • Shadow Host:承载 Shadow DOM 的普通 DOM 元素
  • Shadow Root:Shadow DOM 树的根节点
  • Shadow Tree:Shadow Root 内部的 DOM 结构

2.2 创建 Shadow DOM

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

// 创建 Shadow DOM
// mode: 'open' 表示外部可以通过 element.shadowRoot 访问
// mode: 'closed' 表示外部无法访问
const shadow = this.attachShadow({ mode: 'open' });

// 在 Shadow DOM 中添加内容和样式
shadow.innerHTML = `
<style>
/* 这里的样式只在 Shadow DOM 内部生效 */
.card {
border: 2px solid #e0e0e0;
border-radius: 12px;
padding: 20px;
max-width: 300px;
font-family: sans-serif;
}
h2 {
color: #333;
margin: 0 0 8px 0;
}
p {
color: #666;
margin: 0;
}
</style>
<div class="card">
<h2>卡片标题</h2>
<p>卡片描述内容</p>
</div>
`;
}
}

customElements.define('my-card', MyCard);

2.3 open 和 closed 模式

模式element.shadowRoot外部 JS 访问使用场景
open返回 Shadow Root可以访问大多数场景,方便调试
closed返回 null无法访问需要强隔离的场景
// open 模式
const shadow = this.attachShadow({ mode: 'open' });

// 外部可以访问
const el = document.querySelector('my-card');
console.log(el.shadowRoot); // ShadowRoot 对象 ✅

// closed 模式
const shadow = this.attachShadow({ mode: 'closed' });

// 外部无法访问
const el = document.querySelector('my-card');
console.log(el.shadowRoot); // null ❌
推荐使用 open 模式

实际开发中推荐使用 open 模式。closed 模式并不能提供真正的安全性(有方法绕过),反而会给调试带来困难。

2.4 样式隔离演示

Shadow DOM 的样式隔离是双向的:

class StyleDemo extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
/* ✅ 只影响 Shadow DOM 内部的 p 标签 */
p { color: red; font-weight: bold; }

/* :host 选择器用于设置宿主元素自身的样式 */
:host {
display: block;
margin: 10px 0;
border: 1px dashed blue;
padding: 10px;
}

/* :host() 可以根据宿主的类名或属性匹配 */
:host(.highlight) {
background: #fffae6;
}
</style>
<p>我是 Shadow DOM 内部的文字(红色)</p>
`;
}
}
customElements.define('style-demo', StyleDemo);
<!-- 外部样式不会影响 Shadow DOM 内部 -->
<style>
p { color: green; font-size: 24px; }
</style>

<p>我是外部的 p 标签(绿色、24px)</p>

<!-- Shadow DOM 内部的 p 不受外部影响 -->
<style-demo></style-demo>
<style-demo class="highlight"></style-demo>

<p>我也是外部的 p 标签(绿色、24px)</p>

2.5 Shadow DOM 中的特殊 CSS 选择器

选择器作用示例
:host选中 Shadow Host(宿主元素):host { display: block; }
:host()条件匹配宿主:host(.active) { opacity: 1; }
:host-context()根据宿主的祖先匹配:host-context(.dark) { color: #fff; }
::slotted()选中被分发到 slot 的内容::slotted(p) { color: blue; }
::part()从外部选中 Shadow DOM 内部的 partmy-card::part(title) { color: red; }
class ThemeCard extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host {
display: block;
padding: 16px;
border-radius: 8px;
background: #fff;
}
/* 当宿主位于 .dark-theme 容器内时 */
:host-context(.dark-theme) {
background: #1a1a1a;
color: #fff;
}
/* 使用 part 暴露内部样式钩子 */
h3 { margin: 0 0 8px; }
</style>
<h3 part="title"><slot name="title">默认标题</slot></h3>
<p part="content"><slot>默认内容</slot></p>
`;
}
}
customElements.define('theme-card', ThemeCard);
/* 外部可以通过 ::part() 控制 Shadow DOM 内部元素 */
theme-card::part(title) {
font-size: 20px;
color: #1890ff;
}

theme-card::part(content) {
line-height: 1.6;
}

三、HTML Templates 与 Slots(模板与插槽)

3.1 <template> 标签

<template> 标签定义了一段不会被渲染的 HTML 片段,可以在 JavaScript 中克隆使用:

<!-- 模板不会被显示在页面上 -->
<template id="card-template">
<style>
.card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
margin: 8px;
}
.card-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 8px;
}
.card-body {
color: #666;
}
</style>
<div class="card">
<div class="card-title"></div>
<div class="card-body"></div>
</div>
</template>

<script>
class TemplateCard extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });

// 获取模板内容并克隆
const template = document.getElementById('card-template');
const content = template.content.cloneNode(true);

// 填充数据
content.querySelector('.card-title').textContent = this.getAttribute('title') || '标题';
content.querySelector('.card-body').textContent = this.getAttribute('body') || '内容';

shadow.appendChild(content);
}
}
customElements.define('template-card', TemplateCard);
</script>

<template-card title="学习笔记" body="今天学习了 Web Components"></template-card>
<template-card title="项目总结" body="使用 Shadow DOM 实现样式隔离"></template-card>
template 的优势
  • 不会被渲染<template> 内的内容不会出现在页面上,也不会执行内部的脚本和加载图片
  • 可复用:通过 cloneNode(true) 可以多次克隆使用
  • 性能好:浏览器只解析一次模板,克隆操作非常高效

3.2 <slot> 插槽

Slot 允许使用者从外部向组件内部插入内容,类似于 Vue 中的插槽或 React 中的 children

默认插槽

class SimpleCard extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
.wrapper {
border: 2px solid #1890ff;
border-radius: 8px;
padding: 16px;
}
</style>
<div class="wrapper">
<!-- 默认插槽:接收没有指定 slot 属性的子内容 -->
<slot>这是默认内容,没有传入子内容时显示</slot>
</div>
`;
}
}
customElements.define('simple-card', SimpleCard);
<!-- 传入内容 -->
<simple-card>
<p>这段文字会替代默认内容显示</p>
</simple-card>

<!-- 不传入内容,显示默认 -->
<simple-card></simple-card>

具名插槽

class MyDialog extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
.dialog {
border: 1px solid #ddd;
border-radius: 12px;
overflow: hidden;
max-width: 400px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.dialog-header {
background: #1890ff;
color: #fff;
padding: 12px 16px;
font-weight: bold;
}
.dialog-body {
padding: 16px;
}
.dialog-footer {
padding: 12px 16px;
border-top: 1px solid #eee;
text-align: right;
}
</style>
<div class="dialog">
<div class="dialog-header">
<slot name="title">默认标题</slot>
</div>
<div class="dialog-body">
<slot>默认内容</slot>
</div>
<div class="dialog-footer">
<slot name="footer">
<button>关闭</button>
</slot>
</div>
</div>
`;
}
}
customElements.define('my-dialog', MyDialog);
<my-dialog>
<span slot="title">确认删除</span>
<p>你确定要删除这条记录吗?此操作不可撤销。</p>
<div slot="footer">
<button onclick="alert('取消')">取消</button>
<button onclick="alert('确认')">确认删除</button>
</div>
</my-dialog>

3.3 监听 slot 变化

当插槽内容变化时,可以通过 slotchange 事件监听:

class SlotWatcher extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<div>
<slot></slot>
</div>
`;

// 监听插槽内容变化
shadow.querySelector('slot').addEventListener('slotchange', (e) => {
const assignedNodes = e.target.assignedNodes();
console.log('插槽内容变化,当前节点数:', assignedNodes.length);
console.log('分配的节点:', assignedNodes);
});
}
}
customElements.define('slot-watcher', SlotWatcher);

四、实战:构建一个完整的组件

下面我们结合三大核心技术,构建一个功能完整的 <count-down> 倒计时组件:

class CountDown extends HTMLElement {
static get observedAttributes() {
return ['seconds', 'auto-start'];
}

constructor() {
super();
this._remaining = 0;
this._timer = null;
this._shadow = this.attachShadow({ mode: 'open' });
this._shadow.innerHTML = `
<style>
:host {
display: inline-flex;
align-items: center;
gap: 8px;
font-family: 'Courier New', monospace;
}
.display {
font-size: 2em;
font-weight: bold;
min-width: 80px;
text-align: center;
padding: 8px 16px;
background: #1a1a2e;
color: #00ff88;
border-radius: 8px;
border: 2px solid #333;
}
.display.warning {
color: #ff6b6b;
border-color: #ff6b6b;
}
button {
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: opacity 0.2s;
}
button:hover { opacity: 0.85; }
.start { background: #00c853; color: #fff; }
.pause { background: #ff9800; color: #fff; }
.reset { background: #9e9e9e; color: #fff; }
</style>
<div class="display">
<slot name="prefix"></slot>
<span class="time">00</span>
<slot name="suffix"></slot>
</div>
<button class="start">开始</button>
<button class="pause">暂停</button>
<button class="reset">重置</button>
`;
}

connectedCallback() {
this._remaining = parseInt(this.getAttribute('seconds')) || 60;
this._updateDisplay();

// 绑定按钮事件
this._shadow.querySelector('.start').addEventListener('click', () => this.start());
this._shadow.querySelector('.pause').addEventListener('click', () => this.pause());
this._shadow.querySelector('.reset').addEventListener('click', () => this.reset());

// 支持 auto-start 属性
if (this.hasAttribute('auto-start')) {
this.start();
}
}

disconnectedCallback() {
// 清理定时器,防止内存泄漏
this.pause();
}

attributeChangedCallback(name, oldValue, newValue) {
if (name === 'seconds' && oldValue !== newValue) {
this._remaining = parseInt(newValue) || 60;
this._updateDisplay();
}
}

start() {
if (this._timer) return; // 避免重复启动
this._timer = setInterval(() => {
this._remaining--;
this._updateDisplay();

if (this._remaining <= 0) {
this.pause();
// 触发自定义事件
this.dispatchEvent(new CustomEvent('complete', {
bubbles: true,
composed: true // 允许事件穿越 Shadow DOM 边界
}));
}
}, 1000);
}

pause() {
clearInterval(this._timer);
this._timer = null;
}

reset() {
this.pause();
this._remaining = parseInt(this.getAttribute('seconds')) || 60;
this._updateDisplay();
}

_updateDisplay() {
const display = this._shadow.querySelector('.display');
const timeEl = this._shadow.querySelector('.time');

const mins = Math.floor(this._remaining / 60);
const secs = this._remaining % 60;
timeEl.textContent = mins > 0
? `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
: String(secs).padStart(2, '0');

// 低于 10 秒时显示警告样式
display.classList.toggle('warning', this._remaining <= 10);
}
}

customElements.define('count-down', CountDown);
<!-- 基本用法 -->
<count-down seconds="30"></count-down>

<!-- 自动开始 -->
<count-down seconds="120" auto-start></count-down>

<!-- 使用插槽自定义前后缀 -->
<count-down seconds="60">
<span slot="prefix"></span>
<span slot="suffix"></span>
</count-down>

<!-- 监听完成事件 -->
<script>
document.querySelector('count-down').addEventListener('complete', () => {
alert('倒计时结束!');
});
</script>

五、事件与通信

5.1 自定义事件

Web Components 使用 CustomEvent 与外部通信:

class SearchBox extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host { display: flex; gap: 8px; }
input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 6px;
flex: 1;
}
button {
padding: 8px 16px;
background: #1890ff;
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
}
</style>
<input type="text" placeholder="请输入搜索关键词" />
<button>搜索</button>
`;

const input = shadow.querySelector('input');
const btn = shadow.querySelector('button');

btn.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('search', {
detail: { keyword: input.value },
bubbles: true, // 事件冒泡
composed: true // 穿越 Shadow DOM 边界
}));
});

input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') btn.click();
});
}
}
customElements.define('search-box', SearchBox);
<search-box></search-box>
<script>
document.querySelector('search-box').addEventListener('search', (e) => {
console.log('搜索关键词:', e.detail.keyword);
});
</script>
composed 属性很重要

在 Shadow DOM 中触发的事件默认不会穿越 Shadow DOM 边界。必须设置 composed: true 才能让外部监听到。

5.2 属性(Attribute)与特性(Property)

Web Components 有两种传递数据的方式:

class DataList extends HTMLElement {
static get observedAttributes() {
return ['title'];
}

constructor() {
super();
this._items = []; // Property:可以传复杂数据
this.attachShadow({ mode: 'open' });
}

// 将 Attribute 与 Property 同步
get title() {
return this.getAttribute('title');
}

set title(val) {
this.setAttribute('title', val);
}

// Property:用于传递复杂数据
get items() {
return this._items;
}

set items(val) {
this._items = val;
this.render();
}

attributeChangedCallback() {
this.render();
}

connectedCallback() {
this.render();
}

render() {
this.shadowRoot.innerHTML = `
<style>
h3 { color: #333; }
ul { list-style: none; padding: 0; }
li { padding: 8px; border-bottom: 1px solid #eee; }
</style>
<h3>${this.title || '列表'}</h3>
<ul>
${this._items.map(item => `<li>${item}</li>`).join('')}
</ul>
`;
}
}
customElements.define('data-list', DataList);
<!-- Attribute 方式(字符串) -->
<data-list title="水果列表"></data-list>

<script>
const list = document.querySelector('data-list');
// Property 方式(数组对象)
list.items = ['苹果', '香蕉', '橘子', '葡萄'];
</script>

六、进阶技巧

6.1 使用 CSS 自定义属性穿透样式

虽然 Shadow DOM 隔离了样式,但 CSS 自定义属性(变量)可以穿透 Shadow DOM:

class ThemeButton extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
button {
/* 使用 CSS 变量,允许外部自定义 */
background: var(--btn-bg, #1890ff);
color: var(--btn-color, #fff);
font-size: var(--btn-font-size, 14px);
padding: var(--btn-padding, 8px 20px);
border: none;
border-radius: var(--btn-radius, 6px);
cursor: pointer;
transition: all 0.3s;
}
button:hover {
opacity: 0.85;
}
</style>
<button><slot>按钮</slot></button>
`;
}
}
customElements.define('theme-button', ThemeButton);
/* 外部通过 CSS 变量控制组件样式 */
theme-button {
--btn-bg: #ff4757;
--btn-color: #fff;
--btn-font-size: 16px;
--btn-padding: 12px 24px;
--btn-radius: 20px;
}

/* 暗色主题 */
.dark theme-button {
--btn-bg: #2d3436;
--btn-color: #dfe6e9;
}

6.2 Declarative Shadow DOM(声明式 Shadow DOM)

传统的 Shadow DOM 需要 JavaScript 才能创建。声明式 Shadow DOM 允许在 HTML 中直接定义:

<my-card>
<template shadowrootmode="open">
<style>
.card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
}
</style>
<div class="card">
<slot></slot>
</div>
</template>
<p>这段内容会被放入插槽中</p>
</my-card>
声明式 Shadow DOM 的优势
  • SSR 友好:不需要 JavaScript 就能渲染 Shadow DOM
  • 性能更好:浏览器解析 HTML 时直接创建 Shadow DOM,不需要等待 JS 加载
  • 渐进增强:即使 JavaScript 加载失败,组件结构也能正确显示

6.3 Form-Associated Custom Elements(表单关联自定义元素)

自定义元素可以参与到原生表单中:

class StarRating extends HTMLElement {
static formAssociated = true;

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

constructor() {
super();
this._internals = this.attachInternals();
this._value = 0;
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host { display: inline-flex; gap: 4px; cursor: pointer; }
.star {
font-size: 24px;
color: #ddd;
transition: color 0.2s;
user-select: none;
}
.star.active { color: #ffc107; }
.star:hover { transform: scale(1.2); }
</style>
${[1,2,3,4,5].map(i =>
`<span class="star" data-value="${i}">★</span>`
).join('')}
`;

shadow.addEventListener('click', (e) => {
const star = e.target.closest('.star');
if (!star) return;
this.value = parseInt(star.dataset.value);
});
}

get value() {
return this._value;
}

set value(val) {
this._value = val;
// 将值同步给表单
this._internals.setFormValue(String(val));
this._updateStars();
}

_updateStars() {
const stars = this.shadowRoot.querySelectorAll('.star');
stars.forEach((star, i) => {
star.classList.toggle('active', i < this._value);
});
}

// 表单重置时调用
formResetCallback() {
this.value = 0;
}
}
customElements.define('star-rating', StarRating);
<form id="review-form">
<label>评分:</label>
<star-rating name="rating"></star-rating>
<button type="submit">提交</button>
<button type="reset">重置</button>
</form>

<script>
document.getElementById('review-form').addEventListener('submit', (e) => {
e.preventDefault();
const formData = new FormData(e.target);
console.log('评分:', formData.get('rating')); // 输出选择的星数
});
</script>

6.4 使用 adoptedStyleSheets 共享样式

adoptedStyleSheets 允许多个 Shadow DOM 共享同一份样式表,提高性能:

// 创建共享样式表
const sharedStyles = new CSSStyleSheet();
sharedStyles.replaceSync(`
:host {
display: block;
font-family: -apple-system, sans-serif;
}
.title { font-size: 18px; color: #333; margin-bottom: 8px; }
.content { color: #666; line-height: 1.6; }
`);

class CardA extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
// 复用共享样式
shadow.adoptedStyleSheets = [sharedStyles];
shadow.innerHTML = `<div class="title">卡片 A</div><div class="content"><slot></slot></div>`;
}
}

class CardB extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
// 复用同一份样式 + 追加私有样式
const privateStyles = new CSSStyleSheet();
privateStyles.replaceSync(`.title { color: #1890ff; }`);
shadow.adoptedStyleSheets = [sharedStyles, privateStyles];
shadow.innerHTML = `<div class="title">卡片 B</div><div class="content"><slot></slot></div>`;
}
}

customElements.define('card-a', CardA);
customElements.define('card-b', CardB);

七、Web Components 与框架的协作

7.1 在 React 中使用 Web Components

function App() {
const handleSearch = (e) => {
console.log('搜索关键词:', e.detail.keyword);
};

useEffect(() => {
const el = document.querySelector('search-box');
el.addEventListener('search', handleSearch);
return () => el.removeEventListener('search', handleSearch);
}, []);

return (
<div>
<h1>React + Web Components</h1>
<search-box></search-box>
</div>
);
}
React 中的注意事项

React(18 及以下版本)对 Web Components 的支持有限:

  • React 将所有 props 作为 HTML attributes(字符串)传递,无法直接传递对象/数组
  • 自定义事件需要手动通过 addEventListener 监听,不能用 onXxx 语法
  • React 19 已改善了对 Web Components 的支持,可以通过 props 传递复杂数据

7.2 在 Vue 中使用 Web Components

<template>
<div>
<h1>Vue + Web Components</h1>
<!-- Vue 对 Web Components 有更好的原生支持 -->
<search-box @search="handleSearch"></search-box>
<data-list title="用户列表" :items.prop="users"></data-list>
</div>
</template>

<script setup>
import { ref } from 'vue';

const users = ref(['张三', '李四', '王五']);

function handleSearch(e) {
console.log('搜索关键词:', e.detail.keyword);
}
</script>
// vite.config.js 中配置 Vue 跳过自定义元素解析
export default {
plugins: [
vue({
template: {
compilerOptions: {
isCustomElement: (tag) => tag.includes('-')
}
}
})
]
};

八、浏览器兼容性与 Polyfill

8.1 兼容性总览

特性ChromeFirefoxSafariEdge
Custom Elements v1✅ 67+✅ 63+✅ 10.1+✅ 79+
Shadow DOM v1✅ 53+✅ 63+✅ 10+✅ 79+
HTML Templates✅ 26+✅ 22+✅ 8+✅ 13+
<slot>✅ 53+✅ 63+✅ 10+✅ 79+
Customized Built-in Elements✅ 67+✅ 63+✅ 79+
Form-Associated Custom Elements✅ 77+✅ 93+✅ 16.4+✅ 79+
Declarative Shadow DOM✅ 90+✅ 123+✅ 16.4+✅ 90+

8.2 使用 Polyfill

对于需要兼容旧浏览器的项目,可以使用 @webcomponents/webcomponentsjs

<!-- 按需加载 polyfill -->
<script src="https://unpkg.com/@webcomponents/webcomponentsjs@2/webcomponents-loader.js"></script>

<script>
// 等待 polyfill 加载完成后再定义组件
window.addEventListener('WebComponentsReady', () => {
// 定义自定义元素...
});
</script>

九、最佳实践

9.1 组件设计原则

9.2 开发清单

实践说明
标签名必须含连字符例如 <my-element>
connectedCallback 中初始化 DOM不要在 constructor 中操作 DOM
disconnectedCallback 中清理资源移除事件监听、清除定时器
使用 CSS 变量暴露样式接口允许外部自定义样式
使用 ::part() 暴露内部元素提供更细粒度的样式控制
自定义事件设置 composed: true确保事件能穿越 Shadow DOM
提供合理的默认值确保组件在无属性时也能正常显示
支持无障碍访问添加 rolearia-* 等属性
使用 adoptedStyleSheets 共享样式提高多组件场景的性能
保持 Attribute 和 Property 同步方便使用者以任意方式设置值

十、面试高频问答

Q1:什么是 Web Components?它由哪些技术组成?

A: Web Components 是一套浏览器原生的组件化技术方案,由三大核心技术组成:

  1. Custom Elements:定义自定义 HTML 标签和行为
  2. Shadow DOM:提供 DOM 和样式的隔离
  3. HTML Templates<template> + <slot>):定义可复用的 HTML 模板和内容分发插槽

它们使开发者无需依赖框架就能创建封装良好、可复用的组件。


Q2:Shadow DOM 的作用是什么?open 和 closed 模式有什么区别?

A: Shadow DOM 用于实现 DOM 和 CSS 的隔离封装。组件内部的样式不会影响外部,外部样式也不会泄漏到组件内部。

  • open 模式:外部可以通过 element.shadowRoot 访问 Shadow DOM,方便调试和操作
  • closed 模式element.shadowRoot 返回 null,外部无法直接访问

实际开发中推荐使用 open 模式,因为 closed 不提供真正的安全保障,反而增加调试难度。


Q3:自定义元素的生命周期有哪些?分别在什么时候触发?

A:

  • constructor():元素创建时调用,用于初始化状态和绑定 Shadow DOM
  • connectedCallback():元素插入 DOM 时调用,适合做 DOM 操作和数据获取
  • disconnectedCallback():元素从 DOM 移除时调用,用于清理资源
  • attributeChangedCallback(name, old, new):被观察的属性变化时调用(需配合 static observedAttributes
  • adoptedCallback():元素被移动到新文档时调用(如 iframe 场景)

Q4:为什么自定义元素的标签名必须包含连字符?

A: 这是 W3C 规范的强制要求,目的是避免与现有或未来的原生 HTML 标签冲突。HTML 规范保证原生标签永远不会包含连字符,因此带连字符的标签名天然与原生标签隔离。同时,浏览器解析器可以据此区分自定义元素和未知的原生标签。


Q5:如何实现 Web Components 与外部的通信?

A: 主要有三种方式:

  1. Attributes:通过 HTML 属性传递字符串数据(getAttribute / setAttribute
  2. Properties:通过 JavaScript 属性传递任意类型数据(getter/setter)
  3. Custom Events:通过 CustomEvent + dispatchEvent 向外发送事件,需设置 bubbles: truecomposed: true 以穿越 Shadow DOM

Q6:Shadow DOM 中如何让外部控制内部样式?

A: 有三种主要方式:

  1. CSS 自定义属性(变量):CSS 变量可以穿透 Shadow DOM 边界,组件内部使用 var(--xx) 接收
  2. ::part() 伪元素:组件内部元素添加 part 属性,外部通过 ::part() 选择器自定义样式
  3. <slot> 插槽内容:分发到插槽的内容仍受外部样式控制,可通过 ::slotted() 在组件内部调整样式

Q7:Web Components 和 React/Vue 组件有什么区别?如何选择?

A:

维度Web ComponentsReact/Vue
标准W3C 标准,浏览器原生社区方案,需要运行时
跨框架天然跨框架绑定特定框架
样式隔离Shadow DOM 原生支持需要额外方案
状态管理需要自己实现框架内置响应式系统
生态相对较小丰富成熟
性能原生性能,无虚拟 DOM 开销虚拟 DOM diffing

选择建议:需要跨框架复用或做设计系统时选 Web Components;业务应用开发选 React/Vue 更高效。两者也可以结合使用。


Q8:什么是 Declarative Shadow DOM?它解决了什么问题?

A: 声明式 Shadow DOM 允许在 HTML 中通过 <template shadowrootmode="open"> 直接定义 Shadow DOM,无需 JavaScript。它解决了两个核心问题:

  1. SSR(服务端渲染):传统 Shadow DOM 必须依赖 JS 创建,无法在服务端生成。声明式方案让服务端可以直接输出带 Shadow DOM 的 HTML
  2. 首屏渲染性能:浏览器解析 HTML 时就能创建 Shadow DOM,不需要等待 JS 下载和执行

Q9:Form-Associated Custom Elements 是什么?如何使用?

A: Form-Associated Custom Elements 让自定义元素可以像原生表单控件一样参与表单提交和验证。使用步骤:

  1. 设置 static formAssociated = true
  2. constructor 中调用 this.attachInternals() 获取 ElementInternals 对象
  3. 通过 internals.setFormValue() 设置表单值
  4. 可选实现 formResetCallback() 等回调

这样自定义元素就能通过 FormData 提交数据,并支持表单重置、校验等功能。


Q10:Web Components 的主要局限性有哪些?

A:

  1. 无内置响应式系统:不像 React/Vue 有 state/ref 自动触发更新,需手动管理
  2. SSR 支持有限:传统方案依赖 JS,声明式 Shadow DOM 兼容性还在改善中
  3. Customized Built-in Elements 在 Safari 不支持
  4. 缺乏模板语法:没有 JSX 或模板指令,复杂 UI 需要拼接字符串或操作 DOM
  5. 生态较小:工具链、UI 库不如 React/Vue 丰富
  6. 学习曲线:虽然是原生 API,但概念较多(Shadow DOM、slot 分发、事件穿透等)