零基础也能玩转的Web Components原生组件化开发入门

独立开发小站
2026-07-24 21:55
阅读 333

大家好,我是一名开源项目的维护者,平时除了写代码、维护项目,最喜欢的就是写技术文档和教程。最近有很多刚入门前端的小伙伴私信问我:"现在前端框架这么多,Vue、React都学不过来,为什么还要学Web Components?"说实话,我当初学的时候也有这个疑惑。但后来我发现,Web Components是浏览器原生支持的组件化方案,不依赖任何框架,理解它对你学习任何框架都有帮助。今天这篇教程,我就用最通俗的语言,带你从零开始掌握Web Components。


一、Web Components到底是什么?

简单来说,Web Components是一套浏览器原生提供的API,让你可以创建自定义的、可复用的HTML元素

你可以把它想象成"乐高积木"——你可以定义一块积木(组件),然后在任何地方反复使用它,而且每块积木内部的样式和逻辑都是独立的,不会互相影响。

Web Components的三大核心技术

技术 作用 通俗理解
Custom Elements 定义自定义HTML标签 教浏览器认识一个新标签
Shadow DOM 封装组件内部的DOM和样式 给组件加一个"保护罩",外面进不来
HTML Templates 定义可复用的HTML模板 提前画好积木的设计图

二、环境准备

学Web Components不需要安装任何额外的工具,只要有一个现代浏览器和一个文本编辑器就够了。

2.1 浏览器要求

Web Components在以下浏览器中都有良好支持:

  • Chrome 54+
  • Firefox 63+
  • Safari 10.1+
  • Edge 79+

我当初学的时候,还用IE测试过,结果一片空白,差点以为自己代码写错了。所以一定要用现代浏览器!

2.2 开发工具

推荐使用以下任意一种编辑器:

  • VS Code(推荐,免费且插件丰富)
  • WebStorm(功能强大,付费)
  • Sublime Text(轻量级)

2.3 创建项目文件

新建一个文件夹,比如叫 web-components-demo,然后在里面创建一个 index.html 文件:

<!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>
</head>
<body>
    <h1>我的第一个Web Component</h1>
    
    <script>
        // 我们的组件代码将写在这里
    </script>
</body>
</html>

直接用浏览器打开这个HTML文件,就可以开始学习了。


三、核心概念详解

3.1 Custom Elements(自定义元素)

Custom Elements让你定义自己的HTML标签。比如你可以创建一个叫 <my-card> 的标签,然后在页面里像用 <div> 一样使用它。

基本语法:

class MyCard extends HTMLElement {
    constructor() {
        super(); // 必须先调用super()
        // 初始化代码写在这里
    }
}

// 注册自定义元素
customElements.define('my-card', MyCard);

⚠️ 重要规则: 自定义标签名必须包含一个连字符 -,比如 my-carduser-profile。这是为了和浏览器内置标签区分开。

完整示例:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>Custom Elements示例</title>
</head>
<body>
    <my-greeting></my-greeting>

    <script>
        class MyGreeting extends HTMLElement {
            constructor() {
                super();
                this.innerHTML = '<h2>你好,欢迎来到Web Components的世界!</h2>';
            }
        }
        customElements.define('my-greeting', MyGreeting);
    </script>
</body>
</html>

3.2 生命周期回调函数

自定义元素有几个关键的生命周期函数,你可以在这些时机执行代码:

回调函数 触发时机 常见用途
constructor() 元素被创建时 初始化状态、创建Shadow DOM
connectedCallback() 元素被插入到DOM时 渲染内容、添加事件监听
disconnectedCallback() 元素从DOM中移除时 清理工作、移除事件监听
attributeChangedCallback() 属性发生变化时 根据属性变化更新组件

代码示例:

class MyTimer extends HTMLElement {
    constructor() {
        super();
        console.log('1. 构造函数被调用');
    }

    connectedCallback() {
        console.log('2. 元素被插入到页面中');
        this.innerHTML = '<p>计时器组件已加载</p>';
    }

    disconnectedCallback() {
        console.log('3. 元素从页面中移除了');
    }
}

customElements.define('my-timer', MyTimer);

3.3 Shadow DOM(影子DOM)

Shadow DOM是Web Components中最重要的概念之一。它的作用是样式隔离——组件内部的样式不会泄漏到外面,外面的样式也不会影响里面。

通俗理解: 想象你在一个房间里装修,不管你怎么折腾,都不会影响到隔壁房间。Shadow DOM就是这个"房间"。

创建Shadow DOM:

class MyComponent extends HTMLElement {
    constructor() {
        super();
        // 创建Shadow DOM,mode设为'open'表示可以从外部访问
        const shadow = this.attachShadow({ mode: 'open' });
        
        // 在Shadow DOM中添加内容
        shadow.innerHTML = `
            <style>
                h2 {
                    color: blue;
                    font-family: Arial, sans-serif;
                }
            </style>
            <h2>这个标题是蓝色的,而且不会受到外部样式影响</h2>
        `;
    }
}

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

样式隔离演示:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>Shadow DOM样式隔离</title>
    <style>
        /* 外部样式:所有h2都是红色 */
        h2 {
            color: red;
        }
    </style>
</head>
<body>
    <h2>这个是外部的h2,是红色的</h2>
    <my-box></my-box>

    <script>
        class MyBox extends HTMLElement {
            constructor() {
                super();
                const shadow = this.attachShadow({ mode: 'open' });
                shadow.innerHTML = `
                    <style>
                        h2 {
                            color: green;
                        }
                    </style>
                    <h2>这个是Shadow DOM里的h2,是绿色的</h2>
                `;
            }
        }
        customElements.define('my-box', MyBox);
    </script>
</body>
</html>

3.4 HTML Templates(模板)

<template> 标签用来定义一段不会立即渲染的HTML模板,等到需要的时候再用JavaScript把它"激活"。

基本用法:

<!-- 定义模板 -->
<template id="card-template">
    <style>
        .card {
            border: 1px solid #ccc;
            border-radius: 8px;
            padding: 16px;
            margin: 8px;
        }
        .card-title {
            font-size: 18px;
            font-weight: bold;
        }
    </style>
    <div class="card">
        <div class="card-title"></div>
        <div class="card-content"></div>
    </div>
</template>

<script>
    class MyCard extends HTMLElement {
        constructor() {
            super();
            const shadow = this.attachShadow({ mode: 'open' });
            
            // 获取模板
            const template = document.getElementById('card-template');
            // 克隆模板内容
            const content = template.content.cloneNode(true);
            
            // 添加到Shadow DOM
            shadow.appendChild(content);
        }
    }
    customElements.define('my-card', MyCard);
</script>

四、属性和插槽

4.1 自定义属性(Attributes)

组件通常需要接收外部传入的数据,这就需要通过属性来实现。

完整示例:一个可配置的用户卡片组件

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>自定义属性示例</title>
</head>
<body>
    <user-card name="张三" role="前端工程师" avatar="👨‍💻"></user-card>
    <user-card name="李四" role="后端工程师" avatar="👨‍🔧"></user-card>

    <script>
        class UserCard extends HTMLElement {
            // 声明需要监听的属性列表
            static get observedAttributes() {
                return ['name', 'role', 'avatar'];
            }

            constructor() {
                super();
                this.attachShadow({ mode: 'open' });
                this.render();
            }

            // 当属性变化时触发
            attributeChangedCallback(attrName, oldVal, newVal) {
                this.render();
            }

            // 渲染方法
            render() {
                const name = this.getAttribute('name') || '未知用户';
                const role = this.getAttribute('role') || '暂无职位';
                const avatar = this.getAttribute('avatar') || '👤';

                this.shadowRoot.innerHTML = `
                    <style>
                        .user-card {
                            border: 2px solid #4CAF50;
                            border-radius: 12px;
                            padding: 20px;
                            margin: 10px;
                            display: inline-block;
                            text-align: center;
                            width: 200px;
                            font-family: sans-serif;
                        }
                        .avatar {
                            font-size: 48px;
                        }
                        .name {
                            font-size: 20px;
                            font-weight: bold;
                            margin: 10px 0 5px;
                        }
                        .role {
                            color: #666;
                            font-size: 14px;
                        }
                    </style>
                    <div class="user-card">
                        <div class="avatar">${avatar}</div>
                        <div class="name">${name}</div>
                        <div class="role">${role}</div>
                    </div>
                `;
            }
        }

        customElements.define('user-card', UserCard);
    </script>
</body>
</html>

4.2 插槽(Slots)

插槽允许你在组件中预留"空位",让使用者可以往里面填充自定义内容。这就像给组件留了一个"口袋",想装什么装什么。

插槽示例:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>插槽示例</title>
</head>
<body>
    <my-dialog>
        <h2 slot="title">提示</h2>
        <p slot="content">你确定要删除这条记录吗?</p>
        <button slot="footer" onclick="alert('确认删除')">确认</button>
        <button slot="footer" onclick="alert('取消')">取消</button>
    </my-dialog>

    <script>
        class MyDialog extends HTMLElement {
            constructor() {
                super();
                const shadow = this.attachShadow({ mode: 'open' });
                shadow.innerHTML = `
                    <style>
                        .dialog {
                            border: 1px solid #333;
                            border-radius: 8px;
                            padding: 20px;
                            max-width: 400px;
                            box-shadow: 0 4px 6px rgba(0,0,0,0.1);
                        }
                        .dialog-header {
                            border-bottom: 1px solid #eee;
                            padding-bottom: 10px;
                            margin-bottom: 10px;
                        }
                        .dialog-footer {
                            border-top: 1px solid #eee;
                            padding-top: 10px;
                            margin-top: 10px;
                            text-align: right;
                        }
                    </style>
                    <div class="dialog">
                        <div class="dialog-header">
                            <slot name="title">默认标题</slot>
                        </div>
                        <div class="dialog-body">
                            <slot name="content">默认内容</slot>
                        </div>
                        <div class="dialog-footer">
                            <slot name="footer">
                                <button>关闭</button>
                            </slot>
                        </div>
                    </div>
                `;
            }
        }

        customElements.define('my-dialog', MyDialog);
    </script>
</body>
</html>

五、实战项目:一个完整的Todo List组件

现在我们来做一个稍微复杂一点的项目——一个Todo List组件。这个项目会综合运用前面学到的所有知识。

5.1 项目结构规划

步骤说明:
1. 定义 <todo-list> 自定义组件
2. 使用 Shadow DOM 封装样式
3. 使用 <template> 定义模板
4. 通过属性接收初始数据
5. 使用插槽自定义底部内容
6. 添加交互逻辑(添加、删除、标记完成)

5.2 完整代码

<!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 Todo List</title>
    <style>
        body {
            font-family: 'Segoe UI', sans-serif;
            max-width: 600px;
            margin: 40px auto;
            padding: 0 20px;
            background: #f5f5f5;
        }
        h1 {
            text-align: center;
            color: #333;
        }
    </style>
</head>
<body>
    <h1>📝 我的待办事项</h1>
    
    <todo-list>
        <div slot="footer">
            <small>提示:双击任务可以标记完成</small>
        </div>
    </todo-list>

    <!-- 定义模板 -->
    <template id="todo-template">
        <style>
            .todo-container {
                background: white;
                border-radius: 12px;
                padding: 24px;
                box-shadow: 0 2px 8px rgba(0,0,0,0.1);
            }
            .input-area {
                display: flex;
                gap: 8px;
                margin-bottom: 16px;
            }
            .input-area input {
                flex: 1;
                padding: 10px 14px;
                border: 2px solid #ddd;
                border-radius: 6px;
                font-size: 14px;
                outline: none;
                transition: border-color 0.2s;
            }
            .input-area input:focus {
                border-color: #4CAF50;
            }
            .input-area button {
                padding: 10px 20px;
                background: #4CAF50;
                color: white;
                border: none;
                border-radius: 6px;
                cursor: pointer;
                font-size: 14px;
                transition: background 0.2s;
            }
            .input-area button:hover {
                background: #45a049;
            }
            .todo-list {
                list-style: none;
                padding: 0;
                margin: 0;
            }
            .todo-item {
                display: flex;
                align-items: center;
                padding: 12px;
                border-bottom: 1px solid #eee;
                transition: background 0.2s;
            }
            .todo-item:hover {
                background: #f9f9f9;
            }
            .todo-item:last-child {
                border-bottom: none;
            }
            .todo-item.completed .todo-text {
                text-decoration: line-through;
                color: #999;
            }
            .todo-checkbox {
                width: 20px;
                height: 20px;
                margin-right: 12px;
                cursor: pointer;
                accent-color: #4CAF50;
            }
            .todo-text {
                flex: 1;
                font-size: 15px;
                cursor: pointer;
            }
            .todo-delete {
                background: none;
                border: none;
                color: #e74c3c;
                cursor: pointer;
                font-size: 18px;
                padding: 4px 8px;
                border-radius: 4px;
                transition: background 0.2s;
            }
            .todo-delete:hover {
                background: #fde8e8;
            }
            .todo-stats {
                margin-top: 16px;
                padding-top: 12px;
                border-top: 1px solid #eee;
                color: #666;
                font-size: 13px;
            }
            .empty-msg {
                text-align: center;
                color: #999;
                padding: 20px;
                font-style: italic;
            }
        </style>
        <div class="todo-container">
            <div class="input-area">
                <input type="text" placeholder="输入新的待办事项..." />
                <button class="add-btn">添加</button>
            </div>
            <ul class="todo-list"></ul>
            <div class="todo-stats"></div>
            <slot name="footer"></slot>
        </div>
    </template>

    <script>
        class TodoList extends HTMLElement {
            constructor() {
                super();
                // 创建Shadow DOM
                const shadow = this.attachShadow({ mode: 'open' });
                
                // 克隆模板
                const template = document.getElementById('todo-template');
                const content = template.content.cloneNode(true);
                shadow.appendChild(content);

                // 内部状态
                this._todos = [];

                // 获取DOM引用
                this._input = shadow.querySelector('input');
                this._addBtn = shadow.querySelector('.add-btn');
                this._list = shadow.querySelector('.todo-list');
                this._stats = shadow.querySelector('.todo-stats');

                // 绑定事件
                this._addBtn.addEventListener('click', () => this.addTodo());
                this._input.addEventListener('keypress', (e) => {
                    if (e.key === 'Enter') this.addTodo();
                });

                // 初始渲染
                this.render();
            }

            // 添加待办
            addTodo() {
                const text = this._input.value.trim();
                if (!text) return;

                this._todos.push({
                    id: Date.now(),
                    text: text,
                    completed: false
                });

                this._input.value = '';
                this.render();
            }

            // 删除待办
            deleteTodo(id) {
                this._todos = this._todos.filter(todo => todo.id !== id);
                this.render();
            }

            // 切换完成状态
            toggleTodo(id) {
                const todo = this._todos.find(t => t.id === id);
                if (todo) {
                    todo.completed = !todo.completed;
                    this.render();
                }
            }

            // 渲染列表
            render() {
                if (this._todos.length === 0) {
                    this._list.innerHTML = '<li class="empty-msg">暂无待办事项,添加一个吧!</li>';
                } else {
                    this._list.innerHTML = this._todos.map(todo => `
                        <li class="todo-item ${todo.completed ? 'completed' : ''}">
                            <input 
                                type="checkbox" 
                                class="todo-checkbox" 
                                ${todo.completed ? 'checked' : ''}
                                data-id="${todo.id}"
                            />
                            <span class="todo-text" data-id="${todo.id}">${todo.text}</span>
                            <button class="todo-delete" data-id="${todo.id}">✕</button>
                        </li>
                    `).join('');

                    // 绑定列表项事件
                    this._list.querySelectorAll('.todo-checkbox').forEach(cb => {
                        cb.addEventListener('change', (e) => {
                            this.toggleTodo(Number(e.target.dataset.id));
                        });
                    });

                    this._list.querySelectorAll('.todo-text').forEach(span => {
                        span.addEventListener('dblclick', (e) => {
                            this.toggleTodo(Number(e.target.dataset.id));
                        });
                    });

                    this._list.querySelectorAll('.todo-delete').forEach(btn => {
                        btn.addEventListener('click', (e) => {
                            this.deleteTodo(Number(e.target.dataset.id));
                        });
                    });
                }

                // 更新统计
                const total = this._todos.length;
                const completed = this._todos.filter(t => t.completed).length;
                this._stats.textContent = `共 ${total} 项,已完成 ${completed} 项`;
            }
        }

        customElements.define('todo-list', TodoList);
    </script>
</body>
</html>

5.3 代码解析

这个Todo List组件涵盖了Web Components的核心知识点:

  1. Custom Elements:定义了 <todo-list> 自定义标签
  2. Shadow DOM:所有样式都被封装在Shadow DOM中,不会污染外部
  3. HTML Templates:使用 <template> 定义了组件的HTML和CSS结构
  4. Slots:底部有一个具名插槽 footer,使用者可以自定义底部内容
  5. 生命周期:在 constructor 中完成初始化工作
  6. 事件处理:组件内部自己处理所有交互逻辑

六、Web Components与主流框架的关系

很多初学者会问:"我都学React了,还有必要学Web Components吗?"这里我用一个表格来对比说明:

对比维度 Web Components React Vue
是否依赖框架 ❌ 不依赖 ✅ 依赖React ✅ 依赖Vue
浏览器原生支持 ✅ 原生支持 ❌ 需要编译 ❌ 需要编译
跨框架复用 ✅ 任何框架都能用 ❌ 只能在React中用 ❌ 只能在Vue中用
学习曲线 中等 较陡 较陡
生态系统 较小 非常庞大 非常庞大
状态管理 需要自己实现 Redux/Zustand等 Pinia/Vuex等

我的建议: Web Components和框架不是对立的,而是互补的。实际上,React和Vue都在逐步加强对Web Components的支持。理解Web Components的底层原理,能让你更好地使用任何框架。

说到这里,最近AI编程工具发展很快,像Perplexity这样的AI搜索引擎可以帮你快速查找Web Components的最新资料和最佳实践。而像Devin这样的AI编程助手,甚至可以根据你的需求自动生成Web Components代码。但我建议初学者还是先手动写一遍,理解了原理之后再用AI工具提效,这样基础才会扎实。


七、常见问题解答

Q1:自定义标签名必须包含连字符吗?

是的。 这是HTML规范的要求,目的是避免和浏览器内置标签冲突。比如 my-button 是合法的,但 mybutton 就不行。

Q2:Shadow DOM里的元素能用 document.querySelector 获取吗?

不能直接获取。 Shadow DOM的内容对外部是隐藏的。你需要先获取Shadow Root,再在里面查询:

const host = document.querySelector('my-component');
const shadow = host.shadowRoot;
const innerEl = shadow.querySelector('.some-class');

Q3:Web Components支持CSS预处理器吗?

原生不支持,但你可以通过构建工具来实现。比如用Webpack或Vite把SCSS/Less编译成CSS,再注入到Shadow DOM中。

Q4:组件之间怎么通信?

有几种常见方式:

  • 自定义事件(推荐):组件通过 dispatchEvent 发出事件,外部监听
  • 属性传递:父组件通过属性向子组件传数据
  • 全局状态:使用全局变量或事件总线

自定义事件示例:

// 组件内部发出事件
this.dispatchEvent(new CustomEvent('item-selected', {
    detail: { id: 1, name: '测试项' },
    bubbles: true,    // 允许事件冒泡
    composed: true    // 允许事件穿透Shadow DOM
}));

// 外部监听事件
document.querySelector('my-component').addEventListener('item-selected', (e) => {
    console.log('选中了:', e.detail);
});

Q5:我当初学的时候踩过的坑

  1. 忘记调用 super():在 constructor 中第一行必须调用 super(),否则会报错
  2. 标签名没加连字符:浏览器会直接忽略不合法的标签名
  3. Shadow DOM的mode搞混open 表示外部可以通过 element.shadowRoot 访问,closed 则不行。一般用 open 就好
  4. 模板没有克隆:直接用 template.content 会导致模板被"消耗",第二次使用就空了。一定要用 cloneNode(true)

八、学习建议与下一步

恭喜你读到这里!你已经掌握了Web Components的核心知识。接下来我给你一些学习路径建议:

第一阶段:巩固基础(1-2周)

  • 把本文的Todo List项目自己从头写一遍
  • 尝试给组件添加更多功能,比如编辑任务、筛选已完成/未完成
  • 练习使用自定义事件进行组件通信

第二阶段:进阶学习(2-4周)

  • 学习如何使用构建工具(Vite/Webpack)开发Web Components
  • 了解Lit库(Google出品的Web Components框架,大幅简化开发)
  • 学习如何将Web Components发布为npm包

第三阶段:实战应用(持续)

  • 用Web Components重构你现有项目中的通用组件
  • 构建一个自己的组件库
  • 探索Web Components在微前端架构中的应用

推荐资源

  • MDN Web Docs:最权威的Web Components文档
  • webcomponents.org:Web Components官方网站,有大量教程和示例
  • Perplexity:遇到不懂的问题,可以用AI搜索引擎快速找到答案
  • GitHub:搜索 web components 相关的开源项目,学习别人的代码

总结

Web Components是前端组件化的"原生方案",它不依赖任何框架,由浏览器直接支持。掌握了它,你不仅能写出更轻量的组件,还能更好地理解React、Vue等框架的设计思想。

回顾一下今天学到的核心内容:

知识点 核心要点
Custom Elements customElements.define() 注册自定义标签
Shadow DOM attachShadow() 创建样式隔离的DOM树
HTML Templates <template> 定义可复用的HTML模板
属性与插槽 getAttribute() 读取属性,用 <slot> 分发内容
生命周期 constructorconnectedCallbackdisconnectedCallbackattributeChangedCallback

我当初学Web Components的时候,最大的收获不是学会了某个API,而是理解了"组件化"的本质——封装、复用、隔离。这些思想在任何框架中都是通用的。

希望这篇教程能帮你顺利入门Web Components。如果有任何问题,欢迎在评论区留言讨论。记住,编程学习最重要的是动手实践,光看不练是学不会的。赶紧打开编辑器,把代码敲起来吧!

评论 0

最热最新
暂无评论
独立开发小站Lv.1
0
影响力
0
文章
0
粉丝