自定义元素进阶

本章将深入探讨自定义元素的高级特性,包括表单集成、扩展原生元素、生命周期管理等主题。

表单集成

表单关联元素

class MyInput extends HTMLElement {
    static get formAssociated() {
        return true;
    }
    
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
        this._value = '';
        this._internals = this.attachInternals();
    }
    
    connectedCallback() {
        this.render();
        this.setupEvents();
    }
    
    render() {
        this.shadowRoot.innerHTML = `
            <style>
                input {
                    width: 100%;
                    padding: 8px;
                    border: 1px solid #ccc;
                    border-radius: 4px;
                }
                input:focus {
                    outline: none;
                    border-color: #007bff;
                }
                .error {
                    color: red;
                    font-size: 12px;
                    margin-top: 4px;
                }
            </style>
            <input type="text">
            <div class="error"></div>
        `;
    }
    
    setupEvents() {
        const input = this.shadowRoot.querySelector('input');
        
        input.addEventListener('input', (e) => {
            this._value = e.target.value;
            this._internals.setFormValue(this._value);
            this.dispatchEvent(new Event('input', { bubbles: true }));
        });
        
        input.addEventListener('focus', () => {
            this.dispatchEvent(new Event('focus', { bubbles: true }));
        });
        
        input.addEventListener('blur', () => {
            this.dispatchEvent(new Event('blur', { bubbles: true }));
        });
    }
    
    get value() {
        return this._value;
    }
    
    set value(val) {
        this._value = val;
        this.shadowRoot.querySelector('input').value = val;
        this._internals.setFormValue(val);
    }
    
    get name() {
        return this.getAttribute('name');
    }
    
    get form() {
        return this._internals.form;
    }
    
    get validity() {
        return this._internals.validity;
    }
    
    get validationMessage() {
        return this._internals.validationMessage;
    }
    
    checkValidity() {
        return this._internals.checkValidity();
    }
    
    reportValidity() {
        return this._internals.reportValidity();
    }
    
    setCustomValidity(message) {
        const errorDiv = this.shadowRoot.querySelector('.error');
        
        if (message) {
            this._internals.setValidity({ customError: true }, message);
            errorDiv.textContent = message;
        } else {
            this._internals.setValidity({});
            errorDiv.textContent = '';
        }
    }
    
    formDisabledCallback(disabled) {
        this.shadowRoot.querySelector('input').disabled = disabled;
    }
    
    formResetCallback() {
        this.value = this.getAttribute('value') || '';
    }
    
    formStateRestoreCallback(state) {
        this.value = state;
    }
}

customElements.define('my-input', MyInput);

使用表单元素

<form id="myForm">
    <label>用户名:</label>
    <my-input name="username" required></my-input>
    
    <label>邮箱:</label>
    <my-input name="email"></my-input>
    
    <button type="submit">提交</button>
</form>

<script>
    const form = document.getElementById('myForm');
    
    form.addEventListener('submit', (e) => {
        e.preventDefault();
        
        const formData = new FormData(form);
        console.log('表单数据:', Object.fromEntries(formData));
    });
</script>

扩展原生元素

自定义内置元素

class FancyButton extends HTMLButtonElement {
    constructor() {
        super();
        
        this.addEventListener('click', () => {
            this.ripple();
        });
    }
    
    connectedCallback() {
        this.style.position = 'relative';
        this.style.overflow = 'hidden';
    }
    
    ripple() {
        const ripple = document.createElement('span');
        ripple.style.cssText = `
            position: absolute;
            background: rgba(255,255,255,0.4);
            border-radius: 50%;
            transform: scale(0);
            animation: ripple 0.6s linear;
            pointer-events: none;
        `;
        
        const rect = this.getBoundingClientRect();
        const size = Math.max(rect.width, rect.height);
        ripple.style.width = ripple.style.height = size + 'px';
        ripple.style.left = (event.clientX - rect.left - size / 2) + 'px';
        ripple.style.top = (event.clientY - rect.top - size / 2) + 'px';
        
        this.appendChild(ripple);
        
        setTimeout(() => ripple.remove(), 600);
    }
}

customElements.define('fancy-button', FancyButton, { extends: 'button' });
<button is="fancy-button">点击我</button>

扩展Input元素

class ColorInput extends HTMLInputElement {
    static get observedAttributes() {
        return ['color', 'format'];
    }
    
    constructor() {
        super();
        this.type = 'text';
    }
    
    connectedCallback() {
        this.addEventListener('input', this.validateColor.bind(this));
    }
    
    validateColor() {
        const value = this.value;
        const format = this.getAttribute('format') || 'hex';
        
        let isValid = false;
        
        switch (format) {
            case 'hex':
                isValid = /^#([0-9A-F]{3}){1,2}$/i.test(value);
                break;
            case 'rgb':
                isValid = /^rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)$/.test(value);
                break;
            case 'hsl':
                isValid = /^hsl\(\s*\d+\s*,\s*\d+%\s*,\s*\d+%\s*\)$/.test(value);
                break;
        }
        
        this.style.borderColor = isValid ? 'green' : 'red';
    }
}

customElements.define('color-input', ColorInput, { extends: 'input' });

属性反射

class ReflectElement extends HTMLElement {
    static get observedAttributes() {
        return ['value', 'disabled', 'checked'];
    }
    
    constructor() {
        super();
        
        this._value = null;
        this._disabled = false;
        this._checked = false;
    }
    
    get value() {
        return this._value;
    }
    
    set value(val) {
        this._value = val;
        this._reflectAttribute('value', val);
    }
    
    get disabled() {
        return this._disabled;
    }
    
    set disabled(val) {
        this._disabled = Boolean(val);
        this._reflectBooleanAttribute('disabled', val);
    }
    
    get checked() {
        return this._checked;
    }
    
    set checked(val) {
        this._checked = Boolean(val);
        this._reflectBooleanAttribute('checked', val);
    }
    
    _reflectAttribute(name, value) {
        if (value === null || value === undefined) {
            this.removeAttribute(name);
        } else {
            this.setAttribute(name, value);
        }
    }
    
    _reflectBooleanAttribute(name, value) {
        if (value) {
            this.setAttribute(name, '');
        } else {
            this.removeAttribute(name);
        }
    }
    
    attributeChangedCallback(name, oldValue, newValue) {
        switch (name) {
            case 'value':
                this._value = newValue;
                break;
            case 'disabled':
                this._disabled = newValue !== null;
                break;
            case 'checked':
                this._checked = newValue !== null;
                break;
        }
        
        this.render();
    }
    
    render() {
        // 渲染逻辑
    }
}

事件处理

自定义事件

class CounterElement extends HTMLElement {
    constructor() {
        super();
        this._count = 0;
        this.attachShadow({ mode: 'open' });
    }
    
    connectedCallback() {
        this.render();
    }
    
    increment() {
        this._count++;
        this.render();
        this.emitChange();
    }
    
    decrement() {
        this._count--;
        this.render();
        this.emitChange();
    }
    
    emitChange() {
        this.dispatchEvent(new CustomEvent('count-change', {
            detail: { count: this._count },
            bubbles: true,
            composed: true
        }));
    }
    
    render() {
        this.shadowRoot.innerHTML = `
            <style>
                :host { display: inline-block; }
                button { padding: 8px 16px; margin: 0 4px; }
                span { font-size: 18px; margin: 0 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());
    }
}

customElements.define('counter-element', CounterElement);
<counter-element id="counter"></counter-element>

<script>
    document.getElementById('counter').addEventListener('count-change', (e) => {
        console.log('计数变化:', e.detail.count);
    });
</script>

事件穿透

const eventOptions = {
    bubbles: {
        description: '事件是否冒泡',
        default: false
    },
    composed: {
        description: '事件是否穿透Shadow DOM',
        default: false
    },
    cancelable: {
        description: '事件是否可取消',
        default: false
    }
};

class EventTest extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
    }
    
    connectedCallback() {
        this.shadowRoot.innerHTML = `<button id="btn">点击</button>`;
        
        this.shadowRoot.getElementById('btn').addEventListener('click', (e) => {
            const event = new CustomEvent('button-click', {
                detail: { timestamp: Date.now() },
                bubbles: true,
                composed: true
            });
            
            this.dispatchEvent(event);
        });
    }
}

模板克隆优化

const templateCache = new WeakMap();

class OptimizedElement extends HTMLElement {
    static get template() {
        if (!templateCache.has(this)) {
            const template = document.createElement('template');
            template.innerHTML = `
                <style>
                    :host { display: block; }
                </style>
                <div class="content">
                    <slot></slot>
                </div>
            `;
            templateCache.set(this, template);
        }
        return templateCache.get(this);
    }
    
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
        this.shadowRoot.appendChild(
            this.constructor.template.content.cloneNode(true)
        );
    }
}

注册和升级

customElements.whenDefined('my-element').then(() => {
    console.log('my-element 已定义');
});

async function waitForElement(name) {
    await customElements.whenDefined(name);
    return customElements.get(name);
}

const MyElement = await waitForElement('my-element');

const element = document.createElement('my-element');
if (element instanceof HTMLElement) {
    console.log('元素已升级');
}

customElements.upgrade(element);

const defined = customElements.get('my-element');

东巴文小贴士

🔧 进阶技巧

  1. 表单集成:使用formAssociated让自定义元素参与表单
  2. 事件穿透:composed: true让事件穿透Shadow DOM
  3. 模板缓存:使用WeakMap缓存模板提高性能
  4. 延迟注册:whenDefined处理异步定义

⚠️ 注意事项

  • 扩展原生元素需要{ extends: 'tagname' }
  • Safari对自定义内置元素支持有限
  • 属性反射要注意性能影响

下一步

下一章将探讨 [Shadow DOM深入](file:///e:/db-w.cn/md_data/javascript/83_Shadow DOM深入.md),学习Shadow DOM的更多高级特性。