零基础也能玩转的Web Components原生组件化开发入门
大家好,我是一名开源项目的维护者,平时除了写代码、维护项目,最喜欢的就是写技术文档和教程。最近有很多刚入门前端的小伙伴私信问我:"现在前端框架这么多,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-card、user-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的核心知识点:
- Custom Elements:定义了
<todo-list>自定义标签 - Shadow DOM:所有样式都被封装在Shadow DOM中,不会污染外部
- HTML Templates:使用
<template>定义了组件的HTML和CSS结构 - Slots:底部有一个具名插槽
footer,使用者可以自定义底部内容 - 生命周期:在
constructor中完成初始化工作 - 事件处理:组件内部自己处理所有交互逻辑
六、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:我当初学的时候踩过的坑
- 忘记调用
super():在constructor中第一行必须调用super(),否则会报错 - 标签名没加连字符:浏览器会直接忽略不合法的标签名
- Shadow DOM的mode搞混:
open表示外部可以通过element.shadowRoot访问,closed则不行。一般用open就好 - 模板没有克隆:直接用
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> 分发内容 |
| 生命周期 | constructor、connectedCallback、disconnectedCallback、attributeChangedCallback |
我当初学Web Components的时候,最大的收获不是学会了某个API,而是理解了"组件化"的本质——封装、复用、隔离。这些思想在任何框架中都是通用的。
希望这篇教程能帮你顺利入门Web Components。如果有任何问题,欢迎在评论区留言讨论。记住,编程学习最重要的是动手实践,光看不练是学不会的。赶紧打开编辑器,把代码敲起来吧!


评论 0