pin_drop当前位置:知识文库 ❯ 图文

布局结构:HTML Web Components - 完整教程与代码示例

一、教程简介

Web Components是W3C制定的一套浏览器原生组件化标准,它允许开发者创建可复用的自定义元素,其功能与标准HTML元素无异。Web Components由三个核心技术组成:Custom Elements(自定义元素)、Shadow DOM(影子DOM)和HTML Templates(HTML模板)。本教程将全面讲解Web Components的组件化开发模式、自定义元素的生命周期以及三大核心技术的协作方式。


二、核心概念

Web Components三大核心技术

技术 说明 作用
Custom Elements 自定义元素 定义新的HTML标签及其行为
Shadow DOM 影子DOM 封装样式和结构,避免冲突
HTML Templates HTML模板 定义可复用的DOM结构

Web Components的优势

  • 封装性:Shadow DOM实现样式和结构的隔离

  • 可复用:一次定义,到处使用

  • 无依赖:不依赖任何框架,浏览器原生支持

  • 互操作性:可以与任何框架配合使用

  • 标准化:W3C标准,长期稳定

组件化开发流程

代码示例


1. 定义模板(template)
   ↓
2. 创建自定义元素类(Custom Elements)
   ↓
3. 创建Shadow DOM,挂载模板
   ↓
4. 注册自定义元素(customElements.define)
   ↓
5. 在HTML中使用自定义元素

三、语法与用法

创建Web Component的基本步骤

代码示例


// 1. 创建自定义元素类
class MyComponent extends HTMLElement {
    constructor() {
        super();
        // 创建Shadow DOM
        this.attachShadow({ mode: 'open' });
        // 挂载模板
        this.shadowRoot.appendChild(template.content.cloneNode(true));
    }
}

// 2. 注册自定义元素
customElements.define('my-component', MyComponent);

// 3. 在HTML中使用
// <my-component></my-component>

自定义元素生命周期

回调方法 触发时机 用途
constructor() 元素创建时 初始化状态、创建Shadow DOM
connectedCallback() 元素插入DOM时 添加事件监听、启动定时器
disconnectedCallback() 元素移除DOM时 清理事件监听、停止定时器
adoptedCallback() 元素移到新文档时 处理文档迁移
attributeChangedCallback() 属性变化时 响应属性变化

生命周期执行顺序

代码示例


1. constructor()        -- 元素实例化
2. attributeChangedCallback()  -- 初始属性设置
3. connectedCallback()  -- 插入DOM
4. (属性变化时) attributeChangedCallback()
5. disconnectedCallback() -- 移除DOM

四、代码示例

示例1:Web Components完整演示

代码示例


<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Web Components完整演示</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: "Microsoft YaHei", sans-serif;
            background: #f5f5f5;
            padding: 20px;
        }
        h1 { color: #2c3e50; margin-bottom: 30px; }
        .demo-section {
            background: white;
            border-radius: 8px;
            padding: 20px;
            margin-bottom: 25px;
            box-shadow: 0 2px 8px rgba(0,0,0,0.1);
        }
        h2 { color: #2c3e50; margin-bottom: 15px; font-size: 1.1rem; }
        .demo-row {
            display: grid;
            grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
            gap: 20px;
        }
        .controls {
            display: flex;
            gap: 10px;
            margin-bottom: 15px;
            flex-wrap: wrap;
        }
        .btn {
            padding: 8px 16px;
            background: #3498db;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            font-size: 13px;
        }
        .btn:hover { background: #2980b9; }
        .btn-danger { background: #e74c3c; }
        .btn-danger:hover { background: #c0392b; }
        .btn-success { background: #27ae60; }
        .btn-success:hover { background: #219a52; }
        .log-area {
            background: #1a1a2e;
            color: #0f0;
            padding: 15px;
            border-radius: 6px;
            font-family: monospace;
            font-size: 13px;
            max-height: 200px;
            overflow-y: auto;
            line-height: 1.6;
        }
    </style>
</head>
<body>
    <h1>Web Components组件化开发</h1>

    <!-- 通知组件演示 -->
    <div class="demo-section">
        <h2>1. 通知组件(toast-notification)</h2>
        <div class="controls">
            <button class="btn btn-success" onclick="showToast('success')">成功通知</button>
            <button class="btn" onclick="showToast('info')">信息通知</button>
            <button class="btn btn-danger" onclick="showToast('error')">错误通知</button>
        </div>
        <div id="toastContainer"></div>
    </div>

    <!-- 计数器组件演示 -->
    <div class="demo-section">
        <h2>2. 计数器组件(counter-widget)</h2>
        <div class="demo-row">
            <counter-widget value="0" label="计数器A"></counter-widget>
            <counter-widget value="10" label="计数器B" step="5"></counter-widget>
        </div>
    </div>

    <!-- 进度条组件演示 -->
    <div class="demo-section">
        <h2>3. 进度条组件(progress-bar)</h2>
        <progress-bar value="30" label="下载进度"></progress-bar>
        <progress-bar value="75" label="上传进度" color="#27ae60"></progress-bar>
        <progress-bar value="100" label="安装完成" color="#9b59b6"></progress-bar>
        <div class="controls" style="margin-top:15px;">
            <button class="btn" onclick="updateProgress()">随机更新进度</button>
        </div>
    </div>

    <!-- 生命周期日志 -->
    <div class="demo-section">
        <h2>4. 生命周期回调日志</h2>
        <div class="controls">
            <button class="btn" onclick="addLifecycleElement()">添加元素</button>
            <button class="btn btn-danger" onclick="removeLifecycleElement()">移除元素</button>
            <button class="btn" onclick="changeAttribute()">修改属性</button>
        </div>
        <div id="lifecycleContainer"></div>
        <div class="log-area" id="logArea" style="margin-top:10px;">等待操作...</div>
    </div>

    <!-- ===== 组件模板定义 ===== -->

    <!-- 通知组件模板 -->
    <template id="toastTemplate">
        <style>
            :host {
                display: block;
                margin-bottom: 10px;
                animation: slideIn 0.3s ease-out;
            }
            @keyframes slideIn {
                from { transform: translateX(100%); opacity: 0; }
                to { transform: translateX(0); opacity: 1; }
            }
            @keyframes slideOut {
                from { transform: translateX(0); opacity: 1; }
                to { transform: translateX(100%); opacity: 0; }
            }
            .toast {
                display: flex;
                align-items: center;
                padding: 12px 20px;
                border-radius: 6px;
                color: white;
                font-size: 14px;
                box-shadow: 0 4px 12px rgba(0,0,0,0.15);
            }
            .toast--success { background: #27ae60; }
            .toast--info { background: #3498db; }
            .toast--error { background: #e74c3c; }
            .toast__icon { margin-right: 10px; font-size: 18px; }
            .toast__message { flex: 1; }
            .toast__close {
                background: none;
                border: none;
                color: white;
                cursor: pointer;
                font-size: 18px;
                opacity: 0.7;
                padding: 0 5px;
            }
            .toast__close:hover { opacity: 1; }
            .toast.removing {
                animation: slideOut 0.3s ease-in forwards;
            }
        </style>
        <div class="toast">
            <span class="toast__icon"></span>
            <span class="toast__message"><slot></slot></span>
            <button class="toast__close">&times;</button>
        </div>
    </template>

    <!-- 计数器组件模板 -->
    <template id="counterTemplate">
        <style>
            :host { display: block; }
            .counter {
                background: white;
                border-radius: 8px;
                padding: 20px;
                box-shadow: 0 2px 8px rgba(0,0,0,0.1);
                text-align: center;
            }
            .counter__label {
                color: #666;
                font-size: 13px;
                margin-bottom: 10px;
            }
            .counter__value {
                font-size: 2.5rem;
                font-weight: bold;
                color: #3498db;
                margin-bottom: 15px;
            }
            .counter__controls {
                display: flex;
                justify-content: center;
                gap: 10px;
            }
            .counter__btn {
                width: 40px;
                height: 40px;
                border-radius: 50%;
                border: 2px solid #3498db;
                background: white;
                color: #3498db;
                font-size: 20px;
                cursor: pointer;
                transition: all 0.2s;
            }
            .counter__btn:hover {
                background: #3498db;
                color: white;
            }
        </style>
        <div class="counter">
            <div class="counter__label"></div>
            <div class="counter__value">0</div>
            <div class="counter__controls">
                <button class="counter__btn" id="decrement">-</button>
                <button class="counter__btn" id="reset">0</button>
                <button class="counter__btn" id="increment">+</button>
            </div>
        </div>
    </template>

    <!-- 进度条组件模板 -->
    <template id="progressTemplate">
        <style>
            :host { display: block; margin-bottom: 15px; }
            .progress {
                background: white;
                border-radius: 8px;
                padding: 15px;
                box-shadow: 0 2px 8px rgba(0,0,0,0.1);
            }
            .progress__header {
                display: flex;
                justify-content: space-between;
                margin-bottom: 8px;
                font-size: 14px;
            }
            .progress__label { color: #555; }
            .progress__value { color: #333; font-weight: bold; }
            .progress__track {
                height: 10px;
                background: #ecf0f1;
                border-radius: 5px;
                overflow: hidden;
            }
            .progress__fill {
                height: 100%;
                border-radius: 5px;
                transition: width 0.5s ease;
            }
        </style>
        <div class="progress">
            <div class="progress__header">
                <span class="progress__label"></span>
                <span class="progress__value">0%</span>
            </div>
            <div class="progress__track">
                <div class="progress__fill"></div>
            </div>
        </div>
    </template>

    <!-- 生命周期组件模板 -->
    <template id="lifecycleTemplate">
        <style>
            :host { display: block; }
            .lifecycle-box {
                padding: 15px;
                background: #ecf0f1;
                border-radius: 6px;
                border-left: 4px solid #3498db;
                font-size: 14px;
                color: #2c3e50;
            }
            .lifecycle-box .name {
                font-weight: bold;
                color: #3498db;
            }
        </style>
        <div class="lifecycle-box">
            生命周期组件 - <span class="name"></span>
        </div>
    </template>

    <script>
        // ===== 通知组件 =====
        class ToastNotification extends HTMLElement {
            static get observedAttributes() { return ['type']; }

            constructor() {
                super();
                this.attachShadow({ mode: 'open' });
                var template = document.getElementById('toastTemplate');
                this.shadowRoot.appendChild(template.content.cloneNode(true));

                this.shadowRoot.querySelector('.toast__close').addEventListener('click', () => {
                    this.remove();
                });
            }

            connectedCallback() {
                var type = this.getAttribute('type') || 'info';
                var icons = { success: '\u2713', info: '\u2139', error: '\u2717' };
                this.shadowRoot.querySelector('.toast').classList.add('toast--' + type);
                this.shadowRoot.querySelector('.toast__icon').textContent = icons[type] || '';

                setTimeout(() => {
                    if (this.parentNode) {
                        this.shadowRoot.querySelector('.toast').classList.add('removing');
                        setTimeout(() => this.remove(), 300);
                    }
                }, 3000);
            }
        }
        customElements.define('toast-notification', ToastNotification);

        function showToast(type) {
            var messages = {
                success: '操作成功完成!',
                info: '这是一条信息提示。',
                error: '操作失败,请重试!'
            };
            var toast = document.createElement('toast-notification');
            toast.setAttribute('type', type);
            toast.textContent = messages[type];
            document.getElementById('toastContainer').appendChild(toast);
        }

        // ===== 计数器组件 =====
        class CounterWidget extends HTMLElement {
            static get observedAttributes() { return ['value', 'label', 'step']; }

            constructor() {
                super();
                this._value = 0;
                this._step = 1;
                this.attachShadow({ mode: 'open' });
                var template = document.getElementById('counterTemplate');
                this.shadowRoot.appendChild(template.content.cloneNode(true));

                this.shadowRoot.getElementById('increment').addEventListener('click', () => {
                    this._value += this._step;
                    this._updateDisplay();
                });
                this.shadowRoot.getElementById('decrement').addEventListener('click', () => {
                    this._value -= this._step;
                    this._updateDisplay();
                });
                this.shadowRoot.getElementById('reset').addEventListener('click', () => {
                    this._value = 0;
                    this._updateDisplay();
                });
            }

            connectedCallback() {
                this._value = parseInt(this.getAttribute('value')) || 0;
                this._step = parseInt(this.getAttribute('step')) || 1;
                var label = this.getAttribute('label') || '计数器';
                this.shadowRoot.querySelector('.counter__label').textContent = label;
                this._updateDisplay();
            }

            _updateDisplay() {
                this.shadowRoot.querySelector('.counter__value').textContent = this._value;
                this.setAttribute('value', this._value);
            }
        }
        customElements.define('counter-widget', CounterWidget);

        // ===== 进度条组件 =====
        class ProgressBar extends HTMLElement {
            static get observedAttributes() { return ['value', 'label', 'color']; }

            constructor() {
                super();
                this.attachShadow({ mode: 'open' });
                var template = document.getElementById('progressTemplate');
                this.shadowRoot.appendChild(template.content.cloneNode(true));
            }

            connectedCallback() {
                this._update();
            }

            attributeChangedCallback(name, oldVal, newVal) {
                if (oldVal !== newVal) this._update();
            }

            _update() {
                var value = parseInt(this.getAttribute('value')) || 0;
                var label = this.getAttribute('label') || '进度';
                var color = this.getAttribute('color') || '#3498db';

                var fill = this.shadowRoot.querySelector('.progress__fill');
                if (fill) {
                    fill.style.width = value + '%';
                    fill.style.background = color;
                }
                var labelEl = this.shadowRoot.querySelector('.progress__label');
                if (labelEl) labelEl.textContent = label;
                var valueEl = this.shadowRoot.querySelector('.progress__value');
                if (valueEl) valueEl.textContent = value + '%';
            }
        }
        customElements.define('progress-bar', ProgressBar);

        function updateProgress() {
            var bars = document.querySelectorAll('progress-bar');
            bars.forEach(function(bar) {
                bar.setAttribute('value', Math.floor(Math.random() * 101));
            });
        }

        // ===== 生命周期组件 =====
        var lifecycleCount = 0;

        class LifecycleElement extends HTMLElement {
            static get observedAttributes() { return ['name', 'status']; }

            constructor() {
                super();
                this.attachShadow({ mode: 'open' });
                var template = document.getElementById('lifecycleTemplate');
                this.shadowRoot.appendChild(template.content.cloneNode(true));
                this._log('constructor() - 元素创建');
            }

            connectedCallback() {
                this._log('connectedCallback() - 插入DOM');
                var name = this.getAttribute('name') || '未命名';
                this.shadowRoot.querySelector('.name').textContent = name;
            }

            disconnectedCallback() {
                this._log('disconnectedCallback() - 移除DOM');
            }

            attributeChangedCallback(name, oldVal, newVal) {
                this._log('attributeChangedCallback() - 属性 ' + name + ' 从 "' + oldVal + '" 变为 "' + newVal + '"');
            }

            _log(msg) {
                var logArea = document.getElementById('logArea');
                var time = new Date().toLocaleTimeString();
                logArea.innerHTML += '<div>[' + time + '] ' + msg + '</div>';
                logArea.scrollTop = logArea.scrollHeight;
            }
        }
        customElements.define('lifecycle-element', LifecycleElement);

        function addLifecycleElement() {
            lifecycleCount++;
            var el = document.createElement('lifecycle-element');
            el.setAttribute('name', '组件' + lifecycleCount);
            document.getElementById('lifecycleContainer').appendChild(el);
        }

        function removeLifecycleElement() {
            var el = document.querySelector('#lifecycleContainer lifecycle-element');
            if (el) el.remove();
        }

        function changeAttribute() {
            var el = document.querySelector('#lifecycleContainer lifecycle-element');
            if (el) {
                var statuses = ['active', 'inactive', 'pending'];
                var current = el.getAttribute('status') || '';
                var next = statuses[(statuses.indexOf(current) + 1) % statuses.length];
                el.setAttribute('status', next);
            }
        }
    </script>
</body>
</html>

五、浏览器兼容性

特性 Chrome Firefox Safari Edge IE
Custom Elements v1 63+ 63+ 10.1+ 79+ 不支持
Shadow DOM v1 63+ 63+ 10.1+ 79+ 不支持
HTML Template 26+ 22+ 7.1+ 13+ 不支持
::slotted() 63+ 63+ 10.1+ 79+ 不支持

Polyfill方案

代码示例


<!-- 使用webcomponents.js polyfill支持旧浏览器 -->
<script src="https://unpkg.com/@webcomponents/webcomponentsjs/webcomponents-loader.js"></script>

六、注意事项与最佳实践

1. 自定义元素命名规范

代码示例


<!-- 必须包含连字符 -->
<my-component></my-component>       <!-- 正确 -->
<custom-button></custom-button>     <!-- 正确 -->
<mycomponent></mycomponent>         <!-- 错误:无连字符 -->

2. 避免在constructor中操作DOM

代码示例


class MyElement extends HTMLElement {
    constructor() {
        super();
        // 可以:初始化Shadow DOM、设置默认值
        this.attachShadow({ mode: 'open' });

        // 不可以:操作外部DOM、读取属性
        // this.getAttribute('name');  // 可能获取不到
        // document.body.appendChild(...);  // 元素还未插入
    }

    connectedCallback() {
        // 在这里操作DOM和读取属性
    }
}

3. 使用observedAttributes优化性能

代码示例


class MyElement extends HTMLElement {
    // 只观察需要响应的属性
    static get observedAttributes() {
        return ['value', 'disabled'];  // 只监听这两个属性
    }

    attributeChangedCallback(name, oldVal, newVal) {
        if (oldVal === newVal) return;  // 值未变化则跳过
        // 处理属性变化
    }
}

4. 清理资源

代码示例


class MyElement extends HTMLElement {
    connectedCallback() {
        this._timer = setInterval(() => { /* ... */ }, 1000);
        this._handler = () => { /* ... */ };
        window.addEventListener('resize', this._handler);
    }

    disconnectedCallback() {
        clearInterval(this._timer);           // 清理定时器
        window.removeEventListener('resize', this._handler);  // 移除事件
    }
}

七、代码规范示例

代码示例


// Web Components规范模板
class MyComponent extends HTMLElement {

    // 1. 声明观察的属性
    static get observedAttributes() {
        return ['value', 'disabled'];
    }

    // 2. 构造函数:最小化初始化
    constructor() {
        super();
        this._value = '';
        this._disabled = false;
        this.attachShadow({ mode: 'open' });
        this.shadowRoot.appendChild(
            document.getElementById('myTemplate').content.cloneNode(true)
        );
    }

    // 3. 属性getter/setter
    get value() { return this._value; }
    set value(val) {
        this._value = val;
        this.setAttribute('value', val);
    }

    get disabled() { return this._disabled; }
    set disabled(val) {
        this._disabled = Boolean(val);
        this.toggleAttribute('disabled', this._disabled);
    }

    // 4. 生命周期回调
    connectedCallback() {
        this._upgradeProperties();
        this._render();
        this._addEventListeners();
    }

    disconnectedCallback() {
        this._removeEventListeners();
    }

    attributeChangedCallback(name, oldVal, newVal) {
        if (oldVal === newVal) return;
        this._render();
    }

    // 5. 私有方法
    _upgradeProperties() {
        // 处理在元素注册前设置的属性
    }

    _render() {
        // 更新DOM
    }

    _addEventListeners() {
        // 添加事件监听
    }

    _removeEventListeners() {
        // 移除事件监听
    }
}

customElements.define('my-component', MyComponent);

八、常见问题

常见问题

自定义元素未渲染怎么办?

原因是元素定义在DOM解析之后,或命名不规范。解决方案是确保命名包含连字符,使用customElements.whenDefined等待定义完成。

属性初始值获取不到怎么办?

原因是constructor执行时属性可能尚未解析。解决方案是在connectedCallback中读取属性,或使用_upgradeProperties模式处理在元素注册前设置的属性。

Shadow DOM中的事件无法冒泡到外部怎么办?

原因是部分事件在Shadow DOM边界被重定向。解决方案是使用composed: true选项创建自定义事件,让事件穿透Shadow DOM边界。


九、总结

Web Components是浏览器原生的组件化标准,关键要点如下:

  • 三大核心技术:Custom Elements + Shadow DOM + HTML Templates

  • 生命周期:constructor -> connectedCallback -> attributeChangedCallback -> disconnectedCallback

  • 封装隔离:Shadow DOM实现样式和结构的封装

  • 可复用:一次定义,到处使用,不依赖框架

  • 属性观察:通过observedAttributes和attributeChangedCallback响应属性变化

  • 资源清理:在disconnectedCallback中清理定时器和事件监听

  • 事件通信:使用composed: true让自定义事件穿透Shadow DOM

标签: Web Components Custom Elements Shadow DOM HTML Templates 组件化开发 生命周期

本文涉及AI创作

内容由AI创作,请仔细甄别

list快速访问

上一篇: 布局结构:HTML slot插槽 - 完整教程与代码示例 下一篇: 布局结构:HTML Shadow DOM - 完整教程与代码示例

poll相关推荐