- 发布于
Web Components与自定义元素:原生组件开发实战指南
- 作者

- 姓名
- 全能波
- GitHub
- @weicracker
Web Components与自定义元素:原生组件开发实战指南
Web Components是浏览器原生支持的组件化技术,让我们能够创建可复用的自定义HTML元素。本文将分享Web Components的实战开发经验和最佳实践。
Custom Elements基础
自定义元素定义
// 基础自定义元素
class MyButton extends HTMLElement {
constructor() {
super();
// 初始化状态
this.disabled = false;
this.variant = 'primary';
// 绑定事件处理器
this.handleClick = this.handleClick.bind(this);
}
// 生命周期:元素被插入DOM时调用
connectedCallback() {
this.render();
this.attachEventListeners();
// 设置默认属性
if (!this.hasAttribute('role')) {
this.setAttribute('role', 'button');
}
if (!this.hasAttribute('tabindex')) {
this.setAttribute('tabindex', '0');
}
}
// 生命周期:元素从DOM中移除时调用
disconnectedCallback() {
this.removeEventListeners();
}
// 生命周期:属性变化时调用
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this.handleAttributeChange(name, oldValue, newValue);
}
}
// 生命周期:元素被移动到新文档时调用
adoptedCallback() {
console.log('Button moved to new document');
}
// 指定要监听的属性
static get observedAttributes() {
return ['disabled', 'variant', 'size'];
}
// 属性getter/setter
get disabled() {
return this.hasAttribute('disabled');
}
set disabled(value) {
if (value) {
this.setAttribute('disabled', '');
} else {
this.removeAttribute('disabled');
}
}
get variant() {
return this.getAttribute('variant') || 'primary';
}
set variant(value) {
this.setAttribute('variant', value);
}
// 渲染方法
render() {
this.innerHTML = `
<style>
:host {
display: inline-block;
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
font-size: 14px;
transition: all 0.2s ease;
outline: none;
}
:host([variant="primary"]) {
background-color: #007bff;
color: white;
}
:host([variant="secondary"]) {
background-color: #6c757d;
color: white;
}
:host([variant="outline"]) {
background-color: transparent;
color: #007bff;
border: 1px solid #007bff;
}
:host([disabled]) {
opacity: 0.6;
cursor: not-allowed;
pointer-events: none;
}
:host(:hover:not([disabled])) {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
:host(:focus) {
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.25);
}
:host([size="small"]) {
padding: 4px 8px;
font-size: 12px;
}
:host([size="large"]) {
padding: 12px 24px;
font-size: 16px;
}
</style>
<slot></slot>
`;
}
// 事件处理
attachEventListeners() {
this.addEventListener('click', this.handleClick);
this.addEventListener('keydown', this.handleKeydown);
}
removeEventListeners() {
this.removeEventListener('click', this.handleClick);
this.removeEventListener('keydown', this.handleKeydown);
}
handleClick(event) {
if (this.disabled) {
event.preventDefault();
event.stopPropagation();
return;
}
// 触发自定义事件
this.dispatchEvent(new CustomEvent('my-button-click', {
detail: { variant: this.variant },
bubbles: true,
composed: true
}));
}
handleKeydown(event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.handleClick(event);
}
}
handleAttributeChange(name, oldValue, newValue) {
switch (name) {
case 'disabled':
this.setAttribute('aria-disabled', this.disabled.toString());
break;
case 'variant':
case 'size':
// 样式会通过CSS自动更新
break;
}
}
}
// 注册自定义元素
customElements.define('my-button', MyButton);
// 使用示例
/*
<my-button variant="primary" size="large">
Click Me
</my-button>
<my-button variant="outline" disabled>
Disabled Button
</my-button>
*/
// JavaScript中动态创建
const button = document.createElement('my-button');
button.variant = 'secondary';
button.textContent = 'Dynamic Button';
button.addEventListener('my-button-click', (event) => {
console.log('Button clicked:', event.detail);
});
document.body.appendChild(button);
复杂自定义元素
// 复杂的卡片组件
class MyCard extends HTMLElement {
constructor() {
super();
// 创建Shadow DOM
this.attachShadow({ mode: 'open' });
// 内部状态
this.state = {
expanded: false,
loading: false
};
}
connectedCallback() {
this.render();
this.attachEventListeners();
}
static get observedAttributes() {
return ['title', 'subtitle', 'image', 'expandable', 'loading'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (this.shadowRoot) {
this.updateContent(name, newValue);
}
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
transition: box-shadow 0.3s ease;
}
:host(:hover) {
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.card-header {
padding: 16px;
background: #f8f9fa;
border-bottom: 1px solid #ddd;
display: flex;
align-items: center;
justify-content: space-between;
}
.card-image {
width: 100%;
height: 200px;
object-fit: cover;
display: block;
}
.card-content {
padding: 16px;
}
.card-title {
margin: 0 0 8px 0;
font-size: 1.25rem;
font-weight: bold;
}
.card-subtitle {
margin: 0 0 16px 0;
color: #666;
font-size: 0.9rem;
}
.card-body {
line-height: 1.5;
}
.card-footer {
padding: 16px;
background: #f8f9fa;
border-top: 1px solid #ddd;
}
.expand-button {
background: none;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.expand-button:hover {
background-color: rgba(0,0,0,0.1);
}
.expand-icon {
width: 16px;
height: 16px;
transition: transform 0.3s ease;
}
.expand-icon.expanded {
transform: rotate(180deg);
}
.expandable-content {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.expandable-content.expanded {
max-height: 500px;
}
.loading-spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid #f3f3f3;
border-top: 2px solid #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.hidden {
display: none;
}
</style>
<div class="card-header">
<div>
<h3 class="card-title">${this.getAttribute('title') || ''}</h3>
<p class="card-subtitle">${this.getAttribute('subtitle') || ''}</p>
</div>
${this.hasAttribute('expandable') ? `
<button class="expand-button" id="expandButton">
<svg class="expand-icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M7 10l5 5 5-5z"/>
</svg>
</button>
` : ''}
<div class="loading-spinner ${this.hasAttribute('loading') ? '' : 'hidden'}" id="loadingSpinner"></div>
</div>
${this.getAttribute('image') ? `
<img class="card-image" src="${this.getAttribute('image')}" alt="${this.getAttribute('title') || ''}" />
` : ''}
<div class="card-content">
<div class="card-body">
<slot name="content"></slot>
</div>
<div class="expandable-content" id="expandableContent">
<slot name="expandable"></slot>
</div>
</div>
<div class="card-footer">
<slot name="footer"></slot>
</div>
`;
}
attachEventListeners() {
const expandButton = this.shadowRoot.getElementById('expandButton');
if (expandButton) {
expandButton.addEventListener('click', this.toggleExpanded.bind(this));
}
}
updateContent(attributeName, value) {
switch (attributeName) {
case 'title':
const titleElement = this.shadowRoot.querySelector('.card-title');
if (titleElement) titleElement.textContent = value || '';
break;
case 'subtitle':
const subtitleElement = this.shadowRoot.querySelector('.card-subtitle');
if (subtitleElement) subtitleElement.textContent = value || '';
break;
case 'loading':
const spinner = this.shadowRoot.getElementById('loadingSpinner');
if (spinner) {
spinner.classList.toggle('hidden', !this.hasAttribute('loading'));
}
break;
}
}
toggleExpanded() {
this.state.expanded = !this.state.expanded;
const expandableContent = this.shadowRoot.getElementById('expandableContent');
const expandIcon = this.shadowRoot.querySelector('.expand-icon');
if (expandableContent) {
expandableContent.classList.toggle('expanded', this.state.expanded);
}
if (expandIcon) {
expandIcon.classList.toggle('expanded', this.state.expanded);
}
// 触发自定义事件
this.dispatchEvent(new CustomEvent('card-toggle', {
detail: { expanded: this.state.expanded },
bubbles: true,
composed: true
}));
}
// 公共API
expand() {
if (!this.state.expanded) {
this.toggleExpanded();
}
}
collapse() {
if (this.state.expanded) {
this.toggleExpanded();
}
}
setLoading(loading) {
if (loading) {
this.setAttribute('loading', '');
} else {
this.removeAttribute('loading');
}
}
}
customElements.define('my-card', MyCard);
// 使用示例
/*
<my-card title="Card Title" subtitle="Card Subtitle" expandable>
<div slot="content">
This is the main content of the card.
</div>
<div slot="expandable">
This content is only visible when expanded.
</div>
<div slot="footer">
<button>Action</button>
</div>
</my-card>
*/
Shadow DOM深度应用
Shadow DOM封装
// 高级Shadow DOM组件
class MyModal extends HTMLElement {
constructor() {
super();
// 创建封闭的Shadow DOM
this.attachShadow({ mode: 'closed' });
this.isOpen = false;
this.focusableElements = [];
this.previousFocus = null;
}
connectedCallback() {
this.render();
this.attachEventListeners();
this.setupAccessibility();
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1000;
display: none;
}
:host([open]) {
display: flex;
align-items: center;
justify-content: center;
}
.modal-backdrop {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(2px);
}
.modal-container {
position: relative;
background: white;
border-radius: 8px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
max-width: 90vw;
max-height: 90vh;
overflow: hidden;
transform: scale(0.9);
opacity: 0;
transition: all 0.3s ease;
}
:host([open]) .modal-container {
transform: scale(1);
opacity: 1;
}
.modal-header {
padding: 20px;
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.modal-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.modal-close:hover {
background-color: #f5f5f5;
}
.modal-body {
padding: 20px;
max-height: 60vh;
overflow-y: auto;
}
.modal-footer {
padding: 20px;
border-top: 1px solid #eee;
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* 动画 */
@keyframes modalFadeIn {
from {
opacity: 0;
transform: scale(0.9) translateY(-50px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes modalFadeOut {
from {
opacity: 1;
transform: scale(1) translateY(0);
}
to {
opacity: 0;
transform: scale(0.9) translateY(-50px);
}
}
.modal-container.entering {
animation: modalFadeIn 0.3s ease forwards;
}
.modal-container.leaving {
animation: modalFadeOut 0.3s ease forwards;
}
</style>
<div class="modal-backdrop" id="backdrop"></div>
<div class="modal-container" id="container" role="dialog" aria-modal="true">
<div class="modal-header">
<h2 class="modal-title" id="modalTitle">
<slot name="title">Modal Title</slot>
</h2>
<button class="modal-close" id="closeButton" aria-label="Close modal">
×
</button>
</div>
<div class="modal-body">
<slot></slot>
</div>
<div class="modal-footer">
<slot name="footer"></slot>
</div>
</div>
`;
}
attachEventListeners() {
// 关闭按钮
const closeButton = this.shadowRoot.getElementById('closeButton');
closeButton.addEventListener('click', () => this.close());
// 背景点击关闭
const backdrop = this.shadowRoot.getElementById('backdrop');
backdrop.addEventListener('click', () => this.close());
// ESC键关闭
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && this.isOpen) {
this.close();
}
});
// 焦点陷阱
document.addEventListener('keydown', (event) => {
if (event.key === 'Tab' && this.isOpen) {
this.handleTabKey(event);
}
});
}
setupAccessibility() {
const container = this.shadowRoot.getElementById('container');
const title = this.shadowRoot.getElementById('modalTitle');
// 设置ARIA属性
container.setAttribute('aria-labelledby', 'modalTitle');
// 获取所有可聚焦元素
this.updateFocusableElements();
}
updateFocusableElements() {
const focusableSelectors = [
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'a[href]',
'[tabindex]:not([tabindex="-1"])'
];
// Shadow DOM内的可聚焦元素
const shadowFocusable = Array.from(
this.shadowRoot.querySelectorAll(focusableSelectors.join(','))
);
// Light DOM内的可聚焦元素
const lightFocusable = Array.from(
this.querySelectorAll(focusableSelectors.join(','))
);
this.focusableElements = [...shadowFocusable, ...lightFocusable];
}
handleTabKey(event) {
if (this.focusableElements.length === 0) return;
const firstElement = this.focusableElements[0];
const lastElement = this.focusableElements[this.focusableElements.length - 1];
if (event.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
}
} else {
// Tab
if (document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
}
open() {
if (this.isOpen) return;
this.isOpen = true;
this.previousFocus = document.activeElement;
// 显示模态框
this.setAttribute('open', '');
// 更新可聚焦元素
this.updateFocusableElements();
// 聚焦到第一个可聚焦元素
setTimeout(() => {
if (this.focusableElements.length > 0) {
this.focusableElements[0].focus();
}
}, 100);
// 禁用页面滚动
document.body.style.overflow = 'hidden';
// 触发事件
this.dispatchEvent(new CustomEvent('modal-open', {
bubbles: true,
composed: true
}));
}
close() {
if (!this.isOpen) return;
this.isOpen = false;
// 添加关闭动画
const container = this.shadowRoot.getElementById('container');
container.classList.add('leaving');
setTimeout(() => {
this.removeAttribute('open');
container.classList.remove('leaving');
// 恢复之前的焦点
if (this.previousFocus) {
this.previousFocus.focus();
}
// 恢复页面滚动
document.body.style.overflow = '';
// 触发事件
this.dispatchEvent(new CustomEvent('modal-close', {
bubbles: true,
composed: true
}));
}, 300);
}
// 公共API
toggle() {
if (this.isOpen) {
this.close();
} else {
this.open();
}
}
}
customElements.define('my-modal', MyModal);
// 使用示例
/*
<my-modal id="exampleModal">
<span slot="title">Confirmation</span>
<p>Are you sure you want to delete this item?</p>
<div slot="footer">
<button onclick="document.getElementById('exampleModal').close()">Cancel</button>
<button onclick="handleDelete()">Delete</button>
</div>
</my-modal>
*/
CSS自定义属性与主题
// 支持主题的组件
class ThemedButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
/* 定义CSS自定义属性的默认值 */
--button-bg: var(--primary-color, #007bff);
--button-color: var(--primary-text-color, white);
--button-border: var(--primary-border, 1px solid #007bff);
--button-border-radius: var(--border-radius, 4px);
--button-padding: var(--button-padding-default, 8px 16px);
--button-font-size: var(--font-size-base, 14px);
--button-font-weight: var(--font-weight-medium, 500);
--button-transition: var(--transition-base, all 0.2s ease);
/* 悬停状态变量 */
--button-hover-bg: var(--primary-hover-color, #0056b3);
--button-hover-transform: var(--button-hover-transform-default, translateY(-1px));
/* 禁用状态变量 */
--button-disabled-opacity: var(--disabled-opacity, 0.6);
display: inline-block;
}
button {
background: var(--button-bg);
color: var(--button-color);
border: var(--button-border);
border-radius: var(--button-border-radius);
padding: var(--button-padding);
font-size: var(--button-font-size);
font-weight: var(--button-font-weight);
font-family: inherit;
cursor: pointer;
transition: var(--button-transition);
outline: none;
width: 100%;
}
button:hover:not(:disabled) {
background: var(--button-hover-bg);
transform: var(--button-hover-transform);
box-shadow: var(--button-hover-shadow, 0 2px 4px rgba(0,0,0,0.1));
}
button:focus {
box-shadow: var(--button-focus-shadow, 0 0 0 3px rgba(0, 123, 255, 0.25));
}
button:disabled {
opacity: var(--button-disabled-opacity);
cursor: not-allowed;
transform: none;
}
/* 变体样式 */
:host([variant="secondary"]) {
--button-bg: var(--secondary-color, #6c757d);
--button-hover-bg: var(--secondary-hover-color, #545b62);
}
:host([variant="success"]) {
--button-bg: var(--success-color, #28a745);
--button-hover-bg: var(--success-hover-color, #218838);
}
:host([variant="danger"]) {
--button-bg: var(--danger-color, #dc3545);
--button-hover-bg: var(--danger-hover-color, #c82333);
}
:host([variant="outline"]) {
--button-bg: transparent;
--button-color: var(--primary-color, #007bff);
--button-border: 1px solid var(--primary-color, #007bff);
--button-hover-bg: var(--primary-color, #007bff);
--button-hover-color: white;
}
:host([variant="outline"]) button:hover:not(:disabled) {
color: var(--button-hover-color);
}
/* 尺寸变体 */
:host([size="small"]) {
--button-padding: var(--button-padding-small, 4px 8px);
--button-font-size: var(--font-size-small, 12px);
}
:host([size="large"]) {
--button-padding: var(--button-padding-large, 12px 24px);
--button-font-size: var(--font-size-large, 16px);
}
/* 响应式设计 */
@media (max-width: 768px) {
:host {
--button-padding: var(--button-padding-mobile, 10px 16px);
--button-font-size: var(--font-size-mobile, 16px);
}
}
/* 深色主题支持 */
@media (prefers-color-scheme: dark) {
:host {
--button-bg: var(--primary-color-dark, #0d6efd);
--button-hover-bg: var(--primary-hover-color-dark, #0b5ed7);
}
}
</style>
<button>
<slot></slot>
</button>
`;
}
}
customElements.define('themed-button', ThemedButton);
// 主题管理器
class ThemeManager {
constructor() {
this.themes = {
light: {
'--primary-color': '#007bff',
'--primary-hover-color': '#0056b3',
'--secondary-color': '#6c757d',
'--success-color': '#28a745',
'--danger-color': '#dc3545',
'--background-color': '#ffffff',
'--text-color': '#333333',
'--border-radius': '4px',
'--font-size-base': '14px',
'--transition-base': 'all 0.2s ease'
},
dark: {
'--primary-color': '#0d6efd',
'--primary-hover-color': '#0b5ed7',
'--secondary-color': '#6c757d',
'--success-color': '#198754',
'--danger-color': '#dc3545',
'--background-color': '#1a1a1a',
'--text-color': '#ffffff',
'--border-radius': '4px',
'--font-size-base': '14px',
'--transition-base': 'all 0.2s ease'
},
custom: {
'--primary-color': '#8b5cf6',
'--primary-hover-color': '#7c3aed',
'--secondary-color': '#64748b',
'--success-color': '#10b981',
'--danger-color': '#ef4444',
'--background-color': '#f8fafc',
'--text-color': '#1e293b',
'--border-radius': '8px',
'--font-size-base': '16px',
'--transition-base': 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)'
}
};
this.currentTheme = 'light';
}
applyTheme(themeName) {
const theme = this.themes[themeName];
if (!theme) {
console.warn(`Theme "${themeName}" not found`);
return;
}
const root = document.documentElement;
// 移除之前的主题类
root.classList.remove(`theme-${this.currentTheme}`);
// 应用新主题的CSS变量
Object.entries(theme).forEach(([property, value]) => {
root.style.setProperty(property, value);
});
// 添加新主题类
root.classList.add(`theme-${themeName}`);
this.currentTheme = themeName;
// 触发主题变更事件
document.dispatchEvent(new CustomEvent('theme-changed', {
detail: { theme: themeName }
}));
}
getTheme(themeName) {
return this.themes[themeName];
}
addTheme(name, theme) {
this.themes[name] = theme;
}
getCurrentTheme() {
return this.currentTheme;
}
}
// 全局主题管理器
const themeManager = new ThemeManager();
// 主题切换器组件
class ThemeSwitcher extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
this.attachEventListeners();
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: inline-block;
}
select {
padding: 8px 12px;
border: 1px solid var(--border-color, #ddd);
border-radius: var(--border-radius, 4px);
background: var(--background-color, white);
color: var(--text-color, #333);
font-family: inherit;
font-size: var(--font-size-base, 14px);
cursor: pointer;
}
select:focus {
outline: none;
border-color: var(--primary-color, #007bff);
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
</style>
<select id="themeSelect">
<option value="light">Light Theme</option>
<option value="dark">Dark Theme</option>
<option value="custom">Custom Theme</option>
</select>
`;
}
attachEventListeners() {
const select = this.shadowRoot.getElementById('themeSelect');
select.addEventListener('change', (event) => {
themeManager.applyTheme(event.target.value);
});
// 监听主题变更事件
document.addEventListener('theme-changed', (event) => {
select.value = event.detail.theme;
});
}
}
customElements.define('theme-switcher', ThemeSwitcher);
// 使用示例
/*
<style>
:root {
--primary-color: #007bff;
--primary-hover-color: #0056b3;
}
</style>
<theme-switcher></theme-switcher>
<themed-button variant="primary" size="large">
Primary Button
</themed-button>
<themed-button variant="outline" size="small">
Outline Button
</themed-button>
*/
HTML Templates与Slots
模板系统
// 高级模板组件
class DataTable extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.data = [];
this.columns = [];
this.sortColumn = null;
this.sortDirection = 'asc';
this.currentPage = 1;
this.pageSize = 10;
}
connectedCallback() {
this.parseConfiguration();
this.render();
this.attachEventListeners();
}
static get observedAttributes() {
return ['data', 'columns', 'page-size'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'data' && newValue) {
try {
this.data = JSON.parse(newValue);
this.renderTable();
} catch (e) {
console.error('Invalid data format:', e);
}
}
}
parseConfiguration() {
// 从slot中解析列配置
const columnSlots = this.querySelectorAll('data-column');
this.columns = Array.from(columnSlots).map(slot => ({
key: slot.getAttribute('key'),
title: slot.getAttribute('title') || slot.getAttribute('key'),
sortable: slot.hasAttribute('sortable'),
width: slot.getAttribute('width'),
align: slot.getAttribute('align') || 'left',
formatter: slot.getAttribute('formatter'),
template: slot.innerHTML.trim()
}));
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
font-family: var(--font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
}
.table-container {
overflow-x: auto;
border: 1px solid var(--border-color, #e1e5e9);
border-radius: var(--border-radius, 4px);
}
table {
width: 100%;
border-collapse: collapse;
background: var(--table-bg, white);
}
th, td {
padding: var(--cell-padding, 12px);
text-align: left;
border-bottom: 1px solid var(--border-color, #e1e5e9);
}
th {
background: var(--header-bg, #f8f9fa);
font-weight: var(--header-font-weight, 600);
color: var(--header-color, #495057);
position: sticky;
top: 0;
z-index: 1;
}
th.sortable {
cursor: pointer;
user-select: none;
position: relative;
}
th.sortable:hover {
background: var(--header-hover-bg, #e9ecef);
}
.sort-indicator {
display: inline-block;
margin-left: 4px;
opacity: 0.5;
transition: opacity 0.2s ease;
}
th.sorted .sort-indicator {
opacity: 1;
}
tr:hover td {
background: var(--row-hover-bg, #f8f9fa);
}
.pagination {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--pagination-padding, 16px);
background: var(--pagination-bg, #f8f9fa);
border-top: 1px solid var(--border-color, #e1e5e9);
}
.pagination-info {
color: var(--text-muted, #6c757d);
font-size: var(--font-size-small, 14px);
}
.pagination-controls {
display: flex;
gap: 8px;
}
.pagination-button {
padding: 6px 12px;
border: 1px solid var(--border-color, #e1e5e9);
background: var(--button-bg, white);
color: var(--button-color, #495057);
cursor: pointer;
border-radius: var(--border-radius-small, 3px);
font-size: var(--font-size-small, 14px);
transition: all 0.2s ease;
}
.pagination-button:hover:not(:disabled) {
background: var(--button-hover-bg, #e9ecef);
}
.pagination-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination-button.active {
background: var(--primary-color, #007bff);
color: white;
border-color: var(--primary-color, #007bff);
}
.empty-state {
text-align: center;
padding: var(--empty-state-padding, 40px 20px);
color: var(--text-muted, #6c757d);
}
.loading {
text-align: center;
padding: var(--loading-padding, 40px 20px);
}
.loading-spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid #f3f3f3;
border-top: 2px solid var(--primary-color, #007bff);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
<div class="table-container">
<table id="dataTable">
<thead id="tableHead"></thead>
<tbody id="tableBody"></tbody>
</table>
</div>
<div class="pagination" id="pagination"></div>
`;
this.renderTable();
}
renderTable() {
this.renderHeader();
this.renderBody();
this.renderPagination();
}
renderHeader() {
const thead = this.shadowRoot.getElementById('tableHead');
const headerRow = document.createElement('tr');
this.columns.forEach(column => {
const th = document.createElement('th');
th.textContent = column.title;
if (column.width) {
th.style.width = column.width;
}
if (column.align) {
th.style.textAlign = column.align;
}
if (column.sortable) {
th.classList.add('sortable');
th.addEventListener('click', () => this.handleSort(column.key));
const indicator = document.createElement('span');
indicator.className = 'sort-indicator';
indicator.innerHTML = this.sortColumn === column.key
? (this.sortDirection === 'asc' ? '↑' : '↓')
: '↕';
if (this.sortColumn === column.key) {
th.classList.add('sorted');
}
th.appendChild(indicator);
}
headerRow.appendChild(th);
});
thead.innerHTML = '';
thead.appendChild(headerRow);
}
renderBody() {
const tbody = this.shadowRoot.getElementById('tableBody');
tbody.innerHTML = '';
if (this.data.length === 0) {
const emptyRow = document.createElement('tr');
const emptyCell = document.createElement('td');
emptyCell.colSpan = this.columns.length;
emptyCell.className = 'empty-state';
emptyCell.innerHTML = '<slot name="empty">No data available</slot>';
emptyRow.appendChild(emptyCell);
tbody.appendChild(emptyRow);
return;
}
const sortedData = this.getSortedData();
const paginatedData = this.getPaginatedData(sortedData);
paginatedData.forEach((row, index) => {
const tr = document.createElement('tr');
this.columns.forEach(column => {
const td = document.createElement('td');
if (column.align) {
td.style.textAlign = column.align;
}
const value = this.getCellValue(row, column.key);
if (column.template) {
// 使用模板渲染
td.innerHTML = this.renderTemplate(column.template, row, value);
} else if (column.formatter) {
// 使用格式化函数
td.textContent = this.formatValue(value, column.formatter);
} else {
td.textContent = value;
}
tr.appendChild(td);
});
tbody.appendChild(tr);
});
}
renderPagination() {
const pagination = this.shadowRoot.getElementById('pagination');
const totalPages = Math.ceil(this.data.length / this.pageSize);
if (totalPages <= 1) {
pagination.style.display = 'none';
return;
}
pagination.style.display = 'flex';
const start = (this.currentPage - 1) * this.pageSize + 1;
const end = Math.min(this.currentPage * this.pageSize, this.data.length);
pagination.innerHTML = `
<div class="pagination-info">
Showing ${start}-${end} of ${this.data.length} entries
</div>
<div class="pagination-controls" id="paginationControls"></div>
`;
const controls = pagination.querySelector('#paginationControls');
// Previous button
const prevButton = document.createElement('button');
prevButton.className = 'pagination-button';
prevButton.textContent = 'Previous';
prevButton.disabled = this.currentPage === 1;
prevButton.addEventListener('click', () => this.goToPage(this.currentPage - 1));
controls.appendChild(prevButton);
// Page numbers
const startPage = Math.max(1, this.currentPage - 2);
const endPage = Math.min(totalPages, this.currentPage + 2);
for (let i = startPage; i <= endPage; i++) {
const pageButton = document.createElement('button');
pageButton.className = 'pagination-button';
pageButton.textContent = i;
if (i === this.currentPage) {
pageButton.classList.add('active');
}
pageButton.addEventListener('click', () => this.goToPage(i));
controls.appendChild(pageButton);
}
// Next button
const nextButton = document.createElement('button');
nextButton.className = 'pagination-button';
nextButton.textContent = 'Next';
nextButton.disabled = this.currentPage === totalPages;
nextButton.addEventListener('click', () => this.goToPage(this.currentPage + 1));
controls.appendChild(nextButton);
}
getCellValue(row, key) {
return key.split('.').reduce((obj, k) => obj && obj[k], row);
}
renderTemplate(template, row, value) {
return template
.replace(/\{\{(\w+(?:\.\w+)*)\}\}/g, (match, key) => {
return this.getCellValue(row, key) || '';
})
.replace(/\{\{value\}\}/g, value || '');
}
formatValue(value, formatter) {
switch (formatter) {
case 'currency':
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(value);
case 'date':
return new Date(value).toLocaleDateString();
case 'datetime':
return new Date(value).toLocaleString();
default:
return value;
}
}
getSortedData() {
if (!this.sortColumn) return this.data;
return [...this.data].sort((a, b) => {
const aVal = this.getCellValue(a, this.sortColumn);
const bVal = this.getCellValue(b, this.sortColumn);
let comparison = 0;
if (aVal > bVal) comparison = 1;
if (aVal < bVal) comparison = -1;
return this.sortDirection === 'desc' ? -comparison : comparison;
});
}
getPaginatedData(data) {
const start = (this.currentPage - 1) * this.pageSize;
const end = start + this.pageSize;
return data.slice(start, end);
}
handleSort(columnKey) {
if (this.sortColumn === columnKey) {
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
this.sortColumn = columnKey;
this.sortDirection = 'asc';
}
this.renderTable();
this.dispatchEvent(new CustomEvent('sort-changed', {
detail: { column: this.sortColumn, direction: this.sortDirection },
bubbles: true,
composed: true
}));
}
goToPage(page) {
const totalPages = Math.ceil(this.data.length / this.pageSize);
if (page < 1 || page > totalPages) return;
this.currentPage = page;
this.renderTable();
this.dispatchEvent(new CustomEvent('page-changed', {
detail: { page: this.currentPage },
bubbles: true,
composed: true
}));
}
attachEventListeners() {
// 监听数据变化
this.addEventListener('data-updated', () => {
this.renderTable();
});
}
// 公共API
setData(data) {
this.data = data;
this.currentPage = 1;
this.renderTable();
}
addRow(row) {
this.data.push(row);
this.renderTable();
}
removeRow(index) {
this.data.splice(index, 1);
this.renderTable();
}
updateRow(index, row) {
this.data[index] = { ...this.data[index], ...row };
this.renderTable();
}
}
customElements.define('data-table', DataTable);
// 使用示例
/*
<data-table page-size="5">
<data-column key="id" title="ID" sortable width="80px"></data-column>
<data-column key="name" title="Name" sortable></data-column>
<data-column key="email" title="Email"></data-column>
<data-column key="status" title="Status" template='<span class="status-{{status}}">{{status}}</span>'></data-column>
<data-column key="createdAt" title="Created" formatter="date" sortable></data-column>
<data-column key="actions" title="Actions" template='<button onclick="editUser({{id}})">Edit</button>'></data-column>
<div slot="empty">
<p>No users found.</p>
<button onclick="loadUsers()">Load Users</button>
</div>
</data-table>
<script>
const table = document.querySelector('data-table');
table.setData([
{ id: 1, name: 'John Doe', email: 'john@example.com', status: 'active', createdAt: '2023-01-15' },
{ id: 2, name: 'Jane Smith', email: 'jane@example.com', status: 'inactive', createdAt: '2023-02-20' }
]);
</script>
*/
总结
Web Components的核心要点:
- Custom Elements:定义可复用的自定义HTML元素
- Shadow DOM:提供样式和DOM封装,避免全局污染
- HTML Templates:声明式模板和内容分发机制
- 生命周期:connectedCallback、disconnectedCallback等钩子
- 最佳实践:可访问性、主题支持、事件处理
Web Components为我们提供了原生的组件化解决方案,能够创建真正可复用、框架无关的UI组件,是现代Web开发的重要技术。