Web Components 深度实战:用浏览器原生 API 搭建可复用组件库

4212 字
21 分钟
Web Components 深度实战:用浏览器原生 API 搭建可复用组件库

Web Components 深度实战:用浏览器原生 API 搭建可复用组件库#

如果你厌倦了框架选型焦虑,想要一个在任何项目中都能复用的组件方案,Web Components 可能是你一直在找的答案。

为什么是 Web Components?#

2026 年的前端生态,框架之争早已不是新鲜事。Vue 3、Svelte 5、Solid 各自精彩,但每次切换项目、升级依赖、重构架构时,你是否也曾想过:有没有一种不依赖任何框架的组件化方案?

答案是肯定的。Web Components 不是「未来标准」,它已经是 所有现代浏览器的内置能力。Chrome、Firefox、Safari、Edge 全面支持 Custom Elements v1、Shadow DOM、HTML Templates。这不是某个库的 API,这是浏览器原生提供的组件化基础设施。

对比维度Vue/React 组件Web Components
运行时依赖需要框架运行时(Vue ~16KB, React ~42KB)零依赖,浏览器原生支持
跨框架复用需要额外适配层天然跨框架,HTML 标签即用
学习成本框架专属 API(jsx/模板语法)标准 Web API,一次学会到处用
样式隔离CSS Modules / scoped 需工具链Shadow DOM 原生隔离
SSR 支持框架内置Declarative Shadow DOM(2025 起全面支持)
包体积框架本身 + 组件仅组件代码,无框架开销

核心观点:Web Components 不是要取代 Vue 或 React,而是提供一种 框架无关的组件复用层。在你的 Vue 项目中嵌入一个 Web Component 按钮,和在 React 项目中用,API 完全一致——因为那就是一个 HTML 标签。

一、Custom Elements:让浏览器认识你的标签#

1.1 从 customElements.define 开始#

Custom Elements 是 Web Components 的基石。它允许你注册一个自定义 HTML 标签,并定义它的行为:

// 定义一个计数器组件
class CounterButton extends HTMLElement {
constructor() {
super();
this._count = 0;
}
connectedCallback() {
this.textContent = `点击次数:${this._count}`;
this.addEventListener('click', () => {
this._count++;
this.textContent = `点击次数:${this._count}`;
});
}
}
// 注册:标签名必须包含连字符(-)
customElements.define('counter-button', CounterButton);

在 HTML 中直接使用:

<!-- 不需要 import,不需要框架,直接当普通标签用 -->
<counter-button></counter-button>

为什么标签名必须包含连字符? 这是 W3C 规范的要求,目的是避免与未来 HTML 标准新增的原生标签冲突。<counter-button> 安全,<counter> 不行——万一哪天 HTML 标准加了 <counter> 标签呢?

1.2 生命周期回调:组件的「呼吸节奏」#

Custom Elements 提供四个生命周期回调,理解它们是写出健壮组件的关键:

class UserProfile extends HTMLElement {
// 1. constructor:创建实例时调用,仅一次
// ⚠️ 不要在这里操作 DOM 或读取 attributes
constructor() {
super(); // 必须首先调用 super()
this._shadow = this.attachShadow({ mode: 'open' });
}
// 2. connectedCallback:元素插入 DOM 时调用
// 可能被多次调用(移动节点时),适合初始化
connectedCallback() {
this.render();
this.observeUser();
}
// 3. disconnectedCallback:元素从 DOM 移除时调用
// 清理定时器、事件监听、WebSocket 连接
disconnectedCallback() {
this.cleanup();
}
// 4. attributeChangedCallback:监听的属性变化时调用
// 必须配合 static get observedAttributes() 使用
attributeChangedCallback(name, oldVal, newVal) {
if (oldVal !== newVal) {
this.updateAttribute(name, newVal);
}
}
static get observedAttributes() {
return ['user-id', 'show-avatar', 'theme'];
}
}

实战经验connectedCallback 可能被多次调用。如果你的组件初始化涉及网络请求或 DOM 操作,需要加一个标记防止重复初始化:

connectedCallback() {
if (this._initialized) return;
this._initialized = true;
this.render();
}

1.3 属性与特性的双向绑定#

Web Components 的「特性(attribute)」和「属性(property)」是两个不同的概念,这也是最常见的坑:

class DataCard extends HTMLElement {
// 特性(attribute):HTML 标签上的字符串值
// <data-card title="Hello" count="42"></data-card>
// 属性(property):JavaScript 对象上的值
// document.querySelector('data-card').count = 42;
get title() {
return this.getAttribute('title');
}
set title(val) {
this.setAttribute('title', val);
}
get count() {
// ⚠️ attribute 永远是字符串,需要手动转换类型
return Number(this.getAttribute('count')) || 0;
}
set count(val) {
this.setAttribute('count', String(val));
}
attributeChangedCallback(name, oldVal, newVal) {
// 属性变化时重新渲染
this.render();
}
static get observedAttributes() {
return ['title', 'count'];
}
}

常见坑<data-card count="42"></data-card> 中,this.getAttribute('count') 返回的是字符串 "42",不是数字 42。如果你需要数字类型,必须在 getter 中手动转换。这也是为什么很多开发者选择用 property 传复杂数据(对象、数组),而不是 attribute。

二、Shadow DOM:真正的样式隔离#

2.1 为什么需要 Shadow DOM?#

CSS 的全局性是所有前端开发者的痛。即使有 CSS Modules、Scoped CSS,它们本质上都是编译时的类名变换,运行时仍然是全局样式表。Shadow DOM 提供的是 浏览器级别的样式隔离

class AlertBox extends HTMLElement {
constructor() {
super();
// mode: 'open' → JS 可以访问 shadow root
// mode: 'closed' → shadow root 对外部不可见(类似私有)
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
/* 这里的样式只作用于 AlertBox 内部 */
.alert {
padding: 16px;
border-radius: 8px;
background: #fff3cd;
border: 1px solid #ffc107;
}
/* 外部页面的 .alert 样式不会泄漏进来 */
/* 这里的 .alert 也不会影响外部页面 */
</style>
<div class="alert">
<slot></slot>
</div>
`;
}
}

2.2 Shadow DOM 的样式隔离机制#

Shadow DOM 的隔离不是魔术,它基于浏览器的 样式作用域规则

外部样式表
├── 不穿透 Shadow Boundary(默认)
├── 少数 inherit 属性会穿透(color, font-family 等)
└── 使用 ::part() 和 ::slotted() 可以精确控制暴露
Shadow 内部样式
├── 完全独立的作用域
├── 可以引用外部 CSS 变量(CSS Custom Properties 会穿透)
└── :host 选择器指向宿主元素本身
/* Shadow DOM 内部 */
:host {
/* 样式应用于 <alert-box> 元素本身 */
display: block;
margin: 8px 0;
}
:host([variant="error"]) {
/* 变体模式:<alert-box variant="error"> */
background: #f8d7da;
border-color: #dc3545;
}
:host(:hover) {
/* 宿主元素的伪类 */
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
::slotted(*) {
/* 选中通过 <slot> 插入的内容 */
font-weight: 500;
}
::part(button) {
/* 选中 shadow 内部标记了 part="button" 的元素 */
background: blue;
}
<!-- 使用方 -->
<alert-box variant="error">
<p>这是一条错误提示</p>
</alert-box>
<!-- 外部可以通过 ::part() 定制内部元素 -->
<style>
alert-box::part(button) {
background: red;
}
</style>

2.3 CSS Custom Properties:Shadow DOM 内外的桥梁#

CSS 自定义属性(变量)是少数能穿透 Shadow Boundary 的样式机制,这为组件的主题化提供了天然支持:

class ThemeButton extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host {
/* 使用外部定义的 CSS 变量,提供默认值 */
--btn-bg: var(--theme-primary, #007bff);
--btn-text: var(--theme-text-on-primary, #fff);
}
button {
background: var(--btn-bg);
color: var(--btn-text);
border: none;
padding: 8px 20px;
border-radius: 6px;
cursor: pointer;
}
</style>
<button><slot></slot></button>
`;
}
}
// 外部主题控制
// <style>
// :root {
// --theme-primary: #6c5ce7;
// --theme-text-on-primary: #ffffff;
// }
// </style>
// <theme-button>主题按钮</theme-button>

设计模式:通过 CSS 变量暴露主题接口,通过 ::part() 暴露结构定制接口,通过 slot 暴露内容接口——这是 Web Components 的 三层定制体系,也是成熟组件库的标准做法。

三、Slots 与模板:内容分发机制#

3.1 默认 Slot 与具名 Slot#

Slot 是 Web Components 的内容分发机制,类似于 Vue 的 <slot> 或 React 的 children

class Card extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
.card {
border: 1px solid #e0e0e0;
border-radius: 12px;
overflow: hidden;
font-family: system-ui, sans-serif;
}
.card-header {
padding: 16px;
background: #f8f9fa;
border-bottom: 1px solid #e0e0e0;
}
.card-body { padding: 16px; }
.card-footer {
padding: 12px 16px;
background: #f8f9fa;
border-top: 1px solid #e0e0e0;
}
</style>
<div class="card">
<div class="card-header">
<slot name="header">默认标题</slot>
</div>
<div class="card-body">
<slot>默认内容</slot>
</div>
<div class="card-footer">
<slot name="footer"></slot>
</div>
</div>
`;
}
}
// 使用
// <card>
// <span slot="header">用户信息</span>
// <p>这里是卡片主体内容</p>
// <button slot="footer">操作按钮</button>
// </card>

3.2 Slot 事件冒泡的陷阱#

这是 Web Components 中最容易被忽视的细节:Slot 中的事件,其 target 会被重定向到宿主元素

// Shadow DOM 内部有一个按钮
// <button slot="action">点击</button>
// 外部监听:
card.addEventListener('click', (e) => {
// e.target 是 <card> 元素,不是 <button>!
// 这是 Shadow DOM 的「事件重定向」机制
// 外部代码看不到 Shadow 内部的 DOM 结构
});

解决方案:如果你需要知道具体是哪个元素触发了事件,可以在 Shadow 内部处理后再通过 CustomEvent 抛出:

// Shadow 内部
button.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('card-action', {
bubbles: true,
composed: true, // ⚠️ 关键:允许事件穿透 Shadow Boundary
detail: { action: 'save', timestamp: Date.now() }
}));
});
// 外部监听
card.addEventListener('card-action', (e) => {
console.log(e.detail.action); // 'save'
});

composed: true 是事件穿透 Shadow Boundary 的关键。默认情况下,自定义事件不会穿透 Shadow Boundary,这和原生事件(如 click)的行为不同。

四、Declarative Shadow DOM:SSR 的曙光#

4.1 什么是 Declarative Shadow DOM?#

在 2025 年之前,Web Components 最大的短板是 SSR(服务端渲染)困难。Shadow DOM 必须通过 JavaScript 的 attachShadow() 创建,这意味着服务端渲染的 HTML 中无法包含 Shadow DOM 内容,首屏会出现「闪烁」——先显示无样式内容,JS 加载后才渲染 Shadow DOM。

Declarative Shadow DOM(DSD)解决了这个问题。它允许你在 HTML 中直接声明 Shadow DOM:

<!-- 服务端渲染的输出 -->
<user-card user-id="123">
<template shadowroot="open">
<style>
.profile {
display: flex;
align-items: center;
gap: 12px;
}
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: #e0e0e0;
}
</style>
<div class="profile">
<div class="avatar"></div>
<div>
<div class="name">加载中...</div>
<div class="bio"></div>
</div>
</div>
</template>
</user-card>

浏览器在解析 HTML 时就会直接创建 Shadow DOM,不需要等待 JavaScript 执行。这带来了两个关键好处:

  1. 无闪烁 SSR:首屏就有完整的 Shadow DOM 内容
  2. SEO 友好:搜索引擎爬虫可以直接看到 Shadow DOM 内容

4.2 渐进增强策略#

DSD 的最佳实践是 渐进增强:服务端输出 DSD 保证首屏,客户端 JS 负责交互增强:

class UserCard extends HTMLElement {
connectedCallback() {
// 检查是否已经有 Shadow DOM(由 DSD 创建)
if (this.shadowRoot) {
// DSD 已创建 Shadow DOM,直接增强
this.enhydrate();
} else {
// 客户端渲染,手动创建
this.attachShadow({ mode: 'open' });
this.render();
}
}
async enhydrate() {
// 仅添加交互逻辑,不重新渲染 DOM
const userId = this.getAttribute('user-id');
const data = await fetch(`/api/users/${userId}`).then(r => r.json());
this.shadowRoot.querySelector('.name').textContent = data.name;
this.shadowRoot.querySelector('.bio').textContent = data.bio;
}
}

五、Python FastAPI 后端集成实战#

Web Components 不局限于前端。让我们用一个 Python FastAPI 后端来提供数据,展示 Web Components 如何与后端生态协作。

5.1 构建数据 API#

main.py
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import Optional
app = FastAPI(title="Web Components API")
# CORS 配置:允许前端直接调用
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 生产环境应限制具体域名
allow_methods=["GET", "POST"],
allow_headers=["*"],
)
class UserResponse(BaseModel):
id: int
name: str
email: str
role: str
avatar_url: Optional[str] = None
class UsersResponse(BaseModel):
users: list[UserResponse]
total: int
page: int
# 模拟数据库
USERS_DB = [
UserResponse(id=1, name="张三", email="zhangsan@example.com", role="admin"),
UserResponse(id=2, name="李四", email="lisi@example.com", role="editor"),
UserResponse(id=3, name="王五", email="wangwu@example.com", role="viewer"),
]
@app.get("/api/users", response_model=UsersResponse)
async def get_users(page: int = 1, limit: int = 10):
"""分页获取用户列表"""
start = (page - 1) * limit
end = start + limit
return UsersResponse(
users=USERS_DB[start:end],
total=len(USERS_DB),
page=page
)
@app.get("/api/users/{user_id}", response_model=UserResponse)
async def get_user(user_id: int):
"""获取单个用户"""
for user in USERS_DB:
if user.id == user_id:
return user
raise HTTPException(status_code=404, detail="User not found")

5.2 Web Component 消费 API#

class UserList extends HTMLElement {
constructor() {
super();
this._shadow = this.attachShadow({ mode: 'open' });
this._page = 1;
this._loading = false;
}
connectedCallback() {
this._shadow.innerHTML = `
<style>
:host { display: block; font-family: system-ui, sans-serif; }
.list { list-style: none; padding: 0; margin: 0; }
.item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-bottom: 1px solid #eee;
}
.item:hover { background: #f8f9fa; }
.badge {
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
background: #e3f2fd;
color: #1565c0;
}
.loading { text-align: center; padding: 24px; color: #999; }
.pagination {
display: flex;
justify-content: center;
gap: 8px;
padding: 16px;
}
.pagination button {
padding: 6px 16px;
border: 1px solid #ddd;
border-radius: 6px;
background: #fff;
cursor: pointer;
}
.pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
</style>
<ul class="list"></ul>
<div class="pagination"></div>
`;
this.loadData();
}
async loadData() {
if (this._loading) return;
this._loading = true;
const listEl = this._shadow.querySelector('.list');
listEl.innerHTML = '<li class="loading">加载中...</li>';
try {
const apiUrl = this.getAttribute('api-url') || '/api/users';
const resp = await fetch(
`${apiUrl}?page=${this._page}&limit=10`
);
const data = await resp.json();
listEl.innerHTML = data.users.map(user => `
<li class="item">
<strong>${user.name}</strong>
<span style="color:#666">${user.email}</span>
<span class="badge">${user.role}</span>
</li>
`).join('');
this.renderPagination(data.total, data.page);
} catch (err) {
listEl.innerHTML = `<li class="loading">加载失败:${err.message}</li>`;
} finally {
this._loading = false;
}
}
renderPagination(total, currentPage) {
const totalPages = Math.ceil(total / 10);
const pagEl = this._shadow.querySelector('.pagination');
pagEl.innerHTML = `
<button ${currentPage <= 1 ? 'disabled' : ''} data-page="${currentPage - 1}">上一页</button>
<span>${currentPage} / ${totalPages}</span>
<button ${currentPage >= totalPages ? 'disabled' : ''} data-page="${currentPage + 1}">下一页</button>
`;
pagEl.addEventListener('click', (e) => {
if (e.target.dataset.page) {
this._page = Number(e.target.dataset.page);
this.loadData();
}
});
}
}
customElements.define('user-list', UserList);

使用方式极其简单:

<!-- 在任何页面中,直接当 HTML 标签使用 -->
<user-list api-url="http://localhost:8000/api/users"></user-list>

为什么这样设计? 后端只负责提供 JSON 数据,前端组件完全自治。你可以在 Vue 项目中用 <user-list>,在 React 项目中用 <user-list>,在纯 HTML 页面中也用 <user-list>——组件与框架解耦,后端与前端解耦

六、AI 辅助 Web Components 开发#

6.1 AI 生成组件模板#

利用大模型的能力,可以快速生成 Web Components 的骨架代码。以下是一个有效的 Prompt 模板:

请生成一个 Web Component,要求:
1. 使用 Custom Elements v1 和 Shadow DOM(mode: open)
2. 接收属性:title(字符串)、items(通过 property 传入数组)
3. 内部使用 <slot> 支持内容分发
4. 提供 CSS 变量接口:--list-bg, --list-border-color
5. 包含 connectedCallback 和 disconnectedCallback
6. 通过 CustomEvent 向外抛出 'item-select' 事件
7. 样式使用 CSS Grid 布局,响应式设计

AI 生成的代码可以作为起点,但 必须人工审查 以下几个关键点:

审查项常见问题正确做法
super() 调用忘记或位置不对constructor 第一行必须 super()
composed: true自定义事件不穿透 Shadow需要跨 Shadow 的事件必须设置
类型转换attribute 当数字用Number(this.getAttribute('x'))
重复初始化connectedCallback 多次触发_initialized 标记
内存泄漏未清理事件监听disconnectedCallback 中清理

6.2 AI 辅助测试#

Web Components 的测试可以用 Playwright 或 Puppeteer 进行端到端测试,AI 可以辅助生成测试用例:

// AI 生成的测试用例示例
import { test, expect } from '@playwright/test';
test('user-list 加载并显示用户数据', async ({ page }) => {
await page.goto('/demo');
// 等待组件渲染
const userList = page.locator('user-list');
await expect(userList).toBeVisible();
// 获取 Shadow DOM 内部元素
const listItems = userList.locator('::shadow .item');
await expect(listItems).toHaveCount(3);
// 验证内容
const firstItem = listItems.first();
await expect(firstItem).toContainText('张三');
await expect(firstItem).toContainText('admin');
// 测试分页
const nextBtn = userList.locator('::shadow button:has-text("下一页")');
await nextBtn.click();
await expect(userList.locator('::shadow .loading')).toBeVisible();
});

AI 的局限性:AI 生成的测试代码可能不理解 Shadow DOM 的选择器语法(::shadow 已被废弃,Playwright 使用 locator('user-list').locator('.item') 自动穿透)。AI 是强大的起点,但你需要理解底层机制才能修正细节。

七、Docker 开发环境#

7.1 容器化开发环境#

Web Components 项目通常不需要复杂的构建工具(纯原生 API 甚至不需要构建),但用 Docker 统一开发环境仍然有价值:

# Dockerfile
FROM node:20-alpine
WORKDIR /app
# 安装 http-server 作为轻量级开发服务器
RUN npm install -g http-server
# 复制组件代码
COPY src/ ./src/
COPY index.html ./
# 暴露端口
EXPOSE 8080
# 启动开发服务器(支持 CORS)
CMD ["http-server", ".", "-p", "8080", "-c-1", "--cors"]
docker-compose.yml
version: '3.8'
services:
# Web Components 前端
frontend:
build: ./frontend
ports:
- "8080:8080"
volumes:
- ./frontend/src:/app/src # 热重载
restart: unless-stopped
# FastAPI 后端
backend:
build: ./backend
ports:
- "8000:8000"
volumes:
- ./backend:/app
command: uvicorn main:app --host 0.0.0.0 --reload
restart: unless-stopped
# backend/Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
backend/requirements.txt
fastapi==0.115.0
uvicorn==0.30.0
pydantic==2.9.0

为什么用 Docker? 不是每个项目都需要 Kubernetes,但 docker compose up 一行命令启动前后端完整环境,对团队协作和新成员上手非常有帮助。Web Components 的零构建特性让 Docker 镜像可以非常轻量——前端甚至不需要 Node.js 运行时,用 Nginx 静态托管即可。

八、生产环境最佳实践#

8.1 性能优化清单#

优化项做法收益
懒加载组件动态 import 注册减少首屏 JS 体积
Shadow DOM mode优先 open便于调试和测试
样式内联使用 <style> 而非外部 CSS避免额外请求
事件委托在宿主元素上监听,而非每个子元素减少监听器数量
避免频繁重渲染使用 requestAnimationFrame 批量更新减少 Layout Thrashing
// 懒加载示例
async function loadComponent(tagName, modulePath) {
if (customElements.get(tagName)) return; // 已注册则跳过
const { default: ComponentClass } = await import(modulePath);
customElements.define(tagName, ComponentClass);
}
// 按需加载
loadComponent('data-chart', './components/data-chart.js');

8.2 兼容性兜底#

虽然现代浏览器全面支持 Web Components,但仍有必要考虑降级策略:

// 特性检测
function supportsWebComponents() {
return 'customElements' in window
&& 'attachShadow' in Element.prototype
&& 'innerHTML' in DocumentFragment.prototype;
}
if (!supportsWebComponents()) {
// 引入 polyfill 或降级到简单实现
import('https://unpkg.com/@webcomponents/webcomponentsjs@2/webcomponents-loader.js');
}

总结:Web Components 的定位#

Web Components 不是银弹,但它解决了一个真实存在的问题:跨框架、跨项目的组件复用

适合场景

  • 设计系统 / 组件库(需要被多个框架项目使用)
  • 微前端架构中的独立模块
  • 第三方嵌入组件(Widget、SDK)
  • 长期维护的项目(不受框架升级影响)

不适合场景

  • 需要复杂状态管理的大型 SPA(用 Vue/React)
  • 需要 SSR 完整水合的应用(DSD 还在完善中)
  • 团队已经深度绑定某个框架(迁移成本大于收益)

2026 年的建议:将 Web Components 作为你工具箱中的一件武器,而不是唯一武器。在需要跨框架复用的地方用它,在需要复杂交互的地方用 Vue/React。理解底层原理,才能在选型时做出明智决策。


本文所有代码示例均可直接运行。完整示例代码已随本文发布,欢迎在博客评论区交流讨论。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Web Components 深度实战:用浏览器原生 API 搭建可复用组件库
https://boke.hackerdream.xyz/posts/web-components-deep-dive/
作者
晴天
发布于
2026-05-24
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
晴天
Hello, I'm 晴天.
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
125
分类
17
标签
287
总字数
257,955
运行时长
0
最后活动
0 天前

目录