组件化思想

组件化是现代前端开发的核心思想,它将UI拆分为独立、可复用的组件。本章将介绍组件化开发的设计理念和最佳实践。

组件化概念

什么是组件

const componentDefinition = {
    definition: '组件是可复用的UI单元,封装了结构、样式和行为',
    
    characteristics: [
        '独立性 - 组件独立运作,不依赖外部状态',
        '可复用 - 可在不同场景重复使用',
        '可组合 - 小组件组合成大组件',
        '可测试 - 可独立测试组件功能'
    ],
    
    composition: {
        structure: 'HTML模板结构',
        style: 'CSS样式',
        behavior: 'JavaScript逻辑',
        data: '组件状态和属性'
    }
};

组件分类

const componentTypes = {
    presentational: {
        name: '展示组件',
        description: '只负责UI展示,不包含业务逻辑',
        characteristics: [
            '接收props渲染UI',
            '不直接修改数据',
            '可复用性高',
            '易于测试'
        ],
        example: 'Button, Card, List, Input'
    },
    
    container: {
        name: '容器组件',
        description: '负责数据获取和业务逻辑',
        characteristics: [
            '管理状态',
            '处理数据获取',
            '协调子组件',
            '包含业务逻辑'
        ],
        example: 'UserListContainer, ProductPage'
    },
    
    higherOrder: {
        name: '高阶组件',
        description: '接收组件返回增强组件',
        characteristics: [
            '复用组件逻辑',
            '属性代理',
            '渲染劫持',
            '状态抽象'
        ],
        example: 'withAuth, withLoading'
    }
};

组件设计原则

单一职责

class UserProfile extends HTMLElement {
    connectedCallback() {
        this.innerHTML = `
            <div class="profile">
                <user-avatar src="${this.avatar}"></user-avatar>
                <user-info name="${this.name}" email="${this.email}"></user-info>
                <user-stats posts="${this.posts}" followers="${this.followers}"></user-stats>
            </div>
        `;
    }
}

class UserAvatar extends HTMLElement {
    static get observedAttributes() {
        return ['src', 'size'];
    }
    
    connectedCallback() {
        const size = this.getAttribute('size') || 'medium';
        this.innerHTML = `
            <img class="avatar avatar-${size}" 
                 src="${this.getAttribute('src')}" 
                 alt="用户头像">
        `;
    }
}

class UserInfo extends HTMLElement {
    static get observedAttributes() {
        return ['name', 'email'];
    }
    
    connectedCallback() {
        this.innerHTML = `
            <div class="user-info">
                <h3>${this.getAttribute('name')}</h3>
                <p>${this.getAttribute('email')}</p>
            </div>
        `;
    }
}

组件接口设计

const interfaceDesign = {
    props: {
        description: '组件输入,父组件传递数据',
        guidelines: [
            '使用明确的数据类型',
            '提供默认值',
            '验证必要属性',
            '避免过多属性'
        ]
    },
    
    events: {
        description: '组件输出,向父组件通信',
        guidelines: [
            '使用自定义事件',
            '传递有意义的数据',
            '命名清晰',
            '支持事件取消'
        ]
    },
    
    slots: {
        description: '内容分发,灵活组合',
        guidelines: [
            '提供默认内容',
            '命名插槽',
            '文档说明'
        ]
    }
};

class Button extends HTMLElement {
    static get observedAttributes() {
        return ['type', 'size', 'disabled', 'loading'];
    }
    
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
    }
    
    connectedCallback() {
        this.render();
    }
    
    render() {
        const type = this.getAttribute('type') || 'default';
        const size = this.getAttribute('size') || 'medium';
        const disabled = this.hasAttribute('disabled');
        const loading = this.hasAttribute('loading');
        
        this.shadowRoot.innerHTML = `
            <style>
                :host { display: inline-block; }
                button {
                    padding: var(--btn-padding, 8px 16px);
                    border-radius: var(--btn-radius, 4px);
                    cursor: pointer;
                }
                button:disabled { opacity: 0.5; cursor: not-allowed; }
                button.size-small { padding: 4px 8px; font-size: 12px; }
                button.size-large { padding: 12px 24px; font-size: 16px; }
                button.type-primary { background: #007bff; color: white; }
                button.type-danger { background: #dc3545; color: white; }
            </style>
            <button 
                class="type-${type} size-${size}"
                ?disabled="${disabled || loading}">
                ${loading ? '<span class="spinner"></span>' : ''}
                <slot></slot>
            </button>
        `;
        
        this.shadowRoot.querySelector('button').addEventListener('click', (e) => {
            if (!disabled && !loading) {
                this.dispatchEvent(new CustomEvent('click', { bubbles: true }));
            }
        });
    }
}

组件状态管理

本地状态

class Counter extends HTMLElement {
    constructor() {
        super();
        this._count = 0;
        this.attachShadow({ mode: 'open' });
    }
    
    connectedCallback() {
        this.render();
    }
    
    get count() {
        return this._count;
    }
    
    set count(value) {
        this._count = value;
        this.render();
    }
    
    increment() {
        this.count++;
        this.dispatchEvent(new CustomEvent('change', {
            detail: { count: this.count },
            bubbles: true
        }));
    }
    
    decrement() {
        this.count--;
        this.dispatchEvent(new CustomEvent('change', {
            detail: { count: this.count },
            bubbles: true
        }));
    }
    
    render() {
        this.shadowRoot.innerHTML = `
            <style>
                :host { display: inline-flex; align-items: center; gap: 8px; }
                button { padding: 4px 8px; }
            </style>
            <button id="dec">-</button>
            <span>${this.count}</span>
            <button id="inc">+</button>
        `;
        
        this.shadowRoot.getElementById('inc')
            .addEventListener('click', () => this.increment());
        this.shadowRoot.getElementById('dec')
            .addEventListener('click', () => this.decrement());
    }
}

状态提升

class TemperatureInput extends HTMLElement {
    static get observedAttributes() {
        return ['value', 'scale'];
    }
    
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
    }
    
    connectedCallback() {
        this.render();
    }
    
    attributeChangedCallback() {
        this.render();
    }
    
    render() {
        const value = this.getAttribute('value') || '';
        const scale = this.getAttribute('scale');
        const scaleNames = { c: '摄氏', f: '华氏' };
        
        this.shadowRoot.innerHTML = `
            <fieldset>
                <legend>输入${scaleNames[scale]}温度:</legend>
                <input type="number" value="${value}">
            </fieldset>
        `;
        
        this.shadowRoot.querySelector('input').addEventListener('input', (e) => {
            this.dispatchEvent(new CustomEvent('temperature-change', {
                detail: { value: e.target.value, scale },
                bubbles: true
            }));
        });
    }
}

class Calculator extends HTMLElement {
    constructor() {
        super();
        this._temperature = '';
        this._scale = 'c';
        this.attachShadow({ mode: 'open' });
    }
    
    connectedCallback() {
        this.render();
    }
    
    handleCelsiusChange(value) {
        this._temperature = value;
        this._scale = 'c';
        this.render();
    }
    
    handleFahrenheitChange(value) {
        this._temperature = value;
        this._scale = 'f';
        this.render();
    }
    
    toCelsius(fahrenheit) {
        return (fahrenheit - 32) * 5 / 9;
    }
    
    toFahrenheit(celsius) {
        return (celsius * 9 / 5) + 32;
    }
    
    render() {
        const celsius = this._scale === 'f' 
            ? this.toCelsius(parseFloat(this._temperature) || 0)
            : parseFloat(this._temperature) || 0;
        
        const fahrenheit = this._scale === 'c'
            ? this.toFahrenheit(parseFloat(this._temperature) || 0)
            : parseFloat(this._temperature) || 0;
        
        this.shadowRoot.innerHTML = `
            <temperature-input scale="c" value="${celsius}"></temperature-input>
            <temperature-input scale="f" value="${fahrenheit}"></temperature-input>
        `;
        
        this.shadowRoot.querySelectorAll('temperature-input').forEach(input => {
            input.addEventListener('temperature-change', (e) => {
                if (e.detail.scale === 'c') {
                    this.handleCelsiusChange(e.detail.value);
                } else {
                    this.handleFahrenheitChange(e.detail.value);
                }
            });
        });
    }
}

组件组合

组合模式

class Card extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
    }
    
    connectedCallback() {
        this.shadowRoot.innerHTML = `
            <style>
                :host { display: block; border: 1px solid #ddd; border-radius: 8px; }
                ::slotted([slot="header"]) { padding: 16px; border-bottom: 1px solid #eee; }
                ::slotted([slot="body"]) { padding: 16px; }
                ::slotted([slot="footer"]) { padding: 16px; border-top: 1px solid #eee; }
            </style>
            <slot name="header"></slot>
            <slot name="body"></slot>
            <slot name="footer"></slot>
        `;
    }
}

customElements.define('app-card', Card);
<app-card>
    <div slot="header">
        <h3>卡片标题</h3>
    </div>
    <div slot="body">
        <p>卡片内容</p>
    </div>
    <div slot="footer">
        <button>确定</button>
    </div>
</app-card>

复合组件

class Tabs extends HTMLElement {
    constructor() {
        super();
        this._activeIndex = 0;
        this.attachShadow({ mode: 'open' });
    }
    
    connectedCallback() {
        this.render();
        this.setupListeners();
    }
    
    setupListeners() {
        this.shadowRoot.addEventListener('tab-select', (e) => {
            this._activeIndex = e.detail.index;
            this.render();
        });
    }
    
    render() {
        const tabs = this.querySelectorAll('app-tab');
        const tabList = Array.from(tabs).map((tab, index) => ({
            label: tab.getAttribute('label'),
            active: index === this._activeIndex
        }));
        
        this.shadowRoot.innerHTML = `
            <style>
                :host { display: block; }
                .tab-list { display: flex; border-bottom: 1px solid #ddd; }
                .tab { padding: 8px 16px; cursor: pointer; }
                .tab.active { border-bottom: 2px solid #007bff; color: #007bff; }
                .tab-content { padding: 16px; }
            </style>
            <div class="tab-list">
                ${tabList.map((tab, index) => `
                    <div class="tab ${tab.active ? 'active' : ''}" 
                         data-index="${index}">
                        ${tab.label}
                    </div>
                `).join('')}
            </div>
            <div class="tab-content">
                <slot name="tab-${this._activeIndex}"></slot>
            </div>
        `;
        
        this.shadowRoot.querySelectorAll('.tab').forEach(tab => {
            tab.addEventListener('click', () => {
                const index = parseInt(tab.dataset.index);
                this.shadowRoot.dispatchEvent(new CustomEvent('tab-select', {
                    detail: { index },
                    bubbles: true
                }));
            });
        });
    }
}

class Tab extends HTMLElement {
    static get observedAttributes() {
        return ['label'];
    }
}

customElements.define('app-tabs', Tabs);
customElements.define('app-tab', Tab);

东巴文小贴士

🧩 组件设计要点

  1. 小而美:组件越小,复用性越强
  2. 接口清晰:Props进,Events出
  3. 单一职责:一个组件只做一件事
  4. 可测试:组件应该易于测试

🔄 组件通信方式

  • Props:父传子
  • Events:子传父
  • Provide/Inject:跨层级
  • 状态管理:全局共享

下一步

下一章将探讨 虚拟DOM,学习虚拟DOM的原理和实现。