介绍
Web Components 是一套由 W3C(万维网联盟)制定的 Web 标准,旨在提供一种原生、标准化的方式来创建可重用、封装良好的自定义 HTML 元素。它不是某个框架或库,而是浏览器原生支持的一组 API 的集合。
一、历史背景
1. 起源(2011 年前后)
2011 年:Google 工程师 Dimitri Glazkov 首次在 Chromium 博客中提出 Web Components 的概念。
最初是作为解决前端组件化问题的一种“原生”方案,希望摆脱对 jQuery 插件、Angular、React 等框架的依赖。
同年,W3C 成立了 Web Components 社区组,开始标准化工作。
2. 标准演进
Web Components 最初包含四个主要技术规范:
- Custom Elements(自定义元素)
- Shadow DOM(影子 DOM)
- HTML Templates(HTML 模板)
- HTML Imports(HTML 导入) ← 已被废弃
⚠️ 注意:HTML Imports 因兼容性和性能问题未被广泛采纳,后来被 ES Modules 取代。
3. 浏览器支持时间线
- 2014–2016 年:各浏览器逐步实现基础功能(Chrome 最先支持)。
- 2018 年左右:主流浏览器(Chrome、Safari、Firefox、Edge)基本完成对核心规范(Custom Elements v1 + Shadow DOM v1)的支持。
- 2020 年后:Web Components 进入稳定成熟阶段,被广泛用于微前端、设计系统、跨框架组件等场景。
二、为什么出现?要解决什么问题?
在 Web Components 出现之前,前端开发面临几个核心痛点:
1. 缺乏原生组件化能力
HTML 只有有限的内置标签(如 <div>、<button>),无法表达业务语义。
开发者需要通过 JavaScript + CSS 手动组合结构、样式和行为,难以复用。
2. 样式和脚本全局污染
CSS 选择器是全局的,容易发生命名冲突(比如两个组件都用了 .button)。
JavaScript 变量和逻辑也容易互相干扰。
3. 框架锁定(Framework Lock-in)
React、Vue、Angular 等框架都有自己的组件模型,但彼此不兼容。
如果想在不同项目或团队间共享 UI 组件,往往需要为每个框架单独实现一遍。
4. 封装性差
传统方式无法将 HTML 结构、CSS 样式、JS 行为“打包”成一个黑盒单元。
三、Web Components 如何解决这些问题?
| 问题 | Web Components的解决方案 |
|---|---|
| 无法创建语义化标签 | Custom Elements:允许定义 <my-button> 这样的新 HTML 标签 |
| 样式/脚本污染 | Shadow DOM:提供 DOM 和样式的封装,内部与外部隔离 |
| 缺乏模板机制 | <template> 和 <slot>:声明可复用的 HTML 片段,支持内容分发 |
| 跨框架复用困难 | 原生浏览器支持,可在 React、Vue、Angular、纯 HTML 中无缝使用 |
四、典型应用场景
- Web 框架:如 Salesforce 的 Lightning Web Components、GitHub 的 Primer ViewComponents。
- 微前端架构:不同团队开发的组件可以独立部署、互不干扰。
- 跨框架 UI 库:一次编写,到处使用(例如:Vaadin、Shoelace 等基于 Web Components 的 UI 库)。
- 嵌入第三方小部件:如聊天窗口、支付按钮、地图控件等,避免污染宿主页面。
Web Components 三大核心 API
我们以计数器这个组件为例子进行说明:

1. Custom Elements(自定义元素)
<my-counter></my-counter>
<script>
// 定义一个自定义元素类
class MyCounter extends HTMLElement {
constructor() {
super(); // 必须调用 super()
// 创建 Shadow DOM
const shadow = this.attachShadow({ mode: 'open' });
// 初始化状态
this.count = 0;
// 构建内部结构
shadow.innerHTML = `
<style>
button { font-size: 16px; padding: 4px 8px; }
span { margin: 0 8px; }
</style>
<button id="dec">-</button>
<span id="value">${this.count}</span>
<button id="inc">+</button>
`;
// 绑定事件
shadow.getElementById('inc').addEventListener('click', () => {
this.count++;
this.update();
});
shadow.getElementById('dec').addEventListener('click', () => {
this.count--;
this.update();
});
}
update() {
this.shadowRoot.getElementById('value').textContent = this.count;
}
}
// 注册自定义元素
customElements.define('my-counter', MyCounter);
</script>
✅ 注意:
- 标签名必须包含连字符(-),如 x-button,这是为了与未来 HTML 原生标签区分。
- 必须继承 HTMLElement(或其子类如 HTMLButtonElement)。
- 使用 customElements.define() 注册自定义元素,第一个参数为标签名,第二个参数为类名。
生命周期回调
| 生命周期方法 | 触发时机 | 类似于 Vue / React 的什么? |
|---|---|---|
| constructor() | 元素被创建时(通过 new 或解析 HTML) | setup() / constructor |
| connectedCallback() | 元素被插入到 DOM 树中时 | mounted() / componentDidMount |
| disconnectedCallback() | 元素从 DOM 树中移除时 | unmounted() / componentWillUnmount |
| attributeChangedCallback(name, oldValue, newValue) | 被监听的属性发生变化时(需配合 observedAttributes) | watch(props) / useEffect(依赖属性变化) |
| adoptedCallback() | 元素被移动到另一个 document(如 document.adoptNode()) | 极少使用,无直接对应 |
// 监听 dom 属性变化
static get observedAttributes() {
return ['value'];
}
// 当 value 属性变化时调用
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'value') {
this.count = parseInt(newValue, 10);
this.update();
}
}
<my-counter value="10"></my-counter>
这里我们了解了外部通过标签属性通知组件内部的方式,接下我们介绍如何从组件内部通知外部的方法。
// 组件内部:派发事件
this.dispatchEvent(new CustomEvent('count-change', {
detail: { count: this.count },
bubbles: true, // 事件冒泡
composed: true // 事件可以穿越 Shadow DOM 边界
}));
// 外部监听组件事件
document.querySelector('my-counter').addEventListener('count-change', (event) => {
console.log('收到组件事件:', event.detail);
});
// 收到组件事件: {count: 11}
2. Shadow DOM(影子 DOM)
提供封装的 DOM 子树,将组件的内部结构、样式与外部页面完全隔离。
关键特性:
- 样式隔离:Shadow DOM 内部的 CSS 不会影响外部,外部 CSS 也无法穿透(除非使用 ::part 或 CSS 变量)。
- DOM 封装:document.querySelector() 无法直接访问 Shadow DOM 内部节点。
- 插槽机制(Slots):支持内容分发(类似 Vue 的 slot / React 的 children)。
<my-article>
<span slot="title">我的文章</span>
<p>这是文章正文。</p>
</my-article>
<script>
class MyArticle extends HTMLElement {
constructor() {
super(); // 必须调用 super()
// 创建 Shadow DOM
const shadow = this.attachShadow({ mode: 'open' });
// 初始化状态
this.title = '';
// 构建内部结构
shadow.innerHTML = `
<style>
::slotted(span) { font-size: 24px; font-weight: bold; }
::slotted(p) { color: blue; }
</style>
<header><slot name="title">默认标题</slot></header>
<main><slot>默认内容</slot></main>
`;
}
}
// 注册自定义元素
customElements.define('my-article', MyArticle);
</script>
- open:可通过 element.shadowRoot 访问内部。
- closed:外部无法访问(但实际很少用,调试困难)。
- ::slotted(p):用于给分配到 slot 中的 <p> 元素设置样式
| ::slotted 规则 | 说明 |
|---|---|
| 只能选直接子元素 | ::slotted(.content > p) 无效;必须是 ::slotted(p) |
| 不能用复杂组合选择器 | ::slotted(div p)、::slotted(p + ul) 都无效 |
| 不能继承 Shadow DOM 内部样式 | slot 内容仍保留其原有样式,除非被 ::slotted 覆盖 |
3. HTML Templates(<template> 和 <slot>)
虽然 <template> 本身不是 Web Components 独有,但它是构建组件的重要工具。
- 内容在页面加载时不会渲染。
- 内容是惰性的(inert),脚本不会执行,图片不会加载。
- 可通过 JavaScript 克隆并插入 DOM。
<template id="my-card-template">
<style> .card { border: 1px solid #ccc; padding: 10px; } </style>
<div class="card">
<h3><slot name="name"></slot></h3>
<p><slot></slot></p>
</div>
</template>
<script>
class MyCard extends HTMLElement {
constructor() {
super();
const template = document.getElementById('my-card-template');
const content = template.content.cloneNode(true);
this.attachShadow({ mode: 'open' }).appendChild(content);
}
}
customElements.define('my-card', MyCard);
</script>
工程化
虽然原生 Web Components 功能强大,但直接手写存在一些痛点:
- 模板字符串难以维护(尤其是复杂组件)
- 缺乏响应式更新(需手动操作 DOM)
- 浏览器兼容性虽好,但开发体验不如框架
因此,社区出现了许多基于 Web Components 的增强库:
| 库 | 特点 |
|---|---|
| Lit(Google) | 轻量、响应式、支持 TypeScript,最流行的 Web Components 库 |
| Stencil(Ionic 团队) | 编译时优化,可输出标准 Web Components,支持虚拟 DOM |
| FAST(Microsoft) | 高性能,支持设计系统构建 |
例如,用 Lit 写上面的计数器只需几行:
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
@customElement('my-counter')
export class MyCounter extends LitElement {
static styles = css`button { font-size: 16px; }`;
@state() count = 0;
render() {
return html`
<button @click=${() => this.count--}>-</button>
<span>${this.count}</span>
<button @click=${() => this.count++}>+</button>
`;
}
}
它还提供了,响应式更新、事件处理、插槽机制等功能,基本能实现与 vue 等框架相同的开发体验,这里因为篇幅的原因就不展开了。
补充
在即将到来的 Vue 3.6 中告别虚拟 DOM,采取类似于 Web Components 的实现逻辑,编译时直接生成真实 DOM 操作指令,跳过 VNode 创建与 Diff 过程。
所以在本人看来,一切的框架最终还是会回到最基本的操作 DOM 元素,只是在这个过程中加入了一些语法糖、工具函数等,使开发体验更加友好。
国内对于 Web Components 生态十分稀少,这种天然具有跨平台、微前端、高效的特性,使得它成为了一种非常有潜力的技术。