作为一名资深前端架构师,我很高兴为你带来这份 Web Components 深入教程。在现代前端开发中,组件化是提高代码复用性、可维护性和协作效率的关键。虽然 React、Vue、Angular 等框架提供了强大的组件化能力,但 Web Components 作为浏览器原生支持的技术,为我们提供了一种不依赖任何框架的组件构建方式。理解并掌握它,能让你在任何前端项目中都能构建出独立的、可移植的 UI 元素。
Web Components 并非单一技术,而是一套包含多种标准的集合,它们协同工作,使开发者能够创建可复用的自定义元素。
| 标准 | 描述 | 作用 |
|---|---|---|
| Custom Elements (自定义元素) | 允许开发者定义新的 HTML 标签,并扩展现有 HTML 标签。 | 定义组件的结构和行为。 |
| Shadow DOM (影子 DOM) | 为 Web Component 提供独立的 DOM 树和样式作用域,与主文档 DOM 隔离。 | 实现组件的封装,防止样式和行为泄露。 |
| HTML Templates (<template> 和 <slot>) | 定义可复用的 HTML 结构片段,在页面加载时不会渲染。 | 组件内容的插槽机制和模板复用。 |
| HTML Modules (HTML 模块) | 允许通过 <link rel="module"> 导入 HTML 文件作为模块(实验性)。 |
组件的模块化导入(未来方向)。 |
核心流程:定义一个 JS 类继承 HTMLElement,在其中创建 Shadow DOM 并挂载 HTML 模板,然后通过 customElements.define() 注册为自定义元素。
自定义元素是 Web Components 的核心。它们是浏览器知道如何解析和渲染的新 HTML 标签。
所有自定义元素都必须继承自 HTMLElement 类。
// 定义一个新的 Custom Element 类
class MySimpleElement extends HTMLElement {
constructor() {
super(); // 必须调用父类的 constructor
console.log('MySimpleElement 实例已创建!');
// 可以在这里设置初始状态或绑定事件
this.innerHTML = `<p>Hello from <strong>${this.tagName.toLowerCase()}</strong>!</p>`;
}
// 生命周期回调函数
connectedCallback() {
console.log('MySimpleElement 已连接到文档 DOM!');
// 元素首次连接到文档 DOM 时被调用
// 适合进行初始渲染或添加事件监听器
}
disconnectedCallback() {
console.log('MySimpleElement 已从文档 DOM 移除!');
// 元素从文档 DOM 移除时被调用
// 适合进行清理工作,如移除事件监听器
}
adoptedCallback() {
console.log('MySimpleElement 已被移动到新的文档!');
// 元素被移动到新的文档时被调用(例如,通过 document.adoptNode())
}
static get observedAttributes() {
// 定义要监听的属性列表
return ['name', 'color'];
}
attributeChangedCallback(name, oldValue, newValue) {
console.log(`属性 ${name} 从 ${oldValue} 变为 ${newValue}`);
// 监听的属性发生变化时调用
// 适合根据属性变化更新组件的 UI 或行为
}
}
// 注册自定义元素
// 标签名必须包含连字符(-),例如 'my-element' 而不是 'myelement'
customElements.define('my-simple-element', MySimpleElement);
在 HTML 中使用:
<my-simple-element></my-simple-element>
<my-simple-element name="World" color="blue"></my-simple-element>
自定义元素提供了几个钩子函数,用于响应元素在 DOM 中的生命周期事件:
constructor(): 当元素实例被创建或升级时调用。connectedCallback(): 当元素首次被连接到文档 DOM 时调用。disconnectedCallback(): 当元素从文档 DOM 中移除时调用。adoptedCallback(): 当元素被移动到新的文档(例如,从一个 iframe 移动到主文档)时调用。attributeChangedCallback(name, oldValue, newValue): 当元素的一个被观察的属性(在 static get observedAttributes() 中定义)被添加、移除或更改时调用。Shadow DOM 解决了 Web Components 的两大核心问题:样式封装和 DOM 封装。它允许你将一个独立的 DOM 子树附加到常规 DOM 元素上。
想象一个浏览器内置的 <video> 标签。它的播放、暂停按钮、进度条等都是其内部的 DOM 结构和样式,但你无法通过普通的 CSS 选择器或 JavaScript 访问或修改它们。这就是 Shadow DOM 的作用:提供一个“影子”化的 DOM 树,与主文档 DOM 隔离。
通过元素的 attachShadow() 方法创建并附加一个 Shadow Root:
class MyShadowElement extends HTMLElement {
constructor() {
super();
// 附加 Shadow DOM
// mode: 'open' 表示可以通过 JavaScript 访问 Shadow DOM(element.shadowRoot)
// mode: 'closed' 表示外部无法访问,更严格的封装,但实际使用较少
const shadowRoot = this.attachShadow({ mode: 'open' });
// 在 Shadow DOM 中添加内容和样式
shadowRoot.innerHTML = `
<style>
/* 这里的样式只作用于 Shadow DOM 内部 */
p {
color: blue;
font-size: 18px;
}
.highlight {
background-color: yellow;
}
:host { /* :host 选择器指向 Shadow DOM 的宿主元素本身 */
display: block; /* 默认 inline,改为 block 方便布局 */
border: 2px solid lightgray;
padding: 15px;
margin: 10px 0;
border-radius: 8px;
}
:host-context(body.dark-theme) p {
color: lightblue; /* 根据外部主题调整内部样式 */
}
</style>
<p>这是来自 <span class="highlight">Shadow DOM</span> 的内容。</p>
`;
}
}
customElements.define('my-shadow-element', MyShadowElement);
在 HTML 中使用:
<my-shadow-element></my-shadow-element>
<!-- 外部样式无法影响 Shadow DOM 内部的 P 标签颜色 -->
<style>
my-shadow-element p {
color: red !important; /* 不会生效 */
}
</style>
<style> 标签或 <link> 引入的样式,只作用于 Shadow DOM 内部的元素。:host 选择器: 用于选择 Shadow DOM 的宿主元素本身。例如 :host { display: block; }。:host() 和 :host-context(): 更高级的宿主选择器。
:host(.active): 当宿主元素具有 .active 类时。:host-context(body.dark-theme): 当宿主元素的祖先(或自身)匹配 body.dark-theme 时。/* 主文档 CSS */
my-shadow-element {
--component-text-color: purple;
}
/* Shadow DOM 内部 CSS */
p {
color: var(--component-text-color, blue); /* 提供默认值 */
}
::part() 和 ::theme()(实验性): 允许在 Shadow DOM 内部标记特定部分,供外部通过 ::part() 选择器进行样式定制。模板和插槽是 Web Components 实现内容复用和分发的核心机制。
<template> 元素<template> 标签内的内容在页面加载时不会被渲染,它是一个被浏览器“惰性”解析的 HTML 片段,直到被 JavaScript 克隆并插入到 DOM 中。
<template id="my-component-template">
<style>
div {
border: 1px solid green;
padding: 10px;
margin: 5px;
background-color: #e6ffe6;
}
h4 {
color: green;
}
</style>
<div>
<h4>我是模板里的标题</h4>
<p>这是模板里的默认内容。</p>
</div>
</template>
<script>
class MyTemplateElement extends HTMLElement {
constructor() {
super();
const shadowRoot = this.attachShadow({ mode: 'open' });
const template = document.getElementById('my-component-template');
// 克隆模板内容并附加到 Shadow DOM
shadowRoot.appendChild(template.content.cloneNode(true));
}
}
customElements.define('my-template-element', MyTemplateElement);
</script>
<!-- 在 HTML 中使用模板组件 -->
<my-template-element></my-template-element>
<slot> 元素<slot> 元素是 Shadow DOM 内部的占位符,它允许将宿主元素内部的子节点“投射”到 Shadow DOM 模板的特定位置。这提供了组件的灵活内容分发能力。
name 属性的 <slot> 会接收所有未命名投射的子节点。name 属性的 <slot> 会接收具有对应 slot 属性的子节点。<template id="my-slot-template">
<style>
.card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
margin: 15px 0;
background-color: #fff;
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
}
.header {
font-size: 1.5em;
margin-bottom: 10px;
color: #3498db;
}
.footer {
font-size: 0.9em;
color: #777;
margin-top: 15px;
border-top: 1px dashed #eee;
padding-top: 10px;
}
</style>
<div class="card">
<div class="header">
<slot name="card-header">默认卡片标题</slot> <!-- 命名插槽 -->
</div>
<div class="body">
<slot>卡片主体内容(默认匿名插槽)</slot> <!-- 匿名插槽 -->
</div>
<div class="footer">
<slot name="card-footer">默认卡片底部</slot> <!-- 命名插槽 -->
</div>
</div>
</template>
<script>
class MyCard extends HTMLElement {
constructor() {
super();
const shadowRoot = this.attachShadow({ mode: 'open' });
const template = document.getElementById('my-slot-template');
shadowRoot.appendChild(template.content.cloneNode(true));
}
}
customElements.define('my-card', MyCard);
</script>
<!-- 在 HTML 中使用 MyCard 组件 -->
<my-card>
<h2 slot="card-header">我的自定义卡片</h2>
<p>这是卡片的主体内容。它将被插入到匿名插槽中。</p>
<ul>
<li>列表项 1</li>
<li>列表项 2</li>
</ul>
<span slot="card-footer">由 Web Components 提供支持。</span>
</my-card>
<my-card>
<!-- 没有提供 slot="card-header",所以会显示默认标题 -->
<!-- 没有提供匿名内容,所以会显示默认主体内容 -->
<span slot="card-footer">这是一个只有底部内容的卡片。</span>
</my-card>
通过插槽,我们可以构建出高度灵活且可定制的组件,类似 React/Vue 中的 children 或 slot 概念。
让我们结合 Custom Elements, Shadow DOM 和 Templates,构建一个可关闭的警告框组件。
组件功能:
type="success", type="error")改变样式。<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>可关闭警告框 Web Component</title>
<!-- 引入 Highlight.js 和 Mermaid.js 样式和脚本 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
<style>
/* 为页面的 body 添加一些基本样式,使示例组件更明显 */
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 20px;
background-color: #f0f2f5;
}
h2 {
color: #333;
}
.container-demo {
max-width: 800px;
margin: 30px auto;
padding: 25px;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
}
</style>
</head>
<body>
<div class="container-demo">
<h2>可关闭警告框组件示例</h2>
<!-- 成功提示 -->
<my-alert type="success">
<p><strong>恭喜!</strong> 您的操作已成功完成。</p>
</my-alert>
<!-- 错误提示 -->
<my-alert type="error">
<h4>发生错误!</h4>
<p>请检查您的输入并重试。</p>
</my-alert>
<!-- 警告提示 -->
<my-alert type="warning">
这是一个警告消息,请注意!
</my-alert>
<!-- 信息提示 -->
<my-alert type="info">
这是一个普通信息提示。
</my-alert>
<!-- 默认类型 -->
<my-alert>
这是一个默认样式的提示。
</my-alert>
</div>
<!-- 警告框组件的 <template> 标签 -->
<template id="alert-template">
<style>
:host {
display: block; /* 默认 inline,改为 block 方便布局 */
margin-bottom: 15px;
font-family: Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
}
.alert {
padding: 15px 20px;
border-radius: 5px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
}
.alert-content {
flex-grow: 1;
margin-right: 15px;
}
.close-button {
background: none;
border: none;
font-size: 1.5em;
color: inherit; /* 继承警告框的文本颜色 */
cursor: pointer;
padding: 0 5px;
line-height: 1;
opacity: 0.7;
transition: opacity 0.2s ease;
}
.close-button:hover {
opacity: 1;
}
/* 类型样式 */
:host([type="success"]) .alert {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
:host([type="error"]) .alert {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
:host([type="warning"]) .alert {
background-color: #fff3cd;
color: #856404;
border: 1px solid #ffeeba;
}
:host([type="info"]) .alert {
background-color: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
/* 默认样式 */
:host(:not([type])) .alert {
background-color: #e2e3e5;
color: #383d41;
border: 1px solid #d6d8db;
}
/* 插槽内容样式(可选,更建议让插槽内容自行管理样式) */
/* 但有时可以提供一些默认的排版规则 */
.alert-content ::slotted(p) {
margin: 0.5em 0;
}
.alert-content ::slotted(h4) {
margin-top: 0;
margin-bottom: 0.5em;
}
</style>
<div class="alert">
<div class="alert-content">
<slot>默认警告内容。</slot> <!-- 匿名插槽,用于放置警告消息 -->
</div>
<button class="close-button" aria-label="关闭">×</button> <!-- 关闭按钮 -->
</div>
</template>
<script>
class MyAlert extends HTMLElement {
constructor() {
super();
// 附加 Shadow DOM
const shadowRoot = this.attachShadow({ mode: 'open' });
// 获取模板内容并克隆
const template = document.getElementById('alert-template');
const content = template.content.cloneNode(true);
shadowRoot.appendChild(content);
// 获取关闭按钮并绑定事件
this.closeButton = shadowRoot.querySelector('.close-button');
this.closeButton.addEventListener('click', () => this.hide());
}
// 生命周期钩子:当元素被添加到文档时
connectedCallback() {
console.log('MyAlert connected to DOM.');
// 可以在这里根据初始属性设置状态
this.updateVisibility();
}
// 生命周期钩子:定义需要观察的属性
static get observedAttributes() {
return ['type', 'hidden']; // 观察 'type' 和 'hidden' 属性
}
// 生命周期钩子:当观察的属性发生变化时
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'hidden') {
this.updateVisibility();
}
// 'type' 属性的变化由 CSS :host 选择器处理,无需额外 JS
console.log(`Attribute '${name}' changed from '${oldValue}' to '${newValue}'`);
}
// 隐藏警告框
hide() {
this.setAttribute('hidden', ''); // 添加 hidden 属性,利用浏览器原生隐藏功能
// 或者直接设置样式:this.style.display = 'none';
console.log('MyAlert hidden.');
// 可以触发一个自定义事件通知外部组件被关闭
this.dispatchEvent(new CustomEvent('alert-closed', {
bubbles: true, // 事件冒泡
composed: true // 事件穿透 Shadow DOM 边界
}));
}
// 显示警告框
show() {
this.removeAttribute('hidden');
// 或者设置样式:this.style.display = 'block';
console.log('MyAlert shown.');
}
// 根据 hidden 属性更新元素的显示/隐藏状态
updateVisibility() {
if (this.hasAttribute('hidden')) {
this.style.display = 'none';
} else {
this.style.display = ''; // 恢复默认 display 样式
}
}
}
// 定义自定义元素
customElements.define('my-alert', MyAlert);
// 外部监听事件示例
document.addEventListener('alert-closed', (event) => {
console.log('接收到 alert-closed 事件:', event.target.tagName);
// 可以在这里执行一些清理或日志记录
});
</script>
<!-- Highlight.js script -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script>hljs.highlightAll();</script>
<!-- Mermaid.js script -->
<script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
<script>
mermaid.initialize({
startOnLoad: true,
theme: 'default',
securityLevel: 'loose',
flowchart: {
useMaxWidth: true,
htmlLabels: true
}
});
</script>
</body>
</html>
click, input)会通过 Shadow DOM 边界“重新定位”并冒泡到主文档。这意味着你可以在主文档中监听组件内部元素的事件。
shadowRoot.querySelector('button').addEventListener('click', () => {
// 内部事件处理
});
// 在主文档中监听 my-alert 的点击事件 (即使点击的是 Shadow DOM 内部的按钮)
document.querySelector('my-alert').addEventListener('click', (e) => {
console.log('MyAlert 被点击了!', e.target); // e.target 会是 my-alert 元素本身
// 如果需要知道 Shadow DOM 内部哪个元素被点击,可以使用 event.composedPath()
console.log('点击路径:', e.composedPath());
});
CustomEvent。为了让事件穿透 Shadow DOM 边界并被外部捕获,需要设置 bubbles: true 和 composed: true。
// 在 Shadow DOM 内部触发
this.dispatchEvent(new CustomEvent('item-selected', {
detail: { itemId: this.itemId }, // 传递额外数据
bubbles: true, // 允许事件冒泡
composed: true // 允许事件穿透 Shadow DOM 边界
}));
<input value="hello">)。它们总是字符串。
<my-component my-attribute="some-string-value" another-attribute="123"></my-component>
inputElement.value)。它们可以是任何 JavaScript 类型(字符串、数字、布尔值、对象等)。
const myComponent = document.querySelector('my-component');
myComponent.myProperty = { key: 'value' }; // 可以是对象
myComponent.anotherProperty = 123; // 可以是数字
attributeChangedCallback。attributeChangedCallback 中手动同步。setAttribute()。自 HTML5 以来,自定义元素可以通过实现 formAssociated 静态属性并提供特定的生命周期方法,使其能够像原生表单元素一样参与表单提交和验证。
class MyFormControl extends HTMLElement {
static formAssociated = true; // 声明为表单关联元素
constructor() {
super();
this.internals = this.attachInternals(); // 获取 ElementInternals 对象
this.value = ''; // 内部状态
}
set value(val) {
this._value = val;
// 更新表单值
this.internals.setFormValue(val);
}
get value() {
return this._value;
}
// 监听 attributeChange,例如 'value' attribute
static get observedAttributes() { return ['value']; }
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'value' && this.value !== newValue) {
this.value = newValue;
}
}
// 表单相关回调
formAssociatedCallback(form) { /* ... */ }
formResetCallback() { /* ... */ }
formDisabledCallback(disabled) { /* ... */ }
formStateRestoreCallback(state, mode) { /* ... */ }
}
customElements.define('my-form-control', MyFormControl);
这对于构建复杂的、可验证的自定义表单输入组件非常有用。
虽然 Web Components 本身是原生的,但在大型项目中,你仍然会使用 Webpack, Rollup, Vite 等打包工具。这些工具可以帮助你:
例如,使用打包工具,你可以将样式直接导入到 JavaScript 文件中,然后注入 Shadow DOM。
// component.js (通过打包工具处理)
import styles from './component.css'; // 假设打包工具可以处理 CSS 导入
class MyComponent extends HTMLElement {
constructor() {
super();
const shadowRoot = this.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = `
<style>${styles}</style>
<div>Hello from component!</div>
`;
}
}
customElements.define('my-packed-component', MyComponent);
Web Components 是一项强大的技术,为构建可复用、可互操作的 Web 组件提供了标准化的解决方案。它们是构建设计系统、独立组件库的理想选择,尤其适用于需要“框架无关”的场景。
虽然它们可能不适用于所有类型的应用(特别是大型、复杂的单页应用,框架可能提供更高的开发效率),但理解 Web Components 的原理和能力,能够让你更好地选择工具,并在必要时构建出与任何框架和谐共存的独立组件。
随着浏览器对更多 Web Platform 特性的支持日益完善,以及社区工具的不断发展,Web Components 必将在未来的 Web 开发中扮演越来越重要的角色。
感谢您的阅读,希望本教程能帮助您掌握 Web Components 的精髓!