Web Components基础

Web Components是一套用于创建可复用自定义组件的Web标准技术。本章将介绍Web Components的基本概念和核心技术。

Web Components概述

核心技术

const webComponentsTech = {
    CustomElements: {
        description: '自定义元素',
        purpose: '定义新的HTML标签'
    },
    ShadowDOM: {
        description: '影子DOM',
        purpose: '封装组件内部结构'
    },
    HTMLTemplates: {
        description: 'HTML模板',
        purpose: '定义可复用的HTML片段'
    }
};

浏览器支持

function checkSupport() {
    return {
        customElements: 'customElements' in window,
        shadowDOM: 'attachShadow' in Element.prototype,
        templates: 'content' in document.createElement('template'),
        all: 'customElements' in window && 
             'attachShadow' in Element.prototype &&
             'content' in document.createElement('template')
    };
}

自定义元素

创建自定义元素

class MyButton extends HTMLElement {
    constructor() {
        super();
        this.addEventListener('click', () => {
            console.log('按钮被点击');
        });
    }
    
    connectedCallback() {
        this.innerHTML = `<button>点击我</button>`;
    }
}

customElements.define('my-button', MyButton);

const button = document.createElement('my-button');
document.body.appendChild(button);

document.body.innerHTML += '<my-button></my-button>';

生命周期回调

class LifecycleElement extends HTMLElement {
    constructor() {
        super();
        console.log('1. constructor - 元素被创建');
    }
    
    connectedCallback() {
        console.log('2. connectedCallback - 元素被插入DOM');
    }
    
    disconnectedCallback() {
        console.log('3. disconnectedCallback - 元素从DOM移除');
    }
    
    adoptedCallback() {
        console.log('4. adoptedCallback - 元素被移动到新文档');
    }
    
    attributeChangedCallback(name, oldValue, newValue) {
        console.log(`5. attributeChangedCallback - 属性 ${name}${oldValue} 变为 ${newValue}`);
    }
    
    static get observedAttributes() {
        return ['name', 'count'];
    }
}

customElements.define('lifecycle-element', LifecycleElement);

属性和属性

class UserCard extends HTMLElement {
    static get observedAttributes() {
        return ['name', 'email', 'avatar'];
    }
    
    get name() {
        return this.getAttribute('name') || '';
    }
    
    set name(value) {
        this.setAttribute('name', value);
    }
    
    get email() {
        return this.getAttribute('email') || '';
    }
    
    set email(value) {
        this.setAttribute('email', value);
    }
    
    attributeChangedCallback(name, oldValue, newValue) {
        if (oldValue !== newValue) {
            this.render();
        }
    }
    
    connectedCallback() {
        this.render();
    }
    
    render() {
        this.innerHTML = `
            <div class="user-card">
                <img src="${this.avatar}" alt="${this.name}">
                <h3>${this.name}</h3>
                <p>${this.email}</p>
            </div>
        `;
    }
}

customElements.define('user-card', UserCard);

Shadow DOM

创建Shadow DOM

class ShadowButton extends HTMLElement {
    constructor() {
        super();
        
        const shadow = this.attachShadow({ mode: 'open' });
        
        shadow.innerHTML = `
            <style>
                button {
                    background: #007bff;
                    color: white;
                    border: none;
                    padding: 10px 20px;
                    border-radius: 4px;
                    cursor: pointer;
                }
                
                button:hover {
                    background: #0056b3;
                }
            </style>
            <button><slot>按钮</slot></button>
        `;
    }
}

customElements.define('shadow-button', ShadowButton);

Shadow DOM模式

const shadowModes = {
    open: {
        description: '开放的Shadow DOM',
        access: '可以通过element.shadowRoot访问',
        use: '大多数情况下的选择'
    },
    closed: {
        description: '封闭的Shadow DOM',
        access: 'element.shadowRoot返回null',
        use: '需要严格封装的场景'
    }
};

class OpenShadow extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
    }
}

class ClosedShadow extends HTMLElement {
    constructor() {
        super();
        this._shadowRoot = this.attachShadow({ mode: 'closed' });
    }
    
    get shadowRoot() {
        return this._shadowRoot;
    }
}

样式封装

class StyledCard extends HTMLElement {
    constructor() {
        super();
        
        const shadow = this.attachShadow({ mode: 'open' });
        
        shadow.innerHTML = `
            <style>
                :host {
                    display: block;
                    border: 1px solid #ccc;
                    border-radius: 8px;
                    padding: 16px;
                    margin: 16px;
                }
                
                :host(.highlighted) {
                    border-color: #007bff;
                    box-shadow: 0 2px 8px rgba(0, 123, 255, 0.2);
                }
                
                :host([disabled]) {
                    opacity: 0.5;
                    pointer-events: none;
                }
                
                :host-context(.dark-theme) {
                    background: #333;
                    color: white;
                }
                
                .title {
                    font-size: 18px;
                    font-weight: bold;
                    margin-bottom: 8px;
                }
                
                ::slotted(h2) {
                    color: #007bff;
                }
                
                ::slotted(*) {
                    margin: 0;
                }
            </style>
            
            <div class="title">
                <slot name="title">默认标题</slot>
            </div>
            <div class="content">
                <slot>默认内容</slot>
            </div>
        `;
    }
}

customElements.define('styled-card', StyledCard);

插槽

基本插槽

class CardElement extends HTMLElement {
    constructor() {
        super();
        
        const shadow = this.attachShadow({ mode: 'open' });
        
        shadow.innerHTML = `
            <style>
                .card { border: 1px solid #ddd; padding: 16px; }
                .header { border-bottom: 1px solid #eee; padding-bottom: 8px; }
                .footer { border-top: 1px solid #eee; padding-top: 8px; }
            </style>
            
            <div class="card">
                <div class="header">
                    <slot name="header">默认头部</slot>
                </div>
                <div class="body">
                    <slot>默认内容</slot>
                </div>
                <div class="footer">
                    <slot name="footer">默认底部</slot>
                </div>
            </div>
        `;
    }
}

customElements.define('card-element', CardElement);
<card-element>
    <h2 slot="header">卡片标题</h2>
    <p>这是卡片内容</p>
    <span slot="footer">卡片底部</span>
</card-element>

插槽事件

class SlotContainer extends HTMLElement {
    constructor() {
        super();
        
        const shadow = this.attachShadow({ mode: 'open' });
        
        shadow.innerHTML = `
            <slot id="content"></slot>
        `;
        
        this.slotElement = shadow.querySelector('slot');
    }
    
    connectedCallback() {
        this.slotElement.addEventListener('slotchange', (e) => {
            const nodes = this.slotElement.assignedNodes();
            console.log('插槽内容变化:', nodes);
        });
    }
    
    getSlotContent() {
        return this.slotElement.assignedNodes();
    }
    
    getSlotElements() {
        return this.slotElement.assignedElements();
    }
}

HTML模板

template元素

<template id="card-template">
    <style>
        .card {
            border: 1px solid #ccc;
            padding: 16px;
            border-radius: 8px;
        }
    </style>
    <div class="card">
        <h3 class="title"></h3>
        <p class="content"></p>
    </div>
</template>

<script>
    class TemplateCard extends HTMLElement {
        constructor() {
            super();
            
            const template = document.getElementById('card-template');
            const content = template.content.cloneNode(true);
            
            const shadow = this.attachShadow({ mode: 'open' });
            shadow.appendChild(content);
        }
        
        connectedCallback() {
            const title = this.getAttribute('title') || '标题';
            const content = this.getAttribute('content') || '内容';
            
            this.shadowRoot.querySelector('.title').textContent = title;
            this.shadowRoot.querySelector('.content').textContent = content;
        }
    }
    
    customElements.define('template-card', TemplateCard);
</script>

slot元素

<template id="panel-template">
    <style>
        .panel { border: 1px solid #ddd; padding: 16px; }
    </style>
    <div class="panel">
        <slot name="header"></slot>
        <slot></slot>
        <slot name="footer"></slot>
    </div>
</template>

完整组件示例

class TodoList extends HTMLElement {
    static get observedAttributes() {
        return ['title'];
    }
    
    constructor() {
        super();
        this.todos = [];
        this.attachShadow({ mode: 'open' });
    }
    
    connectedCallback() {
        this.render();
        this.setupEventListeners();
    }
    
    attributeChangedCallback(name, oldValue, newValue) {
        if (oldValue !== newValue) {
            this.render();
        }
    }
    
    setupEventListeners() {
        const input = this.shadowRoot.querySelector('input');
        const addButton = this.shadowRoot.querySelector('.add-btn');
        const list = this.shadowRoot.querySelector('ul');
        
        addButton.addEventListener('click', () => {
            const text = input.value.trim();
            if (text) {
                this.addTodo(text);
                input.value = '';
            }
        });
        
        input.addEventListener('keypress', (e) => {
            if (e.key === 'Enter') {
                addButton.click();
            }
        });
        
        list.addEventListener('click', (e) => {
            if (e.target.classList.contains('delete-btn')) {
                const index = parseInt(e.target.dataset.index);
                this.removeTodo(index);
            } else if (e.target.classList.contains('todo-item')) {
                const index = parseInt(e.target.dataset.index);
                this.toggleTodo(index);
            }
        });
    }
    
    addTodo(text) {
        this.todos.push({ text, done: false });
        this.render();
        this.emitChange();
    }
    
    removeTodo(index) {
        this.todos.splice(index, 1);
        this.render();
        this.emitChange();
    }
    
    toggleTodo(index) {
        this.todos[index].done = !this.todos[index].done;
        this.render();
        this.emitChange();
    }
    
    emitChange() {
        this.dispatchEvent(new CustomEvent('change', {
            detail: { todos: this.todos },
            bubbles: true
        }));
    }
    
    render() {
        const title = this.getAttribute('title') || '待办事项';
        
        this.shadowRoot.innerHTML = `
            <style>
                :host {
                    display: block;
                    font-family: Arial, sans-serif;
                    max-width: 400px;
                }
                
                h2 { margin: 0 0 16px; }
                
                .input-group {
                    display: flex;
                    gap: 8px;
                    margin-bottom: 16px;
                }
                
                input {
                    flex: 1;
                    padding: 8px;
                    border: 1px solid #ccc;
                    border-radius: 4px;
                }
                
                .add-btn {
                    padding: 8px 16px;
                    background: #007bff;
                    color: white;
                    border: none;
                    border-radius: 4px;
                    cursor: pointer;
                }
                
                ul {
                    list-style: none;
                    padding: 0;
                    margin: 0;
                }
                
                li {
                    display: flex;
                    align-items: center;
                    padding: 8px;
                    border-bottom: 1px solid #eee;
                }
                
                .todo-item {
                    flex: 1;
                    cursor: pointer;
                }
                
                .todo-item.done {
                    text-decoration: line-through;
                    color: #999;
                }
                
                .delete-btn {
                    padding: 4px 8px;
                    background: #dc3545;
                    color: white;
                    border: none;
                    border-radius: 4px;
                    cursor: pointer;
                    font-size: 12px;
                }
            </style>
            
            <h2>${title}</h2>
            
            <div class="input-group">
                <input type="text" placeholder="添加新任务...">
                <button class="add-btn">添加</button>
            </div>
            
            <ul>
                ${this.todos.map((todo, index) => `
                    <li>
                        <span class="todo-item ${todo.done ? 'done' : ''}" 
                              data-index="${index}">
                            ${todo.text}
                        </span>
                        <button class="delete-btn" data-index="${index}">删除</button>
                    </li>
                `).join('')}
            </ul>
        `;
        
        this.setupEventListeners();
    }
}

customElements.define('todo-list', TodoList);

东巴文小贴士

🧩 Web Components优势

  1. 原生支持:无需框架,浏览器原生支持
  2. 封装性好:Shadow DOM提供样式和结构封装
  3. 可复用:可在任何项目中使用
  4. 互操作性:与任何框架配合使用

📝 开发建议

  • 使用语义化的自定义元素名称(包含连字符)
  • 合理使用open和closed模式
  • 提供良好的属性和事件接口
  • 考虑无障碍访问支持

下一步

下一章将探讨 自定义元素进阶,学习更多自定义元素的高级特性。