第9章 9.1 Web Components

上一章我们学会了用 Vite 搭建现代项目,开发效率翻倍。但你有没有想过:Vite 帮我们打包的那些组件,能不能像乐高积木一样,一次构建、到处使用?比如你在 A 项目写了一个按钮组件,能不能直接拿到 B 项目用,不用复制粘贴、不用担心样式冲突?

这就是 Web Components 要解决的问题。

🎯 开场 3 分钟:为什么要学这个?

场景引入

想象你在一栋写字楼上班。每天早上要刷卡进门,中午去食堂吃饭,晚上加班打车。每栋楼都有自己的门禁卡、食堂卡、车票系统——每张卡只能在这栋楼用

这就是前端开发的现状:React 项目的按钮组件,直接拿到 Vue 项目里用?门都没有。样式会打架,逻辑会冲突,你只能"翻译"一遍。

但如果有一张全国通卡呢?不管去哪栋楼,刷卡就进。这就是 Web Components 想要做到的事——一次编写,到处运行,不依赖任何框架。

痛点来了

你可能遇到过这些糟心事:

  • 组件复制来复制去:项目 A 的轮\n\nSimple tech illustration expla\n\nAI comic creation scene, creat\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>

运行结果

你好,世界!

这行在干嘛:自定义元素必须继承 HTMLElementconnectedCallback 相当于组件"出生"时执行的构造函数。


概念 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 分钟):添加边界判断

  • 输入:在 CounterAppdecrease 方法里,当 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 让你在网页上执行高性能原生代码。准备好见证魔法了吗?

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。