第9章 9.1 Web Components
上一章我们学会了用 Vite 搭建现代项目,开发效率翻倍。但你有没有想过:Vite 帮我们打包的那些组件,能不能像乐高积木一样,一次构建、到处使用?比如你在 A 项目写了一个按钮组件,能不能直接拿到 B 项目用,不用复制粘贴、不用担心样式冲突?
这就是 Web Components 要解决的问题。
🎯 开场 3 分钟:为什么要学这个?
场景引入
想象你在一栋写字楼上班。每天早上要刷卡进门,中午去食堂吃饭,晚上加班打车。每栋楼都有自己的门禁卡、食堂卡、车票系统——每张卡只能在这栋楼用。
这就是前端开发的现状:React 项目的按钮组件,直接拿到 Vue 项目里用?门都没有。样式会打架,逻辑会冲突,你只能"翻译"一遍。
但如果有一张全国通卡呢?不管去哪栋楼,刷卡就进。这就是 Web Components 想要做到的事——一次编写,到处运行,不依赖任何框架。
痛点来了
你可能遇到过这些糟心事:
- 组件复制来复制去:项目 A 的轮\n\n
\n\n
\n\n播图组件,想给项目 B 用?复制代码、适配样式、改 class 名,一顿操作猛如虎 - 样式污染:你写的
.button样式,莫名其妙被别人的 CSS 覆盖了,按钮变成方块 - 团队协作鸡同鸭讲:React 团队写的组件,Vue 团队说"用不了",各自造轮子
学完能解决什么
学完这一章,你将能够:
- 写出自定义 HTML 标签,比如
<my-button>、<user-card> - 让组件样式隔离,自己的 CSS 不影响别人,别人的 CSS 也影响不了你
- 组件像 MP3 一样即插即用,不管项目用 React 还是 Vue 还是原生 HTML
🧱 基础 25 分钟:核心概念
Web Components 不是一项技术,而是三项技术的组合拳:
| 技术 | 作用 | 类比 |
|---|---|---|
| Custom Elements | 自定义 HTML 标签 | 发明新词汇 |
| Shadow DOM | 样式隔离 | 密封塑料袋 |
| HTML Templates | 模板复用 | 模具 |
| (可选) Lit | 简化开发 | 电动车 |
概念 1:Custom Elements——发明新词汇
是什么:允许你创建全新的 HTML 标签,比如 <hello-world>,浏览器会认得它是一个"元素"。
为什么要用:原生 HTML 标签有限,比如 <button>、<input>。但你可以创造业务相关的标签,比如 <product-card>、<weather-widget>,代码读起来像说话。
怎么用:
// 定义一个自定义元素
class HelloWorld extends HTMLElement {
connectedCallback() {
this.textContent = '你好,世界!';
}
}
// 注册这个元素
customElements.define('hello-world', HelloWorld);
然后在 HTML 里直接用:
<hello-world></hello-world>
运行结果:
你好,世界!
这行在干嘛:自定义元素必须继承 HTMLElement,connectedCallback 相当于组件"出生"时执行的构造函数。
概念 2:Shadow DOM——给自己的 CSS 戴口罩
是什么:给组件创造一个"平行宇宙",组件内部的 CSS 和外面完全隔离,就像塑料袋里的东西不会漏出来。
为什么要用:你写的 .button { border-radius: 50% } ,不想影响外面的按钮;同理,外面的全局样式也别想污染你的组件。
怎么用:
class MyCard extends HTMLElement {
constructor() {
super();
// 开启 Shadow DOM,open 表示 JS 可以访问
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
.card {
border: 2px solid #3498db;
padding: 20px;
border-radius: 10px;
background: #f0f8ff;
}
.title {
color: #2c3e50;
font-size: 20px;
}
</style>
<div class="card">
<h2 class="title">我是卡片</h2>
<p>我在 Shadow DOM 里,样式不会泄露出去</p>
</div>
`;
}
}
customElements.define('my-card', MyCard);
<my-card></my-card>
运行结果:
┌──────────────────────────┐
│ 我是卡片 │
│ 我在 Shadow DOM 里, │
│ 样式不会泄露出去 │
└──────────────────────────┘
这行在干嘛:attachShadow({ mode: 'open' }) 打开了一个独立的空间,组件的 HTML 和 CSS 都在这个空间里,外面的样式表进不来。
概念 3:HTML Templates——模具
是什么:<template> 标签定义 HTML 模板,但不渲染;<slot> 标签定义"插槽",让外部内容可以插进来。
为什么要用:你想复用一段 HTML 结构,但里面的内容每次不一样。比如一个对话框,标题和按钮可能变,但骨架不变。
怎么用:
class ConfirmDialog extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
// 从模板克隆内容
const template = document.getElementById('dialog-template');
shadow.appendChild(template.content.cloneNode(true));
}
}
customElements.define('confirm-dialog', ConfirmDialog);
<!-- 定义模板 -->
<template id="dialog-template">
<style>
.dialog {
border: 1px solid #ddd;
padding: 20px;
background: white;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
</style>
<div class="dialog">
<slot name="title">默认标题</slot>
<slot name="content">默认内容</slot>
</div>
</template>
<!-- 使用组件 -->
<confirm-dialog>
<span slot="title">确认删除?</span>
<span slot="content">删除后无法恢复</span>
</confirm-dialog>
运行结果:
┌─────────────────────────┐
│ 确认删除? │
│ 删除后无法恢复 │
└─────────────────────────┘
这行在干嘛:<slot name="xxx"> 定义了插槽位置,slot="xxx" 的元素会插到对应位置。
概念 4:Lit——电动爹(简化开发)
是什么:Google 开发的库,让 Web Components 写起来更简单。响应式、自动更新、模板字符串一套带走。
为什么要用:纯原生 Web Components 写起来有点繁琐,Lit 帮你简化到几行代码。
怎么用(先安装,后续项目会用到):
npm install lit
import { LitElement, html, css } from 'lit';
class MyButton extends LitElement {
static styles = css`
button {
background: #3498db;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
}
button:hover {
background: #2980b9;
}
`;
static properties = {
label: { type: String }
};
constructor() {
super();
this.label = '点击我';
}
render() {
return html`<button>${this.label}</button>`;
}
}
customElements.define('my-button', MyButton);
<my-button label="提交订单"></my-button>
这行在干嘛:static properties 定义响应式属性,render() 返回 HTML 模板,属性变化时自动重新渲染。
🔥 实战 35 分钟:3 个递进的小项目
项目 1(5 分钟):计数器组件
目标:做一个 <counter-app> 组件,点击按钮数字加减。
完整代码:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>计数器组件</title>
</head>
<body>
<counter-app start="0"></counter-app>
<script>
class CounterApp extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
this.count = parseInt(this.getAttribute('start')) || 0;
shadow.innerHTML = `
<style>
.container {
font-family: Arial, sans-serif;
text-align: center;
padding: 20px;
border: 2px solid #2ecc71;
border-radius: 10px;
width: 200px;
}
.count {
font-size: 48px;
color: #2c3e50;
margin: 10px 0;
}
.btn-group {
display: flex;
gap: 10px;
justify-content: center;
}
button {
padding: 8px 16px;
font-size: 16px;
cursor: pointer;
border: none;
border-radius: 5px;
}
.minus { background: #e74c3c; color: white; }
.plus { background: #2ecc71; color: white; }
</style>
<div class="container">
<div class="count">${this.count}</div>
<div class="btn-group">
<button class="minus">-</button>
<button class="plus">+</button>
</div>
</div>
`;
// 绑定事件
shadow.querySelector('.minus').onclick = () => this.decrease();
shadow.querySelector('.plus').onclick = () => this.increase();
}
decrease() {
this.count--;
this.updateDisplay();
}
increase() {
this.count++;
this.updateDisplay();
}
updateDisplay() {
this.shadowRoot.querySelector('.count').textContent = this.count;
}
}
customElements.define('counter-app', CounterApp);
</script>
</body>
</html>
复制到文件保存为 counter.html,浏览器打开:
预期输出:
[-] [+]
点击 + 显示 1,点击 - 显示 0
一句话解释:getAttribute('start') 读取初始值,点击按钮调用 increase/decrease 修改 count,最后更新 Shadow DOM 里的显示。
项目 2(15 分钟):用户信息卡片(从 JSON 读取)
目标:做一个 <user-card> 组件,根据传入的用户 ID 显示对应的用户信息。
完整代码:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>用户卡片组件</title>
</head>
<body>
<!-- 模拟用户数据 -->
<script>
// 模拟数据库
const users = [
{ id: 1, name: '张三', role: '管理员', avatar: '👨💻', email: 'zhangsan@example.com' },
{ id: 2, name: '李四', role: '开发者', avatar: '👩🎨', email: 'lisi@example.com' },
{ id: 3, name: '王五', role: '设计师', avatar: '👨🎨', email: 'wangwu@example.com' }
];
// 模拟 API 获取用户
function fetchUser(id) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(users.find(u => u.id === id));
}, 300);
});
}
</script>
<!-- 使用组件 -->
<user-card uid="1"></user-card>
<user-card uid="2"></user-card>
<user-card uid="3"></user-card>
<script>
class UserCard extends HTMLElement {
static get observedAttributes() {
return ['uid'];
}
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
async connectedCallback() {
const uid = this.getAttribute('uid');
if (uid) {
const user = await fetchUser(parseInt(uid));
this.render(user);
}
}
attributeChangedCallback(name, oldVal, newVal) {
if (name === 'uid' && oldVal) {
this.connectedCallback();
}
}
render(user) {
if (!user) {
this.shadowRoot.innerHTML = '<p>用户不存在</p>';
return;
}
this.shadowRoot.innerHTML = `
<style>
.card {
border: 1px solid #ddd;
border-radius: 12px;
padding: 20px;
width: 250px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-family: Arial, sans-serif;
}
.avatar {
font-size: 48px;
margin-bottom: 10px;
}
.name {
font-size: 22px;
font-weight: bold;
margin: 5px 0;
}
.role {
background: rgba(255,255,255,0.2);
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
display: inline-block;
margin: 8px 0;
}
.email {
font-size: 14px;
opacity: 0.9;
}
</style>
<div class="card">
<div class="avatar">${user.avatar}</div>
<div class="name">${user.name}</div>
<div class="role">${user.role}</div>
<div class="email">${user.email}</div>
</div>
`;
}
}
customElements.define('user-card', UserCard);
</script>
</body>
</html>
复制到文件保存为 user-card.html,浏览器打开:
预期输出:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 👨💻 │ │ 👩🎨 │ │ 👨🎨 │
│ 张三 │ │ 李四 │ │ 王五 │
│ 管理员 │ │ 开发者 │ │ 设计师 │
│ zhangsan@... │ │ lisi@... │ │ wangwu@... │
└──────────────┘ └──────────────┘ └──────────────┘
一句话解释:attributeChangedCallback 监听属性变化,fetchUser 模拟异步获取数据,渲染时用模板字符串生成 HTML。
项目 3(15 分钟):待办清单组件(组合实战)
目标:做一个 <todo-list> 组件,支持添加、完成、删除任务,数据存本地。
完整代码:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>待办清单组件</title>
<style>
body { font-family: Arial, sans-serif; max-width: 500px; margin: 40px auto; }
</style>
</head>
<body>
<todo-list title="我的待办"></todo-list>
<script>
// 简化的本地存储封装
const storage = {
save(key, data) {
localStorage.setItem(key, JSON.stringify(data));
},
load(key) {
const data = localStorage.getItem(key);
return data ? JSON.parse(data) : null;
}
};
class TodoList extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.storageKey = 'todos-' + Math.random().toString(36).substr(2, 9);
this.todos = storage.load(this.storageKey) || [];
}
connectedCallback() {
this.render();
}
addTodo(text) {
if (!text.trim()) return;
this.todos.push({ id: Date.now(), text: text.trim(), done: false });
this.save();
this.render();
}
toggleTodo(id) {
const todo = this.todos.find(t => t.id === id);
if (todo) {
todo.done = !todo.done;
this.save();
this.render();
}
}
deleteTodo(id) {
this.todos = this.todos.filter(t => t.id !== id);
this.save();
this.render();
}
save() {
storage.save(this.storageKey, this.todos);
}
render() {
const title = this.getAttribute('title') || '待办清单';
const todosHtml = this.todos.map(todo => `
<li class="${todo.done ? 'done' : ''}">
<input type="checkbox" ${todo.done ? 'checked' : ''} data-id="${todo.id}">
<span>${todo.text}</span>
<button class="delete" data-id="${todo.id}">删除</button>
</li>
`).join('');
this.shadowRoot.innerHTML = `
<style>
.container {
border: 2px solid #3498db;
border-radius: 12px;
padding: 20px;
background: #f8f9fa;
}
h2 {
margin: 0 0 15px 0;
color: #2c3e50;
}
.input-group {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
input[type="text"] {
flex: 1;
padding: 10px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
}
button.add-btn {
background: #3498db;
color: white;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
li {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
border-bottom: 1px solid #eee;
}
li.done span {
text-decoration: line-through;
color: #999;
}
.delete {
margin-left: auto;
background: #e74c3c;
color: white;
border: none;
padding: 5px 10px;
border-radius: 4px;
cursor: pointer;
}
.empty {
text-align: center;
color: #999;
padding: 20px;
}
</style>
<div class="container">
<h2>${title}</h2>
<div class="input-group">
<input type="text" placeholder="输入待办事项..." class="new-todo">
<button class="add-btn">添加</button>
</div>
${this.todos.length ? `<ul>${todosHtml}</ul>` : '<p class="empty">暂无待办事项</p>'}
</div>
`;
// 绑定事件
const self = this;
this.shadowRoot.querySelector('.add-btn').onclick = () => {
const input = this.shadowRoot.querySelector('.new-todo');
this.addTodo(input.value);
input.value = '';
};
this.shadowRoot.querySelectorAll('input[type="checkbox"]').forEach(cb => {
cb.onclick = () => self.toggleTodo(parseInt(cb.dataset.id));
});
this.shadowRoot.querySelectorAll('.delete').forEach(btn => {
btn.onclick = () => self.deleteTodo(parseInt(btn.dataset.id));
});
}
}
customElements.define('todo-list', TodoList);
</script>
</body>
</html>
复制到文件保存为 todo.html,浏览器打开:
预期输出:
┌─────────────────────────────────┐
│ 我的待办 │
│ ┌───────────────────┐ ┌──────┐ │
│ │ 输入待办事项... │ │ 添加 │ │
│ └───────────────────┘ └──────┘ │
│ ☐ 买菜 [删除]│
│ ☑ 做饭 [删除]│
└─────────────────────────────────┘
一句话解释:localStorage 持久化数据,render() 方法每次更新整个列表,点击事件通过 dataset.id 找到对应任务。
💪 进阶 20 分钟:常见坑 + 性能小贴士
坑 1:样式不生效——忘了 attachShadow
// ❌ 错误:直接在 this 里写 HTML,样式会污染
class BadComponent extends HTMLElement {
connectedCallback() {
this.innerHTML = '<style>.box { color: red; }</style><div class="box">红色</div>';
}
}
// ✅ 正确:使用 Shadow DOM 隔离
class GoodComponent extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<style>.box { color: red; }</style>
<div class="box">红色</div>
`;
}
}
解释:没有 Shadow DOM,你的 CSS 可能被页面其他样式覆盖,也可能污染全局。
坑 2:属性变化不监听——忘了 observedAttributes
// ❌ 错误:改了属性但不触发更新
class BadCard extends HTMLElement {
connectedCallback() {
this.render();
}
// 属性变了,但没通知我
}
// ✅ 正确:声明要监听的属性
class GoodCard extends HTMLElement {
static get observedAttributes() {
return ['title', 'content']; // 这两个属性变化会触发 callback
}
attributeChangedCallback(name, oldVal, newVal) {
if (oldVal !== newVal) {
this.render(); // 属性变了,重新渲染
}
}
}
解释:observedAttributes 像是"订阅清单",只有列出来的属性变化才通知你。
坑 3:事件绑定丢失——用了箭头函数以外的写法
// ❌ 错误:事件触发时 this 不对
class BadComponent extends HTMLElement {
connectedCallback() {
this.shadowRoot.querySelector('button').onclick = function() {
this.remove(); // 这里的 this 是 button,不是组件
};
}
}
// ✅ 正确:用箭头函数或者 that 保存 this
class GoodComponent extends HTMLElement {
connectedCallback() {
const self = this;
this.shadowRoot.querySelector('button').onclick = () => {
self.remove(); // 箭头函数不绑定 this,找外层的 self
};
}
}
解释:普通函数 function() 会改变 this 指向,箭头函数 () => {} 不会。
坑 4:在 constructor 里访问 getAttribute
// ❌ 错误:constructor 里拿不到属性
class BadComponent extends HTMLElement {
constructor() {
super();
const title = this.getAttribute('title'); // 此时 DOM 还没创建完
console.log(title); // null
}
}
// ✅ 正确:在 connectedCallback 里访问
class GoodComponent extends HTMLElement {
connectedCallback() {
const title = this.getAttribute('title'); // 此时 DOM 已就绪
console.log(title); // 正常值
}
}
解释:constructor 执行时,元素还没插入页面,getAttribute 返回 null。
坑 5:忘记 cloneNode(true) 克隆模板
// ❌ 错误:直接移动模板,模板用一次就没了
const template = document.getElementById('tpl');
this.appendChild(template.content); // 第一次用完,模板空了
// ✅ 正确:克隆副本,原模板保留
const template = document.getElementById('tpl');
this.appendChild(template.content.cloneNode(true)); // 克隆一份用
解释:template.content 是文档片段的引用,移动它等于"剪切",用完就没了。
性能小贴士:批量更新用 requestAnimationFrame
如果组件需要频繁更新(比如动画),不要每次都调用 render():
// ❌ 低效:每次数据变化都重新渲染
updateScore(newScore) {
this.score = newScore;
this.render(); // 频繁调用,可能卡顿
}
// ✅ 高效:批量更新,跳帧渲染
updateScore(newScore) {
this.score = newScore;
if (!this._pendingRender) {
this._pendingRender = true;
requestAnimationFrame(() => {
this.render();
this._pendingRender = false;
});
}
}
解释:requestAnimationFrame 让浏览器决定最佳渲染时机,避免重复渲染。
调试技巧:console.log 配合 customElements.get()
// 查看已注册的元素
console.log(customElements.get('my-button')); // 显示类定义
// 检查 Shadow DOM
const el = document.querySelector('my-button');
console.log(el.shadowRoot); // 显示 Shadow DOM 内容
提示:Chrome 开发者工具里,直接在 Elements 面板点击元素,右侧可以看到 #shadow-root。
✏️ 练习题
练习 1(1 分钟):改初始值
- 输入:把
<counter-app start="0">改成start="10" - 预期输出:页面打开时数字显示
10 - 提示:查看
CounterApp类的constructor里哪行读取了这个属性
练习 2(2 分钟):添加边界判断
- 输入:在
CounterApp的decrease方法里,当count <= 0时不再减少 - 预期输出:数字到 0 时,点
-无反应(不再变成负数) - 提示:在
decrease()方法开头加个if
练习 3(3 分钟):处理新数据
- 输入:给
users数组添加第 4 个用户:{ id: 4, name: '赵六', role: '测试', avatar: '🧪', email: 'zhaoliu@example.com' },然后在 HTML 里添加<user-card uid="4"></user-card> - 预期输出:页面上多出一张赵六的卡片
- 提示:
users数组在<script>标签开头,找得到吧?
练习 4(4 分钟):串两个组件
- 输入:在
TodoList里添加一个"清空已完成"按钮,点击删除所有done: true的任务 - 预期输出:点击后,所有带删除线的任务消失
- 提示:添加按钮,给它绑定事件,事件里用
filter过滤掉done: true的项
练习 5(挑战题,5 分钟):报错分析
- 输入:有人用了
<my-card title="Hello"></my-card>,但页面上什么都不显示 - 预期输出:分析为什么没显示,说明修复方法
- 提示:检查
MyCard类的observedAttributes有没有包含'title'
作业:做一个「评分星级组件」
需求描述:做一个 <star-rating> 组件,可以显示评分(1-5 星),点击星星可以改变评分。
功能点:
1. 通过 score 属性设置初始评分(如 score="4")
2. 鼠标悬停时星星高亮(hover 效果)
3. 点击星星设置评分
加分项:
1. 评分后显示数字(如 4.5 / 5)
2. 用 Lit 库重写(需先 npm install lit)
验收标准:
- 组件能独立使用,不依赖任何框架
- 样式隔离,不影响页面其他元素
- 代码有注释,说明关键逻辑
提交方式:把完整 HTML 文件保存到 GitHub Gist,评论区贴链接。
📚 总结 + 资源
一句话总结
本文学了 Web Components 三件套:Custom Elements 造标签、Shadow DOM 做隔离、模板复用提效率,配合 Lit 可以更优雅地开发。
延伸学习
| 资源 | 说明 |
|---|---|
| MDN Web Components 文档 | 官方权威,示例最全 |
| Lit 官方文档 | Google 出品的 Web Components 库 |
| 《JavaScript 高级程序设计》第 14 章 | 经典书的 Web Components 章节 |
互动钩子:你在项目里有没有遇到过"组件想复用但复制粘贴一堆代码"的情况?用的什么方案解决?评论区聊聊,解决方案详细的同学优先回复!
👉 下章预告:学会了自定义组件,下一章我们要把 C++ 代码跑在浏览器里——对,你没看错,WebAssembly 让你在网页上执行高性能原生代码。准备好见证魔法了吗?

评论(0)