Web Components 基础与实践
大约 6 分钟约 1893 字
Web Components 基础与实践
什么是 Web Components?
Web Components 是浏览器原生支持的组件化标准,由 W3C 制定,包含四个核心技术:
- Custom Elements — 自定义 HTML 元素,定义新的 HTML 标签
- Shadow DOM — 封装组件的 DOM 和样式,实现隔离
- HTML Templates — 可复用的 DOM 模板
- ES Modules — 标准的 JavaScript 模块系统
Web Components 最大的优势是框架无关——用原生 API 编写的组件可以在 React、Vue、Angular 以及无框架的项目中使用。它是浏览器层面的组件标准,不需要任何构建工具或运行时库。
Custom Elements
基础自定义元素
// 定义自定义元素
class UserCard extends HTMLElement {
// 声明需要监听的属性变化
static get observedAttributes() {
return ['name', 'role', 'avatar', 'status'];
}
constructor() {
super(); // 必须调用 super()
// 创建 Shadow DOM
this.attachShadow({ mode: 'open' });
// 内部状态
this._data = {};
}
// 生命周期:元素被插入 DOM 时调用
connectedCallback() {
this.render();
this.setupEvents();
}
// 生命周期:元素从 DOM 中移除时调用
disconnectedCallback() {
this.cleanup();
}
// 生命周期:元素被移动到新文档时调用
adoptedCallback() {}
// 生命周期:属性变化时调用
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this._data[name] = newValue;
this.render();
}
}
// 渲染方法
render() {
const { name, role, avatar, status } = {
name: this.getAttribute('name') || '未知用户',
role: this.getAttribute('role') || 'user',
avatar: this.getAttribute('avatar') || '',
status: this.getAttribute('status') || 'offline',
};
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
font-family: -apple-system, sans-serif;
}
.card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border: 1px solid #e8e8e8;
border-radius: 8px;
background: #fff;
transition: box-shadow 0.2s;
}
.card:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
}
.info { flex: 1; }
.name { font-weight: 600; font-size: 16px; margin: 0 0 4px; }
.role { font-size: 13px; color: #8c8c8c; margin: 0; }
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-dot.online { background: #52c41a; }
.status-dot.offline { background: #ff4d4f; }
.status-dot.busy { background: #faad14; }
::slotted(.actions) {
margin-left: auto;
}
</style>
<div class="card">
${avatar ? `<img class="avatar" src="${avatar}" alt="${name}">` : ''}
<div class="info">
<p class="name">${name}</p>
<p class="role">${role}</p>
</div>
<span class="status-dot ${status}"></span>
<slot name="actions"></slot>
</div>
`;
}
setupEvents() {
this.shadowRoot.querySelector('.card').addEventListener('click', (e) => {
this.dispatchEvent(new CustomEvent('card-click', {
detail: { name: this.getAttribute('name') },
bubbles: true,
composed: true,
}));
});
}
cleanup() {
// 清理事件监听等
}
}
// 注册自定义元素(名称必须包含连字符)
customElements.define('user-card', UserCard);使用自定义元素
<!-- 基本使用 -->
<user-card name="张三" role="管理员" status="online" avatar="/avatar/1.jpg"></user-card>
<!-- 使用 slot 插槽 -->
<user-card name="李四" role="用户" status="offline">
<button slot="actions" class="actions">编辑</button>
</user-card>
<!-- 动态创建 -->
const card = document.createElement('user-card');
card.setAttribute('name', '王五');
card.setAttribute('role', '工程师');
document.body.appendChild(card);
<!-- 监听自定义事件 -->
document.querySelector('user-card').addEventListener('card-click', (e) => {
console.log('Clicked:', e.detail.name);
});Shadow DOM
Shadow DOM 的作用
Shadow DOM 提供了 DOM 和样式的封装隔离:
- 样式隔离 — Shadow DOM 内部的样式不会影响外部,外部样式也不会穿透进来
- DOM 隔离 — 外部无法直接访问 Shadow DOM 内部的元素
- Slot 插槽 — 通过 slot 机制允许外部内容投射到组件内部
open vs closed 模式
// open 模式 — 可以通过 element.shadowRoot 访问
this.attachShadow({ mode: 'open' });
const shadow = this.shadowRoot; // 可以访问
// closed 模式 — 外部无法访问 shadowRoot
this.attachShadow({ mode: 'closed' });
const shadow = this.shadowRoot; // 返回 null
// 注意:closed 模式并不是真正安全的,仍可通过浏览器 DevTools 查看
// 实际开发中通常使用 open 模式CSS 变量穿透
class ThemedButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host {
/* 使用 CSS 变量,允许外部自定义 */
--btn-color: #1890ff;
--btn-text: #fff;
--btn-radius: 4px;
--btn-padding: 8px 16px;
--btn-font-size: 14px;
display: inline-block;
}
button {
background: var(--btn-color);
color: var(--btn-text);
border: none;
border-radius: var(--btn-radius);
padding: var(--btn-padding);
font-size: var(--btn-font-size);
cursor: pointer;
transition: opacity 0.2s;
}
button:hover {
opacity: 0.85;
}
</style>
<button><slot></slot></button>
`;
}
}
customElements.define('themed-button', ThemedButton);<!-- 外部通过 CSS 变量自定义样式 -->
<style>
themed-button {
--btn-color: #ff4d4f;
--btn-radius: 20px;
--btn-padding: 12px 24px;
}
</style>
<themed-button>删除</themed-button>Slot 插槽机制
class Card extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
.card {
border: 1px solid #e8e8e8;
border-radius: 8px;
overflow: hidden;
}
.header {
display: flex;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid #e8e8e8;
}
.title { font-weight: 600; }
.body { padding: 16px; }
/* 默认插槽样式 */
::slotted(*) {
margin: 0;
}
/* 具名插槽样式 */
::slotted([slot="footer"]) {
color: #8c8c8c;
font-size: 13px;
}
</style>
<div class="card">
<div class="header">
<slot name="title">默认标题</slot>
<slot name="actions"></slot>
</div>
<div class="body">
<slot>默认内容</slot>
</div>
<slot name="footer"></slot>
</div>
`;
}
}
customElements.define('my-card', Card);<!-- 使用插槽 -->
<my-card>
<h2 slot="title">设备列表</h2>
<button slot="actions">添加设备</button>
<p>这里有 10 个设备</p>
<div slot="footer">最后更新:2024-01-15</div>
</my-card>响应式属性
class ToggleSwitch extends HTMLElement {
// 使用私有字段
#checked = false;
static get observedAttributes() {
return ['checked', 'disabled', 'label'];
}
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._render();
}
// Getter / Setter
get checked() {
return this.#checked;
}
set checked(value) {
this.#checked = Boolean(value);
this.setAttribute('checked', String(this.#checked));
this._updateView();
}
connectedCallback() {
this.shadowRoot.querySelector('.switch').addEventListener('click', () => {
if (this.hasAttribute('disabled')) return;
this.checked = !this.checked;
this.dispatchEvent(new CustomEvent('change', {
detail: { checked: this.#checked },
bubbles: true,
composed: true,
}));
});
// 支持键盘操作
this.shadowRoot.querySelector('.switch').addEventListener('keydown', (e) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
this.checked = !this.checked;
}
});
this.setAttribute('tabindex', '0');
this.setAttribute('role', 'switch');
this.setAttribute('aria-checked', String(this.#checked));
}
attributeChangedCallback(name, oldVal, newVal) {
if (name === 'checked') {
this.#checked = newVal !== null && newVal !== 'false';
this._updateView();
}
}
_updateView() {
const knob = this.shadowRoot.querySelector('.knob');
const track = this.shadowRoot.querySelector('.track');
if (this.#checked) {
knob.style.left = '24px';
track.style.background = '#1890ff';
} else {
knob.style.left = '2px';
track.style.background = '#ccc';
}
this.setAttribute('aria-checked', String(this.#checked));
}
_render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-family: -apple-system, sans-serif;
}
:host([disabled]) {
opacity: 0.5;
cursor: not-allowed;
}
.track {
width: 44px;
height: 22px;
border-radius: 11px;
background: #ccc;
position: relative;
transition: background 0.3s;
}
.knob {
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
position: absolute;
top: 2px;
left: 2px;
transition: left 0.3s;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.label {
font-size: 14px;
color: #333;
}
</style>
<div class="track">
<div class="knob"></div>
</div>
<span class="label"><slot></slot></span>
`;
}
}
customElements.define('toggle-switch', ToggleSwitch);<!-- 使用 -->
<toggle-switch label="启用通知"></toggle-switch>
<toggle-switch checked label="暗色模式"></toggle-switch>
<toggle-switch disabled label="已禁用"></toggle-switch>
<script>
document.querySelector('toggle-switch').addEventListener('change', (e) => {
console.log('Toggle:', e.detail.checked);
});
</script>使用 Lit 简化开发
原生 Web Components 开发比较繁琐(手写模板、手动管理属性),Lit 是 Google 推出的轻量库,用声明式的方式简化 Web Components 开发。
import { LitElement, html, css, customElement, property } from 'lit';
@customElement('lit-counter')
export class LitCounter extends LitElement {
static styles = css`
:host { display: block; font-family: sans-serif; }
.counter { display: flex; align-items: center; gap: 12px; }
button {
padding: 8px 16px;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
}
.count { font-size: 24px; font-weight: bold; min-width: 40px; text-align: center; }
`;
@property({ type: Number }) count = 0;
@property({ type: String }) label = '计数器';
render() {
return html`
<h3>${this.label}</h3>
<div class="counter">
<button @click=${() => this.count--}>-</button>
<span class="count">${this.count}</span>
<button @click=${() => this.count++}>+</button>
</div>
`;
}
}<lit-counter label="我的计数器" count="5"></lit-counter>在框架中使用 Web Components
React 中使用
// React 事件处理需要特殊处理
import { useEffect, useRef } from 'react';
function DeviceCard({ name, status, onEdit }) {
const ref = useRef<HTMLElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
// Web Components 的自定义事件需要用 ref 监听
const handler = (e: any) => onEdit(e.detail.id);
el.addEventListener('card-click', handler);
return () => el.removeEventListener('card-click', handler);
}, [onEdit]);
return (
<user-card
ref={ref}
name={name}
status={status}
/>
);
}Vue 中使用
<template>
<!-- Vue 3 对 Web Components 支持良好 -->
<user-card
:name="user.name"
:status="user.status"
@card-click="handleCardClick"
/>
</template>
<script setup>
// Vue 配置
// vue.config.js 中需要配置 compilerOptions.isCustomElement
const handleCardClick = (e) => {
console.log('Card clicked:', e.detail.name);
};
</script>常见陷阱
- 自定义元素名必须包含连字符 —
my-button而非button - Shadow DOM 中的 CSS 变量可以继承 — 外部定义的 CSS 变量可以在 Shadow DOM 中使用
- disconnectedCallback 中清理资源 — 定时器、事件监听器、Observer
- SSR 兼容性 — Shadow DOM 不支持 SSR,需要使用 Declarative Shadow DOM
- 不要期望完全替代框架 — Web Components 缺少响应式数据绑定、路由等高级功能
最佳实践总结
- 使用 Lit 简化开发 — 避免手写模板和属性管理
- 通过 CSS 变量实现主题化 — 允许外部自定义组件样式
- 添加 ARIA 属性 — 支持无障碍访问
- 在 disconnectedCallback 中清理 — 防止内存泄漏
- 通过 npm 发布 — 使用 ES Module 导入
- 适合封装基础组件 — 按钮、图标、输入框等框架无关的通用组件
