一次掌握 Web Components 四大核心技术,打造真正可复用的组件
前言
Web Components 是一套原生浏览器技术标准,用于创建可复用的自定义组件。它不需要任何框架,零依赖,跨浏览器兼容,是前端组件化的终极方案。
很多开发者容易混淆 Custom Elements 和 Web Components 的概念。简单来说:
Web Components 是总称,Custom Elements 是其核心组成部分。
css
┌─────────────────────────────────────────────────────────────┐
│ Web Components(总称) │
├─────────────────────────────────────────────────────────────┤
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Custom │ │ Shadow │ │ HTML │ │
│ │ Elements │ │ DOM │ │ Templates │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ ↓ ↓ ↓ │
│ 定义组件行为 封装DOM结构 声明组件模板 │
└─────────────────────────────────────────────────────────────┘
本文将从零开始,带你全面掌握 Web Components 的核心技术。
一、Custom Elements:自定义元素的基石
1.1 什么是 Custom Elements
Custom Elements(自定义元素)允许开发者创建自定义的 HTML 标签,封装特定功能和样式。
核心价值:
- ✅ 语义化 :
<user-card>比<div class="user-card">更清晰 - ✅ 复用性:一次开发,多处复用
- ✅ 封装性:配合 Shadow DOM,实现样式和 DOM 隔离
- ✅ 互操作性:原生 API,不依赖特定框架
1.2 自定义元素分类
| 类型 | 说明 | 示例 |
|---|---|---|
| 独立自定义元素 | 全新的自定义标签 | <my-element> |
| 定制内置元素 | 继承原生元素 | <button is="my-button"> |
1.3 快速上手:独立自定义元素
javascript
class MyElement extends HTMLElement {
constructor() {
super(); // 必须调用
// 初始化代码
}
// 生命周期回调
connectedCallback() {
// 元素被插入 DOM 时触发
this.render();
}
disconnectedCallback() {
// 元素从 DOM 移除时触发
this.cleanup();
}
attributeChangedCallback(name, oldValue, newValue) {
// 监听的属性变化时触发
}
// 指定要监听的属性
static get observedAttributes() {
return ['title', 'theme'];
}
}
// 注册元素(必须包含连字符)
customElements.define('my-element', MyElement);
1.4 实战示例:可交互的计数器
html
<counter-element count="0"></counter-element>
<script>
class CounterElement extends HTMLElement {
static get observedAttributes() {
return ['count'];
}
constructor() {
super();
this._count = 0;
}
connectedCallback() {
this.render();
this.addEventListener('click', this.increment.bind(this));
}
attributeChangedCallback(name, oldVal, newVal) {
if (name === 'count') {
this._count = parseInt(newVal, 10) || 0;
this.render();
}
}
increment() {
this._count++;
this.setAttribute('count', this._count);
}
render() {
this.innerHTML = `
<style>
:host {
display: inline-block;
padding: 16px;
background: #f0f0f0;
border-radius: 8px;
cursor: pointer;
user-select: none;
}
:host(:hover) {
background: #e0e0e0;
}
</style>
<span>点击次数: ${this._count}</span>
`;
}
}
customElements.define('counter-element', CounterElement);
</script>
1.5 定制内置元素:继承原生元素
当你想扩展原生元素的行为时,使用定制内置元素:
javascript
class ValidatedInput extends HTMLInputElement {
static get observedAttributes() {
return ['pattern', 'error-message'];
}
constructor() {
super();
this._errorMessage = '输入格式错误';
}
connectedCallback() {
this.addEventListener('input', this.validate.bind(this));
}
validate() {
const pattern = this.getAttribute('pattern');
if (pattern && !new RegExp(pattern).test(this.value)) {
this.style.borderColor = 'red';
this.title = this._errorMessage;
} else {
this.style.borderColor = '';
this.title = '';
}
}
}
// 注册时需指定 extends
customElements.define('validated-input', ValidatedInput, { extends: 'input' });
html
<!-- 必须使用 is 属性 -->
<input is="validated-input" type="text" pattern="^\d{4}$" error-message="请输入4位数字">
⚠️ 注意:Safari 不支持定制内置元素,需使用 polyfill 或避免使用。
1.6 生命周期回调详解
| 回调方法 | 触发时机 | 典型用途 |
|---|---|---|
constructor() |
元素实例化时 | 初始化状态、创建 Shadow DOM |
connectedCallback() |
元素插入 DOM 时 | 渲染内容、添加事件监听 |
disconnectedCallback() |
元素从 DOM 移除时 | 清理事件、释放资源 |
adoptedCallback() |
元素移至新文档时 | 文档迁移时重新初始化 |
attributeChangedCallback |
监听的属性变化时 | 响应属性变化更新 UI |
重要规则:
constructor中不应设置属性或添加子节点(规范要求)- 在
disconnectedCallback中移除事件监听,避免内存泄漏
二、Shadow DOM:真正的封装
2.1 什么是 Shadow DOM
Shadow DOM 允许将一个隐藏的、独立的 DOM 树附加到元素上,实现真正的封装。
xml
┌─────────────────────────────────────────────────┐
│ Document (Light DOM) │
│ ┌───────────────────────────────────────────┐ │
│ │ <my-component> │ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ Shadow Root (Shadow DOM) │ │ │
│ │ │ ┌───────────────────────┐ │ │ │
│ │ │ │ <style>封装样式</style>│ │ │ │
│ │ │ │ <div>内部结构</div> │ │ │ │
│ │ │ │ <slot>插槽</slot> │ │ │ │
│ │ │ └───────────────────────┘ │ │ │
│ │ └─────────────────────────────┘ │ │
│ │ <p>外部内容(投射到 slot)</p> │ │
│ └───────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
2.2 创建 Shadow DOM
javascript
class MyComponent extends HTMLElement {
constructor() {
super();
// 创建 Shadow DOM
this.attachShadow({ mode: 'open' });
// mode: 'open' - 外部可通过 element.shadowRoot 访问
// mode: 'closed' - 外部无法访问,shadowRoot 返回 null
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
/* 样式仅作用于 Shadow DOM 内部 */
:host {
display: block;
border: 1px solid #ccc;
padding: 16px;
}
.title {
color: blue;
/* 外部 .title 样式不会影响这里 */
}
</style>
<h2 class="title">组件标题</h2>
<slot name="content"></slot>
<slot>默认插槽</slot>
`;
}
}
customElements.define('my-component', MyComponent);
2.3 Shadow DOM 样式选择器
| 选择器 | 作用范围 | 示例 |
|---|---|---|
:host |
组件本身 | :host { display: block; } |
:host(.class) |
带特定类的组件 | :host(.active) { ... } |
:host-context(.class) |
祖先匹配时生效 | :host-context(.dark) { ... } |
::slotted(selector) |
插槽内容样式 | ::slotted(p) { ... } |
::part(name) |
暴露给外部的部分 | ::part(header) { ... } |
2.4 样式隔离演示
html
<!-- 外部样式不会影响 Shadow DOM 内部 -->
<style>
.title { color: red !important; } /* 无效!被 Shadow DOM 阻断 */
</style>
<styled-card class="highlighted">
<p slot="content">这是插槽内容</p>
<span>这是默认插槽</span>
</styled-card>
<script>
class StyledCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
padding: 16px;
background: white;
}
:host(.highlighted) {
border: 2px solid gold;
}
.title {
color: blue;
}
</style>
<h2 class="title">组件标题</h2>
<slot name="content"></slot>
<slot></slot>
`;
}
}
customElements.define('styled-card', StyledCard);
</script>
2.5 事件模型
Shadow DOM 内部的事件会被重定向:
javascript
class EventComponent extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `<button id="inner">内部按钮</button>`;
this.shadowRoot.querySelector('button').addEventListener('click', (e) => {
// 发送自定义事件到外部
this.dispatchEvent(new CustomEvent('custom-click', {
bubbles: true,
composed: true, // 允许穿透 Shadow DOM 边界
detail: { message: '来自内部' }
}));
});
}
}
// 外部监听
document.querySelector('event-component').addEventListener('click', (e) => {
console.log(e.target); // <event-component>(重定向后)
console.log(e.composedPath()); // 完整的事件路径(包含 Shadow DOM 内部)
});
三、HTML Templates:声明式模板
3.1 基本用法
<template> 元素内的内容是惰性的,不会渲染,但可以被 JavaScript 克隆使用。
html
<template id="card-template">
<style>
.card {
border: 1px solid #ddd;
padding: 16px;
border-radius: 8px;
}
</style>
<div class="card">
<div class="card-title">
<slot name="title">默认标题</slot>
</div>
<div class="card-content">
<slot>默认内容</slot>
</div>
</div>
</template>
<script>
class CardTemplate extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
// 获取模板并克隆
const template = document.getElementById('card-template');
const clone = template.content.cloneNode(true);
this.shadowRoot.appendChild(clone);
}
}
customElements.define('card-template', CardTemplate);
</script>
<!-- 使用 -->
<card-template>
<h3 slot="title">自定义标题</h3>
<p>这是卡片内容</p>
</card-template>
3.2 模板优势
| 特性 | 说明 |
|---|---|
| 惰性加载 | 模板内容不会渲染,图片不会加载,脚本不会执行 |
| 高效克隆 | content.cloneNode(true) 比字符串拼接更高效 |
| 可复用 | 同一模板可多次克隆,创建多个实例 |
| 结构清晰 | HTML 结构在模板中声明,逻辑与表现分离 |
四、Slot 插槽机制
4.1 具名插槽
html
<template id="article-template">
<article>
<header>
<slot name="header"></slot>
</header>
<main>
<slot></slot> <!-- 默认插槽 -->
</main>
<footer>
<slot name="footer"></slot>
</footer>
</article>
</template>
<!-- 使用 -->
<my-article>
<h1 slot="header">文章标题</h1>
<p>这是正文内容...</p>
<small slot="footer">版权信息</small>
</my-article>
4.2 插槽事件
javascript
class SlotComponent extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `<slot name="content"></slot>`;
// 监听插槽内容变化
this.shadowRoot.querySelector('slot').addEventListener('slotchange', (e) => {
const assigned = e.target.assignedNodes();
console.log('插槽内容变化:', assigned);
});
}
}
4.3 插槽样式
javascript
this.shadowRoot.innerHTML = `
<style>
/* 插槽容器样式 */
.slot-container {
padding: 16px;
background: #eee;
}
/* 插槽内容样式(有限支持) */
::slotted(p) {
margin: 0;
color: #333;
}
::slotted(.highlight) {
background: yellow;
}
/* 注意:::slotted 只能选择直接子元素 */
/* ::slotted(p span) 无效! */
</style>
<div class="slot-container">
<slot></slot>
</div>
`;
五、完整示例:可复用的标签组件
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Tags Input 组件示例</title>
</head>
<body>
<tags-input value="JavaScript,HTML5,CSS"></tags-input>
<script>
class TagsInput extends HTMLElement {
static get observedAttributes() {
return ['value', 'placeholder', 'readonly'];
}
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._tags = [];
this._placeholder = '输入标签后按回车';
this._readonly = false;
}
connectedCallback() {
this.render();
}
attributeChangedCallback(name, oldVal, newVal) {
if (name === 'value' && oldVal !== newVal) {
this._tags = newVal ? newVal.split(',').filter(t => t.trim()) : [];
if (this.isConnected) this.render();
}
if (name === 'placeholder') {
this._placeholder = newVal || '输入标签后按回车';
}
if (name === 'readonly') {
this._readonly = newVal !== null;
}
}
handleKeydown(e) {
if (this._readonly) return;
const input = this.shadowRoot.querySelector('input');
if (e.key === 'Enter' && input.value.trim()) {
e.preventDefault();
this.addTag(input.value.trim());
input.value = '';
} else if (e.key === 'Backspace' && !input.value) {
this.removeLastTag();
}
}
addTag(tag) {
if (!this._tags.includes(tag)) {
this._tags.push(tag);
this.setAttribute('value', this._tags.join(','));
this.render();
}
}
removeTag(index) {
if (this._readonly) return;
this._tags.splice(index, 1);
this.setAttribute('value', this._tags.join(','));
this.render();
}
removeLastTag() {
if (this._readonly || this._tags.length === 0) return;
this._tags.pop();
this.setAttribute('value', this._tags.join(','));
this.render();
}
get value() {
return this._tags;
}
set value(tags) {
this.setAttribute('value', Array.isArray(tags) ? tags.join(',') : tags);
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.container {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
}
.container:focus-within {
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0,123,255,0.25);
}
.tag {
display: inline-flex;
align-items: center;
padding: 4px 8px;
background: #e9ecef;
border-radius: 4px;
font-size: 14px;
}
.tag button {
margin-left: 6px;
padding: 0;
border: none;
background: none;
cursor: pointer;
color: #666;
font-size: 16px;
line-height: 1;
}
.tag button:hover {
color: #dc3545;
}
input {
flex: 1;
min-width: 120px;
padding: 4px;
border: none;
outline: none;
font-size: 14px;
}
:host([readonly]) .container {
background: #f8f9fa;
}
:host([readonly]) button {
display: none;
}
</style>
<div class="container">
${this._tags.map((tag, i) => `
<span class="tag">
${tag}
<button type="button" data-index="${i}">×</button>
</span>
`).join('')}
<input type="text" placeholder="${this._placeholder}" ${this._readonly ? 'readonly' : ''}>
</div>
`;
// 绑定事件
this.shadowRoot.querySelectorAll('.tag button').forEach(btn => {
btn.addEventListener('click', () => {
this.removeTag(parseInt(btn.dataset.index));
});
});
this.shadowRoot.querySelector('input').addEventListener('keydown', (e) => {
this.handleKeydown(e);
});
}
}
customElements.define('tags-input', TagsInput);
</script>
</body>
</html>
六、框架集成
6.1 React 集成
jsx
import React, { useRef, useEffect } from 'react';
import './components/my-modal.js';
function App() {
const modalRef = useRef(null);
useEffect(() => {
const modal = modalRef.current;
modal.addEventListener('modal-close', () => {
console.log('模态框已关闭');
});
return () => modal.removeEventListener('modal-close', () => {});
}, []);
return (
<div>
<button onClick={() => modalRef.current.open()}>打开模态框</button>
<my-modal ref={modalRef} title="React 集成">
<p>React 内容</p>
</my-modal>
</div>
);
}
6.2 Vue 集成
vue
<template>
<div>
<button @click="openModal">打开模态框</button>
<my-modal
ref="modalRef"
:title="modalTitle"
@modal-close="onModalClose"
>
<p>Vue 内容</p>
</my-modal>
</div>
</template>
<script>
import './components/my-modal.js';
export default {
data() {
return { modalTitle: 'Vue 集成' };
},
methods: {
openModal() {
this.$refs.modalRef.open();
},
onModalClose() {
console.log('模态框关闭');
}
}
};
</script>
6.3 框架兼容性配置
javascript
// Vue 配置
app.config.compilerOptions.isCustomElement = (tag) => {
return tag.startsWith('my-');
};
// React 19+ 对 Web Components 有更好支持
七、最佳实践与常见陷阱
7.1 设计原则
| 原则 | 说明 |
|---|---|
| 渐进增强 | 提供降级方案,确保无 JavaScript 时基本可用 |
| 可访问性 | 使用 ARIA 属性,支持键盘导航 |
| 性能优先 | 使用 <template>,避免频繁 DOM 操作 |
| 样式隔离 | 使用 CSS 自定义属性暴露主题接口 |
| 语义化 | 合理使用原生元素,继承正确的 ARIA 角色 |
7.2 常见陷阱
javascript
// ❌ 错误:在 constructor 中操作 DOM
constructor() {
super();
this.innerHTML = '<p>内容</p>'; // 规范禁止
}
// ✅ 正确:在 connectedCallback 中操作
connectedCallback() {
this.innerHTML = '<p>内容</p>';
}
// ❌ 错误:忘记清理事件监听
connectedCallback() {
window.addEventListener('resize', this.handleResize);
}
// ✅ 正确:在 disconnectedCallback 中清理
disconnectedCallback() {
window.removeEventListener('resize', this.handleResize);
}
7.3 调试技巧
javascript
// 检查元素是否已注册
customElements.get('my-element'); // 返回构造函数或 undefined
// 等待元素定义完成
customElements.whenDefined('my-element').then(() => {
console.log('元素已注册');
});
// 检查 Shadow DOM 模式
const element = document.querySelector('my-component');
console.log(element.shadowRoot); // null = closed 或未创建
// 查看事件路径
element.addEventListener('click', (e) => {
console.log(e.composedPath()); // 完整路径
});
八、浏览器兼容性
| 特性 | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
| Custom Elements v1 | ✅ 67+ | ✅ 63+ | ✅ 13.1+ | ✅ 79+ |
| Shadow DOM v1 | ✅ 67+ | ✅ 63+ | ✅ 13.1+ | ✅ 79+ |
| HTML Templates | ✅ 全部 | ✅ 全部 | ✅ 全部 | ✅ 全部 |
CSS ::part() |
✅ 73+ | ✅ 72+ | ✅ 13.1+ | ✅ 79+ |
| ElementInternals | ✅ 77+ | ✅ 75+ | ✅ 16.4+ | ✅ 79+ |
Polyfill 方案:
html
<script src="https://unpkg.com/@webcomponents/webcomponentsjs/webcomponents-bundle.js"></script>
九、Custom Elements vs Web Components:如何选择?
| 场景 | 推荐方案 |
|---|---|
| 简单行为增强(如表单验证) | 仅 Custom Elements |
| 需要样式隔离的组件 | Custom Elements + Shadow DOM |
| 组件库开发 | 完整 Web Components |
| 性能敏感场景 | 仅 Custom Elements |
一句话总结:
Custom Elements 定义组件的"行为",Web Components 还定义组件的"边界"(通过 Shadow DOM)。
参考资料
- MDN: Web Components
- Google Developers: Custom Elements v1
- Web Components Playground
- Lit(Web Components 工具库)
如果这篇文章对你有帮助,欢迎点赞收藏,有问题欢迎在评论区讨论!