Web Components 深入教程:构建可复用的组件

Web Components 深入教程:构建可复用的组件

作为一名资深前端架构师,我很高兴为你带来这份 Web Components 深入教程。在现代前端开发中,组件化是提高代码复用性、可维护性和协作效率的关键。虽然 React、Vue、Angular 等框架提供了强大的组件化能力,但 Web Components 作为浏览器原生支持的技术,为我们提供了一种不依赖任何框架的组件构建方式。理解并掌握它,能让你在任何前端项目中都能构建出独立的、可移植的 UI 元素。

重要提示: 本教程将专注于 Web Components 的原生 API,即:Custom Elements、Shadow DOM、HTML Templates 和 HTML Modules(虽然 HTML Modules 目前支持度不如其他三项,但概念重要)。

一、Web Components 核心概览

Web Components 并非单一技术,而是一套包含多种标准的集合,它们协同工作,使开发者能够创建可复用的自定义元素。

1.1 构成要素

标准 描述 作用
Custom Elements (自定义元素) 允许开发者定义新的 HTML 标签,并扩展现有 HTML 标签。 定义组件的结构和行为。
Shadow DOM (影子 DOM) 为 Web Component 提供独立的 DOM 树和样式作用域,与主文档 DOM 隔离。 实现组件的封装,防止样式和行为泄露。
HTML Templates (<template> 和 <slot>) 定义可复用的 HTML 结构片段,在页面加载时不会渲染。 组件内容的插槽机制和模板复用。
HTML Modules (HTML 模块) 允许通过 <link rel="module"> 导入 HTML 文件作为模块(实验性)。 组件的模块化导入(未来方向)。

1.2 Web Components 工作流

graph TD A[定义 Custom Element 类] --> B{连接到 DOM?} B -- Yes --> C[connectedCallback] B -- No --> D[disconnectedCallback] C --> E[创建 Shadow DOM] E --> F[加载 <template> 内容] F --> G[处理 <slot> 内容] G --> H[渲染组件] H --> I[监听属性变化] I --> J[attributeChangedCallback] J --> H click A "创建 MyElement extends HTMLElement" click B "元素生命周期钩子" click E "attachShadow({mode: 'open' | 'closed'})" click F "template.content.cloneNode(true)" click G "为组件提供插槽" click I "static get observedAttributes()"

核心流程:定义一个 JS 类继承 HTMLElement,在其中创建 Shadow DOM 并挂载 HTML 模板,然后通过 customElements.define() 注册为自定义元素。

二、Custom Elements (自定义元素)

自定义元素是 Web Components 的核心。它们是浏览器知道如何解析和渲染的新 HTML 标签。

2.1 定义一个自定义元素

所有自定义元素都必须继承自 HTMLElement 类。

// 定义一个新的 Custom Element 类
class MySimpleElement extends HTMLElement {
    constructor() {
        super(); // 必须调用父类的 constructor
        console.log('MySimpleElement 实例已创建!');
        // 可以在这里设置初始状态或绑定事件
        this.innerHTML = `<p>Hello from <strong>${this.tagName.toLowerCase()}</strong>!</p>`;
    }

    // 生命周期回调函数
    connectedCallback() {
        console.log('MySimpleElement 已连接到文档 DOM!');
        // 元素首次连接到文档 DOM 时被调用
        // 适合进行初始渲染或添加事件监听器
    }

    disconnectedCallback() {
        console.log('MySimpleElement 已从文档 DOM 移除!');
        // 元素从文档 DOM 移除时被调用
        // 适合进行清理工作,如移除事件监听器
    }

    adoptedCallback() {
        console.log('MySimpleElement 已被移动到新的文档!');
        // 元素被移动到新的文档时被调用(例如,通过 document.adoptNode())
    }

    static get observedAttributes() {
        // 定义要监听的属性列表
        return ['name', 'color'];
    }

    attributeChangedCallback(name, oldValue, newValue) {
        console.log(`属性 ${name} 从 ${oldValue} 变为 ${newValue}`);
        // 监听的属性发生变化时调用
        // 适合根据属性变化更新组件的 UI 或行为
    }
}

// 注册自定义元素
// 标签名必须包含连字符(-),例如 'my-element' 而不是 'myelement'
customElements.define('my-simple-element', MySimpleElement);

在 HTML 中使用:

<my-simple-element></my-simple-element>
<my-simple-element name="World" color="blue"></my-simple-element>

2.2 生命周期回调函数

自定义元素提供了几个钩子函数,用于响应元素在 DOM 中的生命周期事件:

三、Shadow DOM (影子 DOM)

Shadow DOM 解决了 Web Components 的两大核心问题:样式封装和 DOM 封装。它允许你将一个独立的 DOM 子树附加到常规 DOM 元素上。

3.1 什么是 Shadow DOM?

想象一个浏览器内置的 <video> 标签。它的播放、暂停按钮、进度条等都是其内部的 DOM 结构和样式,但你无法通过普通的 CSS 选择器或 JavaScript 访问或修改它们。这就是 Shadow DOM 的作用:提供一个“影子”化的 DOM 树,与主文档 DOM 隔离。

3.2 附加 Shadow DOM

通过元素的 attachShadow() 方法创建并附加一个 Shadow Root:

class MyShadowElement extends HTMLElement {
    constructor() {
        super();
        // 附加 Shadow DOM
        // mode: 'open' 表示可以通过 JavaScript 访问 Shadow DOM(element.shadowRoot)
        // mode: 'closed' 表示外部无法访问,更严格的封装,但实际使用较少
        const shadowRoot = this.attachShadow({ mode: 'open' });

        // 在 Shadow DOM 中添加内容和样式
        shadowRoot.innerHTML = `
            <style>
                /* 这里的样式只作用于 Shadow DOM 内部 */
                p {
                    color: blue;
                    font-size: 18px;
                }
                .highlight {
                    background-color: yellow;
                }
                :host { /* :host 选择器指向 Shadow DOM 的宿主元素本身 */
                    display: block; /* 默认 inline,改为 block 方便布局 */
                    border: 2px solid lightgray;
                    padding: 15px;
                    margin: 10px 0;
                    border-radius: 8px;
                }
                :host-context(body.dark-theme) p {
                    color: lightblue; /* 根据外部主题调整内部样式 */
                }
            </style>
            <p>这是来自 <span class="highlight">Shadow DOM</span> 的内容。</p>
        `;
    }
}

customElements.define('my-shadow-element', MyShadowElement);

在 HTML 中使用:

<my-shadow-element></my-shadow-element>

<!-- 外部样式无法影响 Shadow DOM 内部的 P 标签颜色 -->
<style>
    my-shadow-element p {
        color: red !important; /* 不会生效 */
    }
</style>

3.3 Shadow DOM 中的 CSS 作用域

四、HTML Templates (<template> 和 <slot>)

模板和插槽是 Web Components 实现内容复用和分发的核心机制。

4.1 <template> 元素

<template> 标签内的内容在页面加载时不会被渲染,它是一个被浏览器“惰性”解析的 HTML 片段,直到被 JavaScript 克隆并插入到 DOM 中。

<template id="my-component-template">
    <style>
        div {
            border: 1px solid green;
            padding: 10px;
            margin: 5px;
            background-color: #e6ffe6;
        }
        h4 {
            color: green;
        }
    </style>
    <div>
        <h4>我是模板里的标题</h4>
        <p>这是模板里的默认内容。</p>
    </div>
</template>

<script>
    class MyTemplateElement extends HTMLElement {
        constructor() {
            super();
            const shadowRoot = this.attachShadow({ mode: 'open' });
            const template = document.getElementById('my-component-template');
            // 克隆模板内容并附加到 Shadow DOM
            shadowRoot.appendChild(template.content.cloneNode(true));
        }
    }
    customElements.define('my-template-element', MyTemplateElement);
</script>

<!-- 在 HTML 中使用模板组件 -->
<my-template-element></my-template-element>

4.2 <slot> 元素

<slot> 元素是 Shadow DOM 内部的占位符,它允许将宿主元素内部的子节点“投射”到 Shadow DOM 模板的特定位置。这提供了组件的灵活内容分发能力。

<template id="my-slot-template">
    <style>
        .card {
            border: 1px solid #ddd;
            border-radius: 8px;
            padding: 20px;
            margin: 15px 0;
            background-color: #fff;
            box-shadow: 0 2px 10px rgba(0,0,0,0.08);
        }
        .header {
            font-size: 1.5em;
            margin-bottom: 10px;
            color: #3498db;
        }
        .footer {
            font-size: 0.9em;
            color: #777;
            margin-top: 15px;
            border-top: 1px dashed #eee;
            padding-top: 10px;
        }
    </style>
    <div class="card">
        <div class="header">
            <slot name="card-header">默认卡片标题</slot> <!-- 命名插槽 -->
        </div>
        <div class="body">
            <slot>卡片主体内容(默认匿名插槽)</slot> <!-- 匿名插槽 -->
        </div>
        <div class="footer">
            <slot name="card-footer">默认卡片底部</slot> <!-- 命名插槽 -->
        </div>
    </div>
</template>

<script>
    class MyCard extends HTMLElement {
        constructor() {
            super();
            const shadowRoot = this.attachShadow({ mode: 'open' });
            const template = document.getElementById('my-slot-template');
            shadowRoot.appendChild(template.content.cloneNode(true));
        }
    }
    customElements.define('my-card', MyCard);
</script>

<!-- 在 HTML 中使用 MyCard 组件 -->
<my-card>
    <h2 slot="card-header">我的自定义卡片</h2>
    <p>这是卡片的主体内容。它将被插入到匿名插槽中。</p>
    <ul>
        <li>列表项 1</li>
        <li>列表项 2</li>
    </ul>
    <span slot="card-footer">由 Web Components 提供支持。</span>
</my-card>

<my-card>
    <!-- 没有提供 slot="card-header",所以会显示默认标题 -->
    <!-- 没有提供匿名内容,所以会显示默认主体内容 -->
    <span slot="card-footer">这是一个只有底部内容的卡片。</span>
</my-card>

通过插槽,我们可以构建出高度灵活且可定制的组件,类似 React/Vue 中的 childrenslot 概念。

五、完整的 Web Component 示例:可关闭的警告框

让我们结合 Custom Elements, Shadow DOM 和 Templates,构建一个可关闭的警告框组件。

组件功能:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>可关闭警告框 Web Component</title>
    <!-- 引入 Highlight.js 和 Mermaid.js 样式和脚本 -->
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
    <style>
        /* 为页面的 body 添加一些基本样式,使示例组件更明显 */
        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            margin: 20px;
            background-color: #f0f2f5;
        }
        h2 {
            color: #333;
        }
        .container-demo {
            max-width: 800px;
            margin: 30px auto;
            padding: 25px;
            background-color: #fff;
            border-radius: 8px;
            box-shadow: 0 4px 12px rgba(0,0,0,0.08);
        }
    </style>
</head>
<body>
    <div class="container-demo">
        <h2>可关闭警告框组件示例</h2>

        <!-- 成功提示 -->
        <my-alert type="success">
            <p><strong>恭喜!</strong> 您的操作已成功完成。</p>
        </my-alert>

        <!-- 错误提示 -->
        <my-alert type="error">
            <h4>发生错误!</h4>
            <p>请检查您的输入并重试。</p>
        </my-alert>

        <!-- 警告提示 -->
        <my-alert type="warning">
            这是一个警告消息,请注意!
        </my-alert>

        <!-- 信息提示 -->
        <my-alert type="info">
            这是一个普通信息提示。
        </my-alert>

        <!-- 默认类型 -->
        <my-alert>
            这是一个默认样式的提示。
        </my-alert>

    </div>

    <!-- 警告框组件的 <template> 标签 -->
    <template id="alert-template">
        <style>
            :host {
                display: block; /* 默认 inline,改为 block 方便布局 */
                margin-bottom: 15px;
                font-family: Arial, sans-serif;
                font-size: 16px;
                line-height: 1.5;
            }

            .alert {
                padding: 15px 20px;
                border-radius: 5px;
                display: flex;
                align-items: center;
                justify-content: space-between;
                box-shadow: 0 2px 5px rgba(0,0,0,0.05);
            }

            .alert-content {
                flex-grow: 1;
                margin-right: 15px;
            }

            .close-button {
                background: none;
                border: none;
                font-size: 1.5em;
                color: inherit; /* 继承警告框的文本颜色 */
                cursor: pointer;
                padding: 0 5px;
                line-height: 1;
                opacity: 0.7;
                transition: opacity 0.2s ease;
            }

            .close-button:hover {
                opacity: 1;
            }

            /* 类型样式 */
            :host([type="success"]) .alert {
                background-color: #d4edda;
                color: #155724;
                border: 1px solid #c3e6cb;
            }
            :host([type="error"]) .alert {
                background-color: #f8d7da;
                color: #721c24;
                border: 1px solid #f5c6cb;
            }
            :host([type="warning"]) .alert {
                background-color: #fff3cd;
                color: #856404;
                border: 1px solid #ffeeba;
            }
            :host([type="info"]) .alert {
                background-color: #d1ecf1;
                color: #0c5460;
                border: 1px solid #bee5eb;
            }
            /* 默认样式 */
            :host(:not([type])) .alert {
                background-color: #e2e3e5;
                color: #383d41;
                border: 1px solid #d6d8db;
            }

            /* 插槽内容样式(可选,更建议让插槽内容自行管理样式) */
            /* 但有时可以提供一些默认的排版规则 */
            .alert-content ::slotted(p) {
                margin: 0.5em 0;
            }
            .alert-content ::slotted(h4) {
                margin-top: 0;
                margin-bottom: 0.5em;
            }
        </style>

        <div class="alert">
            <div class="alert-content">
                <slot>默认警告内容。</slot> <!-- 匿名插槽,用于放置警告消息 -->
            </div>
            <button class="close-button" aria-label="关闭">&times;</button> <!-- 关闭按钮 -->
        </div>
    </template>

    <script>
        class MyAlert extends HTMLElement {
            constructor() {
                super();
                // 附加 Shadow DOM
                const shadowRoot = this.attachShadow({ mode: 'open' });

                // 获取模板内容并克隆
                const template = document.getElementById('alert-template');
                const content = template.content.cloneNode(true);
                shadowRoot.appendChild(content);

                // 获取关闭按钮并绑定事件
                this.closeButton = shadowRoot.querySelector('.close-button');
                this.closeButton.addEventListener('click', () => this.hide());
            }

            // 生命周期钩子:当元素被添加到文档时
            connectedCallback() {
                console.log('MyAlert connected to DOM.');
                // 可以在这里根据初始属性设置状态
                this.updateVisibility();
            }

            // 生命周期钩子:定义需要观察的属性
            static get observedAttributes() {
                return ['type', 'hidden']; // 观察 'type' 和 'hidden' 属性
            }

            // 生命周期钩子:当观察的属性发生变化时
            attributeChangedCallback(name, oldValue, newValue) {
                if (name === 'hidden') {
                    this.updateVisibility();
                }
                // 'type' 属性的变化由 CSS :host 选择器处理,无需额外 JS
                console.log(`Attribute '${name}' changed from '${oldValue}' to '${newValue}'`);
            }

            // 隐藏警告框
            hide() {
                this.setAttribute('hidden', ''); // 添加 hidden 属性,利用浏览器原生隐藏功能
                // 或者直接设置样式:this.style.display = 'none';
                console.log('MyAlert hidden.');

                // 可以触发一个自定义事件通知外部组件被关闭
                this.dispatchEvent(new CustomEvent('alert-closed', {
                    bubbles: true, // 事件冒泡
                    composed: true // 事件穿透 Shadow DOM 边界
                }));
            }

            // 显示警告框
            show() {
                this.removeAttribute('hidden');
                // 或者设置样式:this.style.display = 'block';
                console.log('MyAlert shown.');
            }

            // 根据 hidden 属性更新元素的显示/隐藏状态
            updateVisibility() {
                if (this.hasAttribute('hidden')) {
                    this.style.display = 'none';
                } else {
                    this.style.display = ''; // 恢复默认 display 样式
                }
            }
        }

        // 定义自定义元素
        customElements.define('my-alert', MyAlert);

        // 外部监听事件示例
        document.addEventListener('alert-closed', (event) => {
            console.log('接收到 alert-closed 事件:', event.target.tagName);
            // 可以在这里执行一些清理或日志记录
        });
    </script>

    <!-- Highlight.js script -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
    <script>hljs.highlightAll();</script>

    <!-- Mermaid.js script -->
    <script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
    <script>
        mermaid.initialize({
            startOnLoad: true,
            theme: 'default',
            securityLevel: 'loose',
            flowchart: {
                useMaxWidth: true,
                htmlLabels: true
            }
        });
    </script>
</body>
</html>

六、Web Components 的高级主题与最佳实践

6.1 事件:原生事件与自定义事件

6.2 属性 (Attributes) vs. 属性 (Properties)

6.3 表单关联 (Form-associated Custom Elements)

自 HTML5 以来,自定义元素可以通过实现 formAssociated 静态属性并提供特定的生命周期方法,使其能够像原生表单元素一样参与表单提交和验证。

class MyFormControl extends HTMLElement {
    static formAssociated = true; // 声明为表单关联元素

    constructor() {
        super();
        this.internals = this.attachInternals(); // 获取 ElementInternals 对象
        this.value = ''; // 内部状态
    }

    set value(val) {
        this._value = val;
        // 更新表单值
        this.internals.setFormValue(val);
    }

    get value() {
        return this._value;
    }

    // 监听 attributeChange,例如 'value' attribute
    static get observedAttributes() { return ['value']; }
    attributeChangedCallback(name, oldValue, newValue) {
        if (name === 'value' && this.value !== newValue) {
            this.value = newValue;
        }
    }

    // 表单相关回调
    formAssociatedCallback(form) { /* ... */ }
    formResetCallback() { /* ... */ }
    formDisabledCallback(disabled) { /* ... */ }
    formStateRestoreCallback(state, mode) { /* ... */ }
}
customElements.define('my-form-control', MyFormControl);

这对于构建复杂的、可验证的自定义表单输入组件非常有用。

6.4 CSS Modules 与打包工具

虽然 Web Components 本身是原生的,但在大型项目中,你仍然会使用 Webpack, Rollup, Vite 等打包工具。这些工具可以帮助你:

例如,使用打包工具,你可以将样式直接导入到 JavaScript 文件中,然后注入 Shadow DOM。

// component.js (通过打包工具处理)
import styles from './component.css'; // 假设打包工具可以处理 CSS 导入

class MyComponent extends HTMLElement {
    constructor() {
        super();
        const shadowRoot = this.attachShadow({ mode: 'open' });
        shadowRoot.innerHTML = `
            <style>${styles}</style>
            <div>Hello from component!</div>
        `;
    }
}
customElements.define('my-packed-component', MyComponent);

七、Web Components 的优势与局限性

7.1 优势

7.2 局限性

八、总结与展望

Web Components 是一项强大的技术,为构建可复用、可互操作的 Web 组件提供了标准化的解决方案。它们是构建设计系统、独立组件库的理想选择,尤其适用于需要“框架无关”的场景。

虽然它们可能不适用于所有类型的应用(特别是大型、复杂的单页应用,框架可能提供更高的开发效率),但理解 Web Components 的原理和能力,能够让你更好地选择工具,并在必要时构建出与任何框架和谐共存的独立组件。

随着浏览器对更多 Web Platform 特性的支持日益完善,以及社区工具的不断发展,Web Components 必将在未来的 Web 开发中扮演越来越重要的角色。

感谢您的阅读,希望本教程能帮助您掌握 Web Components 的精髓!

互动区域

登录后可以点赞此内容

参与互动

登录后可以点赞和评论此内容,与作者互动交流