Skip to content

15.1 Web Components

关键词: Web Components, Custom Elements, Shadow DOM, HTML Templates, HTML Imports, 组件化开发, 封装性, 可复用性

学习目标

  • 理解Web Components的核心概念和技术栈
  • 掌握Custom Elements的创建和使用方法
  • 学会Shadow DOM的封装和样式隔离技术
  • 了解HTML Templates和HTML Imports的应用
  • 掌握Web Components的实际开发技巧和最佳实践

15.1.1 Web Components概述

什么是Web Components

Web Components是一套不同的技术,允许开发者创建可重用的自定义元素,并且在Web应用中使用它们。这些技术提供了一种标准化的方式来创建封装的、可复用的组件。

html
<!-- Web Components基础示例 -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>Web Components入门</title>
</head>
<body>
    <h1>Web Components示例</h1>
    
    <!-- 使用自定义元素 -->
    <my-button type="primary" size="large">点击我</my-button>
    <my-card title="卡片标题">
        <p>这是卡片的内容</p>
    </my-card>
    
    <script>
        // 简单的自定义元素示例
        class MyButton extends HTMLElement {
            constructor() {
                super();
                this.render();
            }
            
            render() {
                const type = this.getAttribute('type') || 'default';
                const size = this.getAttribute('size') || 'medium';
                
                this.innerHTML = `
                    <style>
                        .btn {
                            padding: 8px 16px;
                            border: none;
                            border-radius: 4px;
                            cursor: pointer;
                            font-size: 14px;
                            transition: all 0.3s ease;
                        }
                        
                        .btn.primary {
                            background-color: #007bff;
                            color: white;
                        }
                        
                        .btn.primary:hover {
                            background-color: #0056b3;
                        }
                        
                        .btn.large {
                            padding: 12px 24px;
                            font-size: 16px;
                        }
                    </style>
                    <button class="btn ${type} ${size}">
                        <slot></slot>
                    </button>
                `;
            }
        }
        
        // 注册自定义元素
        customElements.define('my-button', MyButton);
        
        // 卡片组件示例
        class MyCard extends HTMLElement {
            constructor() {
                super();
                this.render();
            }
            
            render() {
                const title = this.getAttribute('title') || '默认标题';
                
                this.innerHTML = `
                    <style>
                        .card {
                            border: 1px solid #ddd;
                            border-radius: 8px;
                            padding: 16px;
                            margin: 16px 0;
                            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
                        }
                        
                        .card-title {
                            font-size: 18px;
                            font-weight: bold;
                            margin-bottom: 12px;
                            color: #333;
                        }
                        
                        .card-content {
                            color: #666;
                            line-height: 1.5;
                        }
                    </style>
                    <div class="card">
                        <div class="card-title">${title}</div>
                        <div class="card-content">
                            <slot></slot>
                        </div>
                    </div>
                `;
            }
        }
        
        customElements.define('my-card', MyCard);
    </script>
</body>
</html>

Web Components的核心技术

Web Components由四项主要技术组成:

  1. Custom Elements:定义新的HTML元素
  2. Shadow DOM:封装样式和标记
  3. HTML Templates:可重用的HTML模板
  4. HTML Imports:导入和重用HTML文档

15.1.2 Custom Elements详解

定义Custom Elements

Custom Elements允许开发者定义全新的HTML元素类型。

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>Custom Elements详解</title>
</head>
<body>
    <h1>Custom Elements示例</h1>
    
    <!-- 使用自定义元素 -->
    <user-profile 
        name="张三" 
        email="zhangsan@example.com" 
        avatar="https://via.placeholder.com/100">
    </user-profile>
    
    <todo-list></todo-list>
    
    <script>
        // 用户资料组件
        class UserProfile extends HTMLElement {
            constructor() {
                super();
                this.attachShadow({ mode: 'open' });
                this.render();
            }
            
            // 观察的属性
            static get observedAttributes() {
                return ['name', 'email', 'avatar'];
            }
            
            // 属性变化时调用
            attributeChangedCallback(name, oldValue, newValue) {
                if (oldValue !== newValue) {
                    this.render();
                }
            }
            
            // 元素插入DOM时调用
            connectedCallback() {
                console.log('用户资料组件已连接到DOM');
                this.addEventListener('click', this.handleClick);
            }
            
            // 元素从DOM移除时调用
            disconnectedCallback() {
                console.log('用户资料组件已从DOM移除');
                this.removeEventListener('click', this.handleClick);
            }
            
            handleClick() {
                const name = this.getAttribute('name');
                alert(`点击了${name}的资料`);
            }
            
            render() {
                const name = this.getAttribute('name') || '未知用户';
                const email = this.getAttribute('email') || '未知邮箱';
                const avatar = this.getAttribute('avatar') || 'https://via.placeholder.com/100';
                
                this.shadowRoot.innerHTML = `
                    <style>
                        :host {
                            display: block;
                            border: 1px solid #ccc;
                            border-radius: 8px;
                            padding: 16px;
                            margin: 16px 0;
                            cursor: pointer;
                            transition: box-shadow 0.3s ease;
                        }
                        
                        :host(:hover) {
                            box-shadow: 0 4px 8px rgba(0,0,0,0.1);
                        }
                        
                        .profile {
                            display: flex;
                            align-items: center;
                            gap: 16px;
                        }
                        
                        .avatar {
                            width: 60px;
                            height: 60px;
                            border-radius: 50%;
                            object-fit: cover;
                        }
                        
                        .info h3 {
                            margin: 0 0 8px 0;
                            color: #333;
                        }
                        
                        .info p {
                            margin: 0;
                            color: #666;
                            font-size: 14px;
                        }
                    </style>
                    <div class="profile">
                        <img src="${avatar}" alt="${name}" class="avatar">
                        <div class="info">
                            <h3>${name}</h3>
                            <p>${email}</p>
                        </div>
                    </div>
                `;
            }
        }
        
        // 待办事项列表组件
        class TodoList extends HTMLElement {
            constructor() {
                super();
                this.attachShadow({ mode: 'open' });
                this.todos = [];
                this.render();
                this.bindEvents();
            }
            
            connectedCallback() {
                // 从本地存储加载数据
                const savedTodos = localStorage.getItem('todos');
                if (savedTodos) {
                    this.todos = JSON.parse(savedTodos);
                    this.render();
                }
            }
            
            bindEvents() {
                this.shadowRoot.addEventListener('click', (e) => {
                    if (e.target.classList.contains('add-btn')) {
                        this.addTodo();
                    } else if (e.target.classList.contains('delete-btn')) {
                        const index = parseInt(e.target.dataset.index);
                        this.deleteTodo(index);
                    } else if (e.target.classList.contains('todo-checkbox')) {
                        const index = parseInt(e.target.dataset.index);
                        this.toggleTodo(index);
                    }
                });
                
                this.shadowRoot.addEventListener('keypress', (e) => {
                    if (e.key === 'Enter' && e.target.classList.contains('todo-input')) {
                        this.addTodo();
                    }
                });
            }
            
            addTodo() {
                const input = this.shadowRoot.querySelector('.todo-input');
                const text = input.value.trim();
                
                if (text) {
                    this.todos.push({
                        id: Date.now(),
                        text: text,
                        completed: false
                    });
                    
                    input.value = '';
                    this.saveTodos();
                    this.render();
                }
            }
            
            deleteTodo(index) {
                this.todos.splice(index, 1);
                this.saveTodos();
                this.render();
            }
            
            toggleTodo(index) {
                this.todos[index].completed = !this.todos[index].completed;
                this.saveTodos();
                this.render();
            }
            
            saveTodos() {
                localStorage.setItem('todos', JSON.stringify(this.todos));
            }
            
            render() {
                this.shadowRoot.innerHTML = `
                    <style>
                        :host {
                            display: block;
                            max-width: 400px;
                            margin: 16px 0;
                            border: 1px solid #ccc;
                            border-radius: 8px;
                            padding: 16px;
                        }
                        
                        .header {
                            display: flex;
                            gap: 8px;
                            margin-bottom: 16px;
                        }
                        
                        .todo-input {
                            flex: 1;
                            padding: 8px;
                            border: 1px solid #ccc;
                            border-radius: 4px;
                        }
                        
                        .add-btn {
                            padding: 8px 16px;
                            background-color: #007bff;
                            color: white;
                            border: none;
                            border-radius: 4px;
                            cursor: pointer;
                        }
                        
                        .add-btn:hover {
                            background-color: #0056b3;
                        }
                        
                        .todo-item {
                            display: flex;
                            align-items: center;
                            gap: 8px;
                            padding: 8px 0;
                            border-bottom: 1px solid #eee;
                        }
                        
                        .todo-item:last-child {
                            border-bottom: none;
                        }
                        
                        .todo-checkbox {
                            cursor: pointer;
                        }
                        
                        .todo-text {
                            flex: 1;
                            transition: all 0.3s ease;
                        }
                        
                        .todo-text.completed {
                            text-decoration: line-through;
                            color: #999;
                        }
                        
                        .delete-btn {
                            padding: 4px 8px;
                            background-color: #dc3545;
                            color: white;
                            border: none;
                            border-radius: 4px;
                            cursor: pointer;
                            font-size: 12px;
                        }
                        
                        .delete-btn:hover {
                            background-color: #c82333;
                        }
                        
                        .empty-state {
                            text-align: center;
                            color: #999;
                            padding: 32px;
                        }
                    </style>
                    
                    <div class="header">
                        <input type="text" class="todo-input" placeholder="输入待办事项...">
                        <button class="add-btn">添加</button>
                    </div>
                    
                    <div class="todo-list">
                        ${this.todos.length === 0 ? 
                            '<div class="empty-state">暂无待办事项</div>' :
                            this.todos.map((todo, index) => `
                                <div class="todo-item">
                                    <input type="checkbox" class="todo-checkbox" data-index="${index}" ${todo.completed ? 'checked' : ''}>
                                    <span class="todo-text ${todo.completed ? 'completed' : ''}">${todo.text}</span>
                                    <button class="delete-btn" data-index="${index}">删除</button>
                                </div>
                            `).join('')
                        }
                    </div>
                `;
            }
        }
        
        // 注册自定义元素
        customElements.define('user-profile', UserProfile);
        customElements.define('todo-list', TodoList);
    </script>
</body>
</html>

15.1.3 Shadow DOM详解

Shadow DOM封装

Shadow DOM提供了一种将DOM和CSS封装在组件内部的方法,避免样式冲突。

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>Shadow DOM详解</title>
    <style>
        /* 全局样式 */
        .button {
            background-color: red;
            color: white;
            padding: 20px;
        }
        
        h3 {
            color: blue;
        }
    </style>
</head>
<body>
    <h1>Shadow DOM封装示例</h1>
    
    <!-- 全局样式影响的元素 -->
    <div class="button">全局样式按钮</div>
    <h3>全局样式标题</h3>
    
    <!-- 使用Shadow DOM的组件 -->
    <modal-dialog title="确认对话框">
        <p>您确定要删除这个项目吗?</p>
    </modal-dialog>
    
    <progress-bar value="75" max="100"></progress-bar>
    
    <script>
        // 模态对话框组件
        class ModalDialog extends HTMLElement {
            constructor() {
                super();
                // 创建Shadow DOM
                this.attachShadow({ mode: 'open' });
                this.render();
                this.bindEvents();
            }
            
            static get observedAttributes() {
                return ['title', 'open'];
            }
            
            attributeChangedCallback(name, oldValue, newValue) {
                if (name === 'title' && oldValue !== newValue) {
                    this.render();
                }
            }
            
            bindEvents() {
                this.shadowRoot.addEventListener('click', (e) => {
                    if (e.target.classList.contains('close-btn') || 
                        e.target.classList.contains('overlay')) {
                        this.close();
                    }
                    
                    if (e.target.classList.contains('confirm-btn')) {
                        this.dispatchEvent(new CustomEvent('confirm', {
                            detail: { confirmed: true }
                        }));
                        this.close();
                    }
                    
                    if (e.target.classList.contains('cancel-btn')) {
                        this.dispatchEvent(new CustomEvent('cancel', {
                            detail: { confirmed: false }
                        }));
                        this.close();
                    }
                });
                
                // 监听ESC键
                document.addEventListener('keydown', (e) => {
                    if (e.key === 'Escape' && this.hasAttribute('open')) {
                        this.close();
                    }
                });
            }
            
            open() {
                this.setAttribute('open', '');
                document.body.style.overflow = 'hidden';
            }
            
            close() {
                this.removeAttribute('open');
                document.body.style.overflow = '';
            }
            
            render() {
                const title = this.getAttribute('title') || '对话框';
                const isOpen = this.hasAttribute('open');
                
                this.shadowRoot.innerHTML = `
                    <style>
                        :host {
                            display: ${isOpen ? 'block' : 'none'};
                            position: fixed;
                            top: 0;
                            left: 0;
                            width: 100%;
                            height: 100%;
                            z-index: 1000;
                        }
                        
                        .overlay {
                            position: absolute;
                            top: 0;
                            left: 0;
                            width: 100%;
                            height: 100%;
                            background-color: rgba(0, 0, 0, 0.5);
                            display: flex;
                            align-items: center;
                            justify-content: center;
                        }
                        
                        .modal {
                            background-color: white;
                            border-radius: 8px;
                            padding: 0;
                            max-width: 500px;
                            width: 90%;
                            max-height: 80vh;
                            overflow: auto;
                            box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
                            animation: modalIn 0.3s ease-out;
                        }
                        
                        @keyframes modalIn {
                            from {
                                opacity: 0;
                                transform: scale(0.8);
                            }
                            to {
                                opacity: 1;
                                transform: scale(1);
                            }
                        }
                        
                        .modal-header {
                            display: flex;
                            justify-content: space-between;
                            align-items: center;
                            padding: 16px 20px;
                            border-bottom: 1px solid #eee;
                        }
                        
                        .modal-title {
                            margin: 0;
                            font-size: 18px;
                            color: #333;
                        }
                        
                        .close-btn {
                            background: none;
                            border: none;
                            font-size: 24px;
                            cursor: pointer;
                            color: #999;
                            padding: 0;
                            width: 30px;
                            height: 30px;
                            display: flex;
                            align-items: center;
                            justify-content: center;
                        }
                        
                        .close-btn:hover {
                            color: #333;
                        }
                        
                        .modal-body {
                            padding: 20px;
                        }
                        
                        .modal-footer {
                            display: flex;
                            justify-content: flex-end;
                            gap: 8px;
                            padding: 16px 20px;
                            border-top: 1px solid #eee;
                        }
                        
                        .btn {
                            padding: 8px 16px;
                            border: none;
                            border-radius: 4px;
                            cursor: pointer;
                            font-size: 14px;
                        }
                        
                        .confirm-btn {
                            background-color: #dc3545;
                            color: white;
                        }
                        
                        .confirm-btn:hover {
                            background-color: #c82333;
                        }
                        
                        .cancel-btn {
                            background-color: #6c757d;
                            color: white;
                        }
                        
                        .cancel-btn:hover {
                            background-color: #5a6268;
                        }
                    </style>
                    
                    <div class="overlay">
                        <div class="modal">
                            <div class="modal-header">
                                <h3 class="modal-title">${title}</h3>
                                <button class="close-btn">&times;</button>
                            </div>
                            <div class="modal-body">
                                <slot></slot>
                            </div>
                            <div class="modal-footer">
                                <button class="btn cancel-btn">取消</button>
                                <button class="btn confirm-btn">确认</button>
                            </div>
                        </div>
                    </div>
                `;
            }
        }
        
        // 进度条组件
        class ProgressBar extends HTMLElement {
            constructor() {
                super();
                this.attachShadow({ mode: 'open' });
                this.render();
            }
            
            static get observedAttributes() {
                return ['value', 'max'];
            }
            
            attributeChangedCallback(name, oldValue, newValue) {
                if (oldValue !== newValue) {
                    this.render();
                }
            }
            
            render() {
                const value = parseFloat(this.getAttribute('value')) || 0;
                const max = parseFloat(this.getAttribute('max')) || 100;
                const percentage = Math.min((value / max) * 100, 100);
                
                this.shadowRoot.innerHTML = `
                    <style>
                        :host {
                            display: block;
                            width: 100%;
                            margin: 16px 0;
                        }
                        
                        .progress-container {
                            width: 100%;
                            height: 20px;
                            background-color: #f0f0f0;
                            border-radius: 10px;
                            overflow: hidden;
                            position: relative;
                        }
                        
                        .progress-bar {
                            height: 100%;
                            background: linear-gradient(45deg, #4CAF50, #45a049);
                            width: ${percentage}%;
                            transition: width 0.3s ease;
                            position: relative;
                        }
                        
                        .progress-text {
                            position: absolute;
                            top: 50%;
                            left: 50%;
                            transform: translate(-50%, -50%);
                            color: #333;
                            font-size: 12px;
                            font-weight: bold;
                        }
                        
                        .progress-info {
                            display: flex;
                            justify-content: space-between;
                            margin-top: 4px;
                            font-size: 12px;
                            color: #666;
                        }
                    </style>
                    
                    <div class="progress-container">
                        <div class="progress-bar"></div>
                        <div class="progress-text">${percentage.toFixed(1)}%</div>
                    </div>
                    <div class="progress-info">
                        <span>进度: ${value}/${max}</span>
                        <span>${percentage.toFixed(1)}%</span>
                    </div>
                `;
            }
        }
        
        // 注册组件
        customElements.define('modal-dialog', ModalDialog);
        customElements.define('progress-bar', ProgressBar);
        
        // 测试模态对话框
        setTimeout(() => {
            const modal = document.querySelector('modal-dialog');
            modal.open();
            
            modal.addEventListener('confirm', (e) => {
                console.log('用户确认了操作');
            });
            
            modal.addEventListener('cancel', (e) => {
                console.log('用户取消了操作');
            });
        }, 2000);
    </script>
</body>
</html>

15.1.4 HTML Templates和Slots

模板和插槽系统

HTML Templates和Slots提供了更灵活的内容组织方式。

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>HTML Templates和Slots</title>
</head>
<body>
    <h1>模板和插槽示例</h1>
    
    <!-- 定义模板 -->
    <template id="article-template">
        <style>
            .article {
                border: 1px solid #ddd;
                border-radius: 8px;
                padding: 16px;
                margin: 16px 0;
            }
            
            .article-header {
                display: flex;
                justify-content: space-between;
                align-items: center;
                margin-bottom: 12px;
                padding-bottom: 8px;
                border-bottom: 1px solid #eee;
            }
            
            .article-title {
                font-size: 20px;
                font-weight: bold;
                color: #333;
                margin: 0;
            }
            
            .article-date {
                color: #666;
                font-size: 14px;
            }
            
            .article-content {
                line-height: 1.6;
                color: #555;
            }
            
            .article-footer {
                margin-top: 16px;
                padding-top: 8px;
                border-top: 1px solid #eee;
            }
            
            .tags {
                display: flex;
                gap: 8px;
                flex-wrap: wrap;
            }
            
            .tag {
                background-color: #f0f0f0;
                color: #666;
                padding: 4px 8px;
                border-radius: 4px;
                font-size: 12px;
            }
        </style>
        
        <article class="article">
            <header class="article-header">
                <h2 class="article-title">
                    <slot name="title">默认标题</slot>
                </h2>
                <time class="article-date">
                    <slot name="date">2024-01-01</slot>
                </time>
            </header>
            
            <div class="article-content">
                <slot>默认内容</slot>
            </div>
            
            <footer class="article-footer">
                <div class="tags">
                    <slot name="tags"></slot>
                </div>
            </footer>
        </article>
    </template>
    
    <!-- 使用带有插槽的组件 -->
    <blog-article>
        <span slot="title">Web Components深入解析</span>
        <span slot="date">2024-01-15</span>
        <p>Web Components是现代Web开发中的重要技术,它允许开发者创建可重用的自定义HTML元素。</p>
        <p>本文将详细介绍Web Components的核心概念和实际应用。</p>
        <span slot="tags" class="tag">Web Components</span>
        <span slot="tags" class="tag">前端开发</span>
        <span slot="tags" class="tag">HTML5</span>
    </blog-article>
    
    <blog-article>
        <span slot="title">Shadow DOM的应用</span>
        <span slot="date">2024-01-10</span>
        <p>Shadow DOM为组件提供了样式和DOM封装,避免了全局样式冲突。</p>
        <span slot="tags" class="tag">Shadow DOM</span>
        <span slot="tags" class="tag">CSS</span>
    </blog-article>
    
    <!-- 复杂的卡片组件 -->
    <feature-card>
        <img slot="icon" src="https://via.placeholder.com/64" alt="图标">
        <h3 slot="title">高性能</h3>
        <p slot="description">使用最新的Web技术,提供卓越的性能体验</p>
        <ul slot="features">
            <li>快速加载</li>
            <li>响应式设计</li>
            <li>SEO友好</li>
        </ul>
        <button slot="action">了解更多</button>
    </feature-card>
    
    <script>
        // 博客文章组件
        class BlogArticle extends HTMLElement {
            constructor() {
                super();
                this.attachShadow({ mode: 'open' });
                
                // 克隆模板
                const template = document.getElementById('article-template');
                const templateContent = template.content;
                
                this.shadowRoot.appendChild(templateContent.cloneNode(true));
            }
        }
        
        // 功能卡片组件
        class FeatureCard extends HTMLElement {
            constructor() {
                super();
                this.attachShadow({ mode: 'open' });
                this.render();
            }
            
            render() {
                this.shadowRoot.innerHTML = `
                    <style>
                        :host {
                            display: block;
                            margin: 16px 0;
                        }
                        
                        .card {
                            border: 1px solid #ddd;
                            border-radius: 12px;
                            padding: 24px;
                            text-align: center;
                            background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
                            transition: transform 0.3s ease, box-shadow 0.3s ease;
                        }
                        
                        .card:hover {
                            transform: translateY(-4px);
                            box-shadow: 0 8px 25px rgba(0,0,0,0.1);
                        }
                        
                        .icon {
                            margin-bottom: 16px;
                        }
                        
                        .icon ::slotted(img) {
                            width: 64px;
                            height: 64px;
                            object-fit: cover;
                        }
                        
                        .title {
                            margin-bottom: 12px;
                        }
                        
                        .title ::slotted(h3) {
                            margin: 0;
                            color: #333;
                            font-size: 24px;
                        }
                        
                        .description {
                            margin-bottom: 16px;
                        }
                        
                        .description ::slotted(p) {
                            margin: 0;
                            color: #666;
                            line-height: 1.6;
                        }
                        
                        .features {
                            margin-bottom: 20px;
                        }
                        
                        .features ::slotted(ul) {
                            list-style: none;
                            padding: 0;
                            margin: 0;
                        }
                        
                        .features ::slotted(li) {
                            padding: 4px 0;
                            color: #555;
                            position: relative;
                        }
                        
                        .features ::slotted(li):before {
                            content: "✓";
                            color: #4CAF50;
                            font-weight: bold;
                            margin-right: 8px;
                        }
                        
                        .action ::slotted(button) {
                            background-color: #007bff;
                            color: white;
                            border: none;
                            padding: 12px 24px;
                            border-radius: 6px;
                            cursor: pointer;
                            font-size: 16px;
                            transition: background-color 0.3s ease;
                        }
                        
                        .action ::slotted(button):hover {
                            background-color: #0056b3;
                        }
                    </style>
                    
                    <div class="card">
                        <div class="icon">
                            <slot name="icon"></slot>
                        </div>
                        
                        <div class="title">
                            <slot name="title"></slot>
                        </div>
                        
                        <div class="description">
                            <slot name="description"></slot>
                        </div>
                        
                        <div class="features">
                            <slot name="features"></slot>
                        </div>
                        
                        <div class="action">
                            <slot name="action"></slot>
                        </div>
                    </div>
                `;
            }
        }
        
        // 注册组件
        customElements.define('blog-article', BlogArticle);
        customElements.define('feature-card', FeatureCard);
    </script>
</body>
</html>

15.1.5 Web Components实践案例

完整的组件库示例

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>Web Components实践案例</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            max-width: 1200px;
            margin: 0 auto;
            padding: 20px;
        }
        
        .demo-section {
            margin: 40px 0;
            padding: 20px;
            border: 1px solid #ddd;
            border-radius: 8px;
        }
    </style>
</head>
<body>
    <h1>Web Components实践案例</h1>
    
    <div class="demo-section">
        <h2>数据表格组件</h2>
        <data-table id="users-table"></data-table>
    </div>
    
    <div class="demo-section">
        <h2>表单组件</h2>
        <form-builder id="contact-form"></form-builder>
    </div>
    
    <div class="demo-section">
        <h2>通知组件</h2>
        <button onclick="showNotification('success', '操作成功!')">成功通知</button>
        <button onclick="showNotification('error', '操作失败!')">错误通知</button>
        <button onclick="showNotification('warning', '警告信息')">警告通知</button>
        <button onclick="showNotification('info', '提示信息')">信息通知</button>
    </div>
    
    <notification-container></notification-container>
    
    <script>
        // 数据表格组件
        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 = 5;
                this.render();
            }
            
            connectedCallback() {
                // 模拟数据加载
                this.loadData();
            }
            
            async loadData() {
                // 模拟API请求
                const response = await fetch('https://jsonplaceholder.typicode.com/users');
                const users = await response.json();
                
                this.data = users.map(user => ({
                    id: user.id,
                    name: user.name,
                    email: user.email,
                    phone: user.phone,
                    website: user.website
                }));
                
                this.columns = [
                    { key: 'id', title: 'ID', sortable: true },
                    { key: 'name', title: '姓名', sortable: true },
                    { key: 'email', title: '邮箱', sortable: true },
                    { key: 'phone', title: '电话', sortable: false },
                    { key: 'website', title: '网站', sortable: false }
                ];
                
                this.render();
            }
            
            sort(column) {
                if (this.sortColumn === column) {
                    this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
                } else {
                    this.sortColumn = column;
                    this.sortDirection = 'asc';
                }
                
                this.data.sort((a, b) => {
                    const aValue = a[column];
                    const bValue = b[column];
                    
                    if (aValue < bValue) return this.sortDirection === 'asc' ? -1 : 1;
                    if (aValue > bValue) return this.sortDirection === 'asc' ? 1 : -1;
                    return 0;
                });
                
                this.render();
            }
            
            changePage(page) {
                this.currentPage = page;
                this.render();
            }
            
            getPaginatedData() {
                const start = (this.currentPage - 1) * this.pageSize;
                const end = start + this.pageSize;
                return this.data.slice(start, end);
            }
            
            getTotalPages() {
                return Math.ceil(this.data.length / this.pageSize);
            }
            
            render() {
                const paginatedData = this.getPaginatedData();
                const totalPages = this.getTotalPages();
                
                this.shadowRoot.innerHTML = `
                    <style>
                        .table-container {
                            border: 1px solid #ddd;
                            border-radius: 8px;
                            overflow: hidden;
                        }
                        
                        .table {
                            width: 100%;
                            border-collapse: collapse;
                        }
                        
                        .table th,
                        .table td {
                            padding: 12px;
                            text-align: left;
                            border-bottom: 1px solid #eee;
                        }
                        
                        .table th {
                            background-color: #f8f9fa;
                            font-weight: bold;
                            cursor: pointer;
                            user-select: none;
                        }
                        
                        .table th:hover {
                            background-color: #e9ecef;
                        }
                        
                        .table th.sortable:after {
                            content: " ⇅";
                            color: #999;
                        }
                        
                        .table th.sorted-asc:after {
                            content: " ↑";
                            color: #007bff;
                        }
                        
                        .table th.sorted-desc:after {
                            content: " ↓";
                            color: #007bff;
                        }
                        
                        .table tbody tr:hover {
                            background-color: #f8f9fa;
                        }
                        
                        .pagination {
                            display: flex;
                            justify-content: center;
                            align-items: center;
                            gap: 8px;
                            padding: 16px;
                            background-color: #f8f9fa;
                        }
                        
                        .pagination button {
                            padding: 8px 12px;
                            border: 1px solid #ddd;
                            background-color: white;
                            cursor: pointer;
                            border-radius: 4px;
                        }
                        
                        .pagination button:hover {
                            background-color: #e9ecef;
                        }
                        
                        .pagination button.active {
                            background-color: #007bff;
                            color: white;
                            border-color: #007bff;
                        }
                        
                        .pagination button:disabled {
                            opacity: 0.5;
                            cursor: not-allowed;
                        }
                    </style>
                    
                    <div class="table-container">
                        <table class="table">
                            <thead>
                                <tr>
                                    ${this.columns.map(col => `
                                        <th class="${col.sortable ? 'sortable' : ''} ${this.sortColumn === col.key ? 'sorted-' + this.sortDirection : ''}"
                                            onclick="this.getRootNode().host.sort('${col.key}')">
                                            ${col.title}
                                        </th>
                                    `).join('')}
                                </tr>
                            </thead>
                            <tbody>
                                ${paginatedData.map(row => `
                                    <tr>
                                        ${this.columns.map(col => `
                                            <td>${row[col.key]}</td>
                                        `).join('')}
                                    </tr>
                                `).join('')}
                            </tbody>
                        </table>
                        
                        <div class="pagination">
                            <button onclick="this.getRootNode().host.changePage(${this.currentPage - 1})"
                                    ${this.currentPage === 1 ? 'disabled' : ''}>
                                上一页
                            </button>
                            
                            ${Array.from({length: totalPages}, (_, i) => i + 1).map(page => `
                                <button class="${page === this.currentPage ? 'active' : ''}"
                                        onclick="this.getRootNode().host.changePage(${page})">
                                    ${page}
                                </button>
                            `).join('')}
                            
                            <button onclick="this.getRootNode().host.changePage(${this.currentPage + 1})"
                                    ${this.currentPage === totalPages ? 'disabled' : ''}>
                                下一页
                            </button>
                        </div>
                    </div>
                `;
            }
        }
        
        // 表单构建器组件
        class FormBuilder extends HTMLElement {
            constructor() {
                super();
                this.attachShadow({ mode: 'open' });
                this.formData = {};
                this.render();
                this.bindEvents();
            }
            
            bindEvents() {
                this.shadowRoot.addEventListener('submit', (e) => {
                    e.preventDefault();
                    this.handleSubmit();
                });
                
                this.shadowRoot.addEventListener('input', (e) => {
                    this.formData[e.target.name] = e.target.value;
                });
            }
            
            handleSubmit() {
                const isValid = this.validateForm();
                if (isValid) {
                    console.log('表单数据:', this.formData);
                    showNotification('success', '表单提交成功!');
                } else {
                    showNotification('error', '请填写所有必填字段!');
                }
            }
            
            validateForm() {
                const requiredFields = ['name', 'email', 'message'];
                return requiredFields.every(field => this.formData[field] && this.formData[field].trim());
            }
            
            render() {
                this.shadowRoot.innerHTML = `
                    <style>
                        .form {
                            max-width: 500px;
                            margin: 0 auto;
                        }
                        
                        .form-group {
                            margin-bottom: 20px;
                        }
                        
                        .form-label {
                            display: block;
                            margin-bottom: 8px;
                            font-weight: bold;
                            color: #333;
                        }
                        
                        .form-input,
                        .form-textarea,
                        .form-select {
                            width: 100%;
                            padding: 12px;
                            border: 1px solid #ddd;
                            border-radius: 6px;
                            font-size: 16px;
                            transition: border-color 0.3s ease;
                        }
                        
                        .form-input:focus,
                        .form-textarea:focus,
                        .form-select:focus {
                            outline: none;
                            border-color: #007bff;
                            box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
                        }
                        
                        .form-textarea {
                            resize: vertical;
                            min-height: 100px;
                        }
                        
                        .form-submit {
                            background-color: #007bff;
                            color: white;
                            border: none;
                            padding: 12px 24px;
                            border-radius: 6px;
                            cursor: pointer;
                            font-size: 16px;
                            transition: background-color 0.3s ease;
                        }
                        
                        .form-submit:hover {
                            background-color: #0056b3;
                        }
                        
                        .required {
                            color: #dc3545;
                        }
                    </style>
                    
                    <form class="form">
                        <div class="form-group">
                            <label class="form-label">
                                姓名 <span class="required">*</span>
                            </label>
                            <input type="text" name="name" class="form-input" required>
                        </div>
                        
                        <div class="form-group">
                            <label class="form-label">
                                邮箱 <span class="required">*</span>
                            </label>
                            <input type="email" name="email" class="form-input" required>
                        </div>
                        
                        <div class="form-group">
                            <label class="form-label">主题</label>
                            <select name="subject" class="form-select">
                                <option value="">请选择主题</option>
                                <option value="general">一般询问</option>
                                <option value="support">技术支持</option>
                                <option value="feedback">意见反馈</option>
                            </select>
                        </div>
                        
                        <div class="form-group">
                            <label class="form-label">
                                消息 <span class="required">*</span>
                            </label>
                            <textarea name="message" class="form-textarea" required></textarea>
                        </div>
                        
                        <button type="submit" class="form-submit">提交表单</button>
                    </form>
                `;
            }
        }
        
        // 通知容器组件
        class NotificationContainer extends HTMLElement {
            constructor() {
                super();
                this.attachShadow({ mode: 'open' });
                this.notifications = [];
                this.render();
            }
            
            addNotification(type, message) {
                const id = Date.now();
                const notification = { id, type, message };
                this.notifications.push(notification);
                this.render();
                
                // 自动移除通知
                setTimeout(() => {
                    this.removeNotification(id);
                }, 5000);
            }
            
            removeNotification(id) {
                this.notifications = this.notifications.filter(n => n.id !== id);
                this.render();
            }
            
            render() {
                this.shadowRoot.innerHTML = `
                    <style>
                        :host {
                            position: fixed;
                            top: 20px;
                            right: 20px;
                            z-index: 9999;
                        }
                        
                        .notification {
                            padding: 16px;
                            margin-bottom: 8px;
                            border-radius: 6px;
                            color: white;
                            min-width: 300px;
                            position: relative;
                            animation: slideIn 0.3s ease-out;
                        }
                        
                        @keyframes slideIn {
                            from {
                                transform: translateX(100%);
                                opacity: 0;
                            }
                            to {
                                transform: translateX(0);
                                opacity: 1;
                            }
                        }
                        
                        .notification.success {
                            background-color: #28a745;
                        }
                        
                        .notification.error {
                            background-color: #dc3545;
                        }
                        
                        .notification.warning {
                            background-color: #ffc107;
                            color: #212529;
                        }
                        
                        .notification.info {
                            background-color: #17a2b8;
                        }
                        
                        .close-btn {
                            position: absolute;
                            top: 8px;
                            right: 8px;
                            background: none;
                            border: none;
                            color: inherit;
                            cursor: pointer;
                            font-size: 18px;
                            padding: 0;
                            width: 24px;
                            height: 24px;
                        }
                    </style>
                    
                    ${this.notifications.map(notification => `
                        <div class="notification ${notification.type}">
                            ${notification.message}
                            <button class="close-btn" onclick="this.getRootNode().host.removeNotification(${notification.id})">×</button>
                        </div>
                    `).join('')}
                `;
            }
        }
        
        // 注册组件
        customElements.define('data-table', DataTable);
        customElements.define('form-builder', FormBuilder);
        customElements.define('notification-container', NotificationContainer);
        
        // 全局通知函数
        function showNotification(type, message) {
            const container = document.querySelector('notification-container');
            container.addNotification(type, message);
        }
    </script>
</body>
</html>

本节要点回顾

  • Web Components概述:理解组件化开发的核心概念和技术栈
  • Custom Elements:掌握自定义元素的创建、生命周期和属性观察
  • Shadow DOM:学会使用Shadow DOM实现样式和DOM封装
  • HTML Templates:使用模板和插槽系统创建灵活的组件结构
  • 实践应用:通过完整的组件库案例掌握Web Components的实际开发技巧

相关学习资源

常见问题FAQ

Q: Web Components与React/Vue等框架有什么区别?

A: Web Components是浏览器原生支持的标准,无需依赖框架;而React/Vue是JavaScript框架,提供了更丰富的生态系统和开发工具。

Q: Shadow DOM的样式隔离如何工作?

A: Shadow DOM创建了一个封闭的作用域,外部CSS无法影响内部元素,内部CSS也不会影响外部元素,实现了真正的样式隔离。

Q: 如何在Web Components中处理事件?

A: 可以使用addEventListener添加事件监听器,使用CustomEvent创建自定义事件,通过dispatchEvent分发事件。

Q: Web Components的浏览器兼容性如何?

A: 现代浏览器都支持Web Components,对于老旧浏览器可以使用polyfill来提供兼容性支持。

Q: 如何测试Web Components?

A: 可以使用传统的JavaScript测试框架(如Jest)结合DOM操作来测试Web Components的功能和行为。


下一节预览:下一节我们将学习Progressive Web Apps,重点介绍PWA的核心概念、Service Worker和离线功能实现。