Web Components
问题
什么是 Web Components?它包含哪些核心技术?在实际项目中有哪些应用场景?
答案
Web Components 是一套浏览器原生支持的组件化技术标准,允许开发者创建可复用、封装良好、与框架无关的自定义 HTML 元素。它由 W3C 标准化,不依赖任何第三方框架,是构建跨框架共享组件的基础方案。
1. 三大核心技术
Web Components 由三个核心 API 组成,各自承担不同职责:
| 技术 | 职责 | 关键 API |
|---|---|---|
| Custom Elements | 定义自定义 HTML 标签 | customElements.define() |
| Shadow DOM | 提供样式和 DOM 隔离 | element.attachShadow() |
| HTML Templates & Slots | 声明式模板和内容分发 | <template> / <slot> |
2. Custom Elements
Custom Elements 允许开发者注册自定义 HTML 标签,使其拥有自己的行为和生命周期。
定义与注册
class MyCounter extends HTMLElement {
private count = 0;
private shadow: ShadowRoot;
// 声明需要监听的 attribute
static get observedAttributes(): string[] {
return ['initial', 'step'];
}
constructor() {
super(); // 必须首先调用 super()
this.shadow = this.attachShadow({ mode: 'open' });
}
// 元素插入 DOM 时调用
connectedCallback(): void {
const initial = this.getAttribute('initial');
if (initial) this.count = parseInt(initial, 10);
this.render();
}
// 元素从 DOM 移除时调用
disconnectedCallback(): void {
console.log('MyCounter removed from DOM');
}
// 监听的 attribute 变化时调用
attributeChangedCallback(
name: string,
oldValue: string | null,
newValue: string | null
): void {
if (name === 'initial' && newValue !== null) {
this.count = parseInt(newValue, 10);
this.render();
}
}
// 元素被移动到新文档时调用(如 iframe)
adoptedCallback(): void {
console.log('MyCounter adopted into new document');
}
private render(): void {
const step = parseInt(this.getAttribute('step') ?? '1', 10);
this.shadow.innerHTML = `
<style>
button { padding: 8px 16px; font-size: 16px; cursor: pointer; }
span { margin: 0 12px; font-size: 20px; }
</style>
<button id="dec">-</button>
<span>${this.count}</span>
<button id="inc">+</button>
`;
this.shadow.getElementById('inc')!.addEventListener('click', () => {
this.count += step;
this.render();
});
this.shadow.getElementById('dec')!.addEventListener('click', () => {
this.count -= step;
this.render();
});
}
}
customElements.define('my-counter', MyCounter);
自定义元素的标签名必须包含连字符(如 my-counter),这是为了与原生 HTML 标签区分。单个单词的标签名(如 counter)是不合法的。
生命周期回调一览
| 回调 | 触发时机 | 常见用途 |
|---|---|---|
constructor() | 元素被创建(new 或解析 HTML) | 初始化状态、attachShadow |
connectedCallback() | 元素插入到 DOM | 渲染、添加事件监听、请求数据 |
disconnectedCallback() | 元素从 DOM 移除 | 清理定时器、移除监听、断开连接 |
attributeChangedCallback() | observedAttributes 中的 attribute 变化 | 响应外部属性变化 |
adoptedCallback() | 元素被 document.adoptNode() 移到新文档 | 适配新文档环境 |
attributeChangedCallback 只会监听 observedAttributes 中声明的 attribute。未声明的 attribute 变化不会触发回调。此外,constructor 中不应访问 attribute 或子元素,因为此时元素可能尚未被添加到 DOM。
3. Shadow DOM
Shadow DOM 为自定义元素提供了独立的 DOM 树和样式作用域,内部样式不会泄漏到外部,外部样式也不会影响内部。这是 Web Components 实现真正组件封装的关键。
open vs closed 模式
class OpenComponent extends HTMLElement {
constructor() {
super();
// open: 外部可通过 element.shadowRoot 访问
this.attachShadow({ mode: 'open' });
}
}
class ClosedComponent extends HTMLElement {
private shadow: ShadowRoot;
constructor() {
super();
// closed: 外部无法通过 element.shadowRoot 访问
this.shadow = this.attachShadow({ mode: 'closed' });
}
}
// 使用
const openEl = document.querySelector('open-component')!;
console.log(openEl.shadowRoot); // ShadowRoot 对象
const closedEl = document.querySelector('closed-component')!;
console.log(closedEl.shadowRoot); // null
| 特性 | mode: 'open' | mode: 'closed' |
|---|---|---|
外部访问 shadowRoot | 可以 | 返回 null |
| JavaScript 操作内部 DOM | 可以 | 只能通过组件内部方法 |
| 使用场景 | 大多数场景、调试友好 | 安全敏感、防止外部篡改 |
| 推荐度 | 推荐(更灵活) | 仅在需要严格封装时使用 |
Shadow DOM 创建了一个独立的 DOM 子树。浏览器的 CSS 选择器无法穿透 Shadow 边界(除了 CSS 自定义属性/变量),这意味着:
- 外部的
div { color: red }不会影响 Shadow DOM 内的<div> - Shadow DOM 内的
p { font-size: 20px }不会泄漏到外部
这种隔离是浏览器原生级别的,比 CSS Modules 的类名 hash 或 CSS-in-JS 的运行时注入更加彻底。
::part 穿透样式
虽然 Shadow DOM 默认隔离样式,但可以通过 part attribute 暴露特定元素给外部样式化:
<!-- 组件内部 -->
<template id="fancy-button-template">
<style>
/* 组件默认样式 */
button { padding: 8px 16px; border: none; border-radius: 4px; }
</style>
<button part="btn"><slot></slot></button>
</template>
<!-- 外部样式穿透 -->
<style>
fancy-button::part(btn) {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-weight: bold;
}
fancy-button::part(btn):hover {
opacity: 0.9;
}
</style>
<fancy-button>Click Me</fancy-button>
Shadow DOM vs CSS Modules vs CSS-in-JS
| 特性 | Shadow DOM | CSS Modules | CSS-in-JS |
|---|---|---|---|
| 隔离方式 | 浏览器原生 DOM 边界 | 类名 hash(编译时) | 运行时生成唯一类名 |
| 隔离程度 | 完全隔离(标签选择器也隔离) | 仅类名隔离 | 仅类名隔离 |
| 全局样式影响 | 不受影响(除 CSS 变量) | 可能被标签选择器影响 | 可能被标签选择器影响 |
| 运行时开销 | 无(浏览器原生) | 无(编译时处理) | 有(动态生成样式) |
| 框架依赖 | 无 | 需要构建工具 | 需要运行时库 |
| SSR 支持 | 需要 Declarative Shadow DOM | 原生支持 | 需要额外配置 |
| 开发体验 | 调试较复杂 | 良好 | 良好,支持动态样式 |
4. HTML Templates 与 Slots
<template> 标签中的内容在页面加载时不会被渲染,但可以在 JavaScript 中被克隆使用。<slot> 则提供了内容分发机制,类似于 Vue 的插槽或 React 的 children。
<template id="card-template">
<style>
.card {
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
}
.card-header {
padding: 16px;
background: #f7fafc;
border-bottom: 1px solid #e2e8f0;
}
.card-body { padding: 16px; }
.card-footer {
padding: 12px 16px;
background: #f7fafc;
border-top: 1px solid #e2e8f0;
}
</style>
<div class="card">
<div class="card-header">
<!-- 具名 slot:外部通过 slot="header" 填充 -->
<slot name="header">默认标题</slot>
</div>
<div class="card-body">
<!-- 默认 slot:没有 slot attribute 的子元素会进入这里 -->
<slot>默认内容</slot>
</div>
<div class="card-footer">
<slot name="footer">默认底部</slot>
</div>
</div>
</template>
<script>
class MyCard extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
const template = document.getElementById('card-template') as HTMLTemplateElement;
// cloneNode(true) 深克隆模板内容
shadow.appendChild(template.content.cloneNode(true));
}
}
customElements.define('my-card', MyCard);
</script>
<!-- 使用 -->
<my-card>
<h2 slot="header">卡片标题</h2>
<p>这段内容会进入默认 slot</p>
<button slot="footer">操作按钮</button>
</my-card>
- 默认 slot(
<slot>):接收所有没有slotattribute 的子元素 - 具名 slot(
<slot name="xxx">):接收带slot="xxx"的子元素 - 默认内容:
<slot>标签之间的内容是 fallback,当没有对应内容分发时显示 - slotchange 事件:当 slot 分发的内容变化时触发,可用于响应内容变更
5. 完整组件示例:<my-dialog>
下面实现一个包含 Shadow DOM、template、slot 和完整生命周期的自定义对话框组件:
class MyDialog extends HTMLElement {
private shadow: ShadowRoot;
private isOpen = false;
static get observedAttributes(): string[] {
return ['open', 'title'];
}
constructor() {
super();
this.shadow = this.attachShadow({ mode: 'open' });
this.shadow.innerHTML = `
<style>
:host {
/* :host 选择器匹配组件自身 */
display: block;
}
:host([hidden]) {
display: none;
}
.overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s, visibility 0.3s;
}
.overlay.visible {
opacity: 1;
visibility: visible;
}
.dialog {
background: white;
border-radius: 12px;
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #e2e8f0;
}
.header h2 { margin: 0; font-size: 18px; }
.close-btn {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
}
.close-btn:hover { background: #f1f5f9; }
.body { padding: 20px; }
.footer {
padding: 12px 20px;
border-top: 1px solid #e2e8f0;
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
<div class="overlay" id="overlay">
<div class="dialog" role="dialog" aria-modal="true">
<div class="header">
<h2><slot name="title">对话框</slot></h2>
<button class="close-btn" id="close-btn">×</button>
</div>
<div class="body">
<slot></slot>
</div>
<div class="footer">
<slot name="footer"></slot>
</div>
</div>
</div>
`;
}
connectedCallback(): void {
// 绑定事件
this.shadow.getElementById('close-btn')!.addEventListener('click', () => {
this.close();
});
this.shadow.getElementById('overlay')!.addEventListener('click', (e) => {
if ((e.target as HTMLElement).id === 'overlay') {
this.close();
}
});
// 检查初始 open 状态
if (this.hasAttribute('open')) {
this.open();
}
}
disconnectedCallback(): void {
// 清理:确保移除时关闭对话框
document.body.style.overflow = '';
}
attributeChangedCallback(name: string, _old: string | null, newVal: string | null): void {
if (name === 'open') {
newVal !== null ? this.open() : this.close();
}
}
open(): void {
this.isOpen = true;
const overlay = this.shadow.getElementById('overlay')!;
overlay.classList.add('visible');
document.body.style.overflow = 'hidden';
this.dispatchEvent(new CustomEvent('dialog-open', { bubbles: true, composed: true }));
}
close(): void {
this.isOpen = false;
const overlay = this.shadow.getElementById('overlay')!;
overlay.classList.remove('visible');
document.body.style.overflow = '';
// composed: true 使事件可以穿透 Shadow DOM 边界
this.dispatchEvent(new CustomEvent('dialog-close', { bubbles: true, composed: true }));
}
}
customElements.define('my-dialog', MyDialog);
使用方式:
<my-dialog id="demo-dialog">
<span slot="title">确认删除</span>
<p>你确定要删除这条记录吗?此操作不可撤销。</p>
<div slot="footer">
<button onclick="document.getElementById('demo-dialog').close()">取消</button>
<button onclick="handleConfirm()">确认</button>
</div>
</my-dialog>
<button onclick="document.getElementById('demo-dialog').open()">打开对话框</button>
Web Components 内部的事件默认不会穿透 Shadow DOM 边界。如果需要外部监听,需要使用 CustomEvent 并设置 composed: true。同样,点击等原生事件也需要注意 event.composedPath() 来获取完整路径。关于事件机制的更多细节,请参阅浏览器事件机制。
6. 与框架集成
React 中使用 Web Components
在 React 18 及之前版本中,React 对 Web Components 的支持有一些限制:属性只会作为 attribute(字符串)设置,事件监听需要通过 ref 手动绑定。
import { useRef, useEffect } from 'react';
// 声明自定义元素的类型
declare global {
namespace JSX {
interface IntrinsicElements {
'my-counter': React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement> & {
initial?: string;
step?: string;
},
HTMLElement
>;
}
}
}
function App() {
const dialogRef = useRef<HTMLElement & { open: () => void; close: () => void }>(null);
useEffect(() => {
const el = dialogRef.current;
if (!el) return;
// React 18: 事件需要通过 ref 手动绑定
const handleClose = (e: Event) => {
console.log('Dialog closed', e);
};
el.addEventListener('dialog-close', handleClose);
return () => {
el.removeEventListener('dialog-close', handleClose);
};
}, []);
return (
<div>
{/* 基本使用 - attribute 传字符串 */}
<my-counter initial="10" step="2" />
{/* ref 绑定事件 */}
<my-dialog ref={dialogRef}>
<span slot="title">React Dialog</span>
<p>Content from React</p>
</my-dialog>
<button onClick={() => dialogRef.current?.open()}>Open</button>
</div>
);
}
React 19 对 Web Components 的支持有了重大改进:
- 属性自动映射:React 会自动判断将值设置为 property 还是 attribute
- 事件原生支持:可以直接使用
onXxx语法绑定自定义事件 - 不再需要 ref 绑定事件:大幅简化集成代码
// React 19 - 更简洁的写法
<my-dialog
onDialogClose={(e) => console.log('closed', e)} // 直接绑定
title="React 19 Dialog" // 自动设为 property
/>
Vue 中使用 Web Components
Vue 对 Web Components 有很好的原生支持,但需要告知 Vue 编译器哪些是自定义元素:
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
// 以 my- 开头的标签视为自定义元素,不做 Vue 组件解析
isCustomElement: (tag) => tag.startsWith('my-'),
},
},
}),
],
});
<template>
<!-- Vue 可以直接用 v-bind 传属性、@ 绑事件 -->
<my-counter :initial="count" step="1" />
<my-dialog ref="dialogRef" @dialog-close="handleClose">
<span slot="title">Vue Dialog</span>
<p>{{ message }}</p>
</my-dialog>
</template>
Angular 中使用(Angular Elements)
Angular 不仅可以使用 Web Components,还可以通过 Angular Elements 将 Angular 组件导出为 Web Components,供其他框架使用:
import { createCustomElement } from '@angular/elements';
@NgModule({
declarations: [MyWidgetComponent],
})
export class AppModule {
constructor(private injector: Injector) {
// 将 Angular 组件注册为自定义元素
const el = createCustomElement(MyWidgetComponent, { injector });
customElements.define('my-widget', el);
}
}
7. 应用场景
| 场景 | 说明 | 示例 |
|---|---|---|
| 微前端 | 框架无关的子应用封装,天然样式隔离 | 不同团队使用不同框架,通过自定义元素集成 |
| 跨团队/跨框架共享 UI | 同一组件在 React、Vue、Angular 中复用 | 公司级 UI 组件库的基础层 |
| Design System 基础层 | 用 Web Components 做底层,框架做上层封装 | Shoelace、Spectrum Web Components |
| 第三方嵌入式组件 | 嵌入到任意网站中不受宿主样式影响 | 聊天插件、评论组件、支付表单 |
| CMS/低代码平台 | 拖拽生成页面,组件标准化 | 自定义组件无需指定框架 |
Web Components 在微前端架构中发挥重要作用:
- 自定义元素封装子应用:每个子应用用
customElements.define注册,主应用只需插入 HTML 标签 - Shadow DOM 样式隔离:比 CSS Modules 方案更彻底,无需额外工具
- 通信机制:通过 attribute、CustomEvent、或全局 EventBus 进行跨组件通信
相比 Proxy 沙箱等 JS 层面的隔离方案,Shadow DOM 提供的是浏览器原生的 DOM/CSS 隔离,两者可以配合使用,详见设计前端沙箱隔离系统。
8. 局限性
| 局限 | 说明 | 应对方案 |
|---|---|---|
| SSR 支持差 | Shadow DOM 无法在服务端序列化 | Declarative Shadow DOM(Chrome 90+) |
| React 集成有摩擦 | React 18 之前不支持 property 直接传递 | 升级 React 19,或使用 ref 桥接 |
| 生态不如框架组件 | 社区库和工具链不如 React/Vue 丰富 | 使用 Lit 等轻量库提升 DX |
| 无内置状态管理 | 没有 React Hooks 或 Vue 响应式 | 自行实现或使用 Lit 的 reactive properties |
| 表单集成复杂 | Shadow DOM 中的 <input> 不会自动参与外部 <form> | 使用 ElementInternals API |
| Accessibility 需手动处理 | 需要手动添加 ARIA 属性和键盘导航 | 参考 WAI-ARIA Authoring Practices |
传统 Shadow DOM 只能通过 JavaScript 创建。Declarative Shadow DOM 允许在 HTML 中声明式地定义 Shadow DOM,解决了 SSR 的痛点:
<my-card>
<template shadowrootmode="open">
<style>
.card { border: 1px solid #ccc; padding: 16px; }
</style>
<div class="card">
<slot></slot>
</div>
</template>
<p>这段内容在 SSR 时就能正确渲染</p>
</my-card>
浏览器解析 HTML 时遇到 <template shadowrootmode="open"> 会自动创建 Shadow Root,无需等 JavaScript 加载。
9. 与微前端
Web Components 在微前端方案中的角色已经越来越重要,它提供了框架无关的组件封装能力:
- 子应用封装:每个微应用导出一个自定义元素,主应用只需按需插入
<micro-app-a>标签 - 样式隔离:Shadow DOM 提供原生级别的 CSS 隔离,避免样式冲突
- 生命周期管理:
connectedCallback/disconnectedCallback天然对应子应用的挂载/卸载 - 通信:通过 attribute 传参、CustomEvent 通知、共享状态管理
class MicroApp extends HTMLElement {
private shadow: ShadowRoot;
private app: unknown = null;
constructor() {
super();
this.shadow = this.attachShadow({ mode: 'open' });
}
async connectedCallback(): Promise<void> {
const appUrl = this.getAttribute('src');
if (!appUrl) return;
// 加载子应用资源
const container = document.createElement('div');
this.shadow.appendChild(container);
// 在 Shadow DOM 内挂载子应用
const module = await import(/* @vite-ignore */ appUrl);
this.app = module.mount(container);
}
disconnectedCallback(): void {
if (this.app && typeof (this.app as { unmount: () => void }).unmount === 'function') {
(this.app as { unmount: () => void }).unmount();
}
}
}
customElements.define('micro-app', MicroApp);
更完整的微前端方案设计请参考微前端架构和设计前端沙箱隔离系统。
常见面试问题
Q1: Web Components 包含哪三大核心技术?各自的作用是什么?
答案:
Web Components 由三大核心技术组成:
-
Custom Elements:允许开发者自定义 HTML 标签,继承
HTMLElement,通过customElements.define()注册。提供connectedCallback、disconnectedCallback、attributeChangedCallback、adoptedCallback四个生命周期回调。 -
Shadow DOM:为自定义元素提供独立的 DOM 树和样式作用域。通过
attachShadow({ mode: 'open' | 'closed' })创建。内部样式不会泄漏到外部,外部样式也不会影响内部(CSS 自定义属性除外),实现浏览器原生级别的样式隔离。 -
HTML Templates & Slots:
<template>标签定义不被渲染的模板片段,在 JavaScript 中通过cloneNode(true)复用。<slot>提供内容分发机制(类似 Vue 的插槽),支持默认 slot 和具名 slot。
这三者配合使用,可以创建封装良好、可复用、与框架无关的自定义组件。
Q2: Shadow DOM 如何实现样式隔离?open 和 closed 模式有什么区别?
答案:
样式隔离原理:
Shadow DOM 创建了一个与主文档隔离的 DOM 子树。浏览器在匹配 CSS 选择器时,不会跨越 Shadow DOM 边界:
- 外部样式表中的选择器(如
div { color: red })不会匹配 Shadow DOM 内部的元素 - Shadow DOM 内部的样式不会泄漏到外部
- 例外:CSS 自定义属性(
--my-color)可以穿透 Shadow 边界,这是设计留的样式定制入口
可以通过 ::part() 伪元素选择器对组件内部暴露了 part attribute 的元素进行样式化。
open vs closed:
// open - 外部可通过 element.shadowRoot 访问内部 DOM
this.attachShadow({ mode: 'open' });
document.querySelector('my-el')!.shadowRoot; // ShadowRoot
// closed - 外部无法访问
this.attachShadow({ mode: 'closed' });
document.querySelector('my-el')!.shadowRoot; // null
| 对比项 | open | closed |
|---|---|---|
| 外部访问 | element.shadowRoot 可用 | 返回 null |
| 调试便利性 | DevTools 可直接查看 | 需要内部方法暴露 |
| 安全性 | 较低,外部可操作内部 DOM | 较高,但无法完全防止 |
| 推荐场景 | 绝大多数场景 | 严格封装需求(如支付表单) |
即使使用 closed 模式,也不能作为安全机制依赖。攻击者可以通过重写 Element.prototype.attachShadow 等方式绕过限制。真正的安全应依赖服务端验证。
Q3: Custom Elements 的生命周期回调有哪些?分别在什么时候触发?
答案:
Custom Elements 有四个生命周期回调:
class LifecycleDemo extends HTMLElement {
static get observedAttributes(): string[] {
return ['theme', 'size'];
}
constructor() {
super();
// 1. 元素被创建时(通过 new 或 HTML 解析)
// 注意:此时不应访问 attribute 或子元素
console.log('constructor: 元素被创建');
}
connectedCallback(): void {
// 2. 元素被插入到 DOM 时
// 适合:初始渲染、添加事件监听、发起请求
console.log('connected: 插入 DOM');
}
disconnectedCallback(): void {
// 3. 元素从 DOM 中移除时
// 适合:清理定时器、移除事件监听、断开连接
console.log('disconnected: 从 DOM 移除');
}
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {
// 4. observedAttributes 中声明的 attribute 变化时
// 仅监听 observedAttributes 返回的属性
console.log(`attribute changed: ${name} ${oldValue} -> ${newValue}`);
}
adoptedCallback(): void {
// 5. 元素被 document.adoptNode() 移到新文档时(如 iframe)
// 极少使用
console.log('adopted: 移到新文档');
}
}
触发顺序:constructor -> attributeChangedCallback(如果有初始 attribute)-> connectedCallback
关键注意点:
constructor中不能访问 attribute 和子元素,初始化渲染应在connectedCallback中attributeChangedCallback只响应observedAttributes声明的 attributeconnectedCallback可能被多次调用(元素被移动时)disconnectedCallback适合做资源清理,类似 React 的useEffect返回的清理函数
Q4: Web Components 和 React/Vue 组件有什么区别?各自的优缺点?
答案:
| 对比维度 | Web Components | React/Vue 组件 |
|---|---|---|
| 标准 | W3C 浏览器标准 | 框架私有实现 |
| 运行环境 | 任何浏览器,无需框架 | 需要对应框架运行时 |
| 样式隔离 | Shadow DOM 原生隔离 | 需要 CSS Modules / CSS-in-JS |
| 状态管理 | 无内置方案,需手动实现 | useState / ref / reactive 等 |
| 响应式更新 | 手动操作 DOM | Virtual DOM / 响应式自动更新 |
| 生态工具 | 较少(Lit、Stencil) | 极其丰富(路由、状态管理、UI 库) |
| SSR 支持 | 差(需 Declarative Shadow DOM) | 成熟(Next.js / Nuxt) |
| 学习成本 | 较低(原生 API) | 中等(需学习框架概念) |
| 类型支持 | 需手动声明 | React/Vue + TS 支持完善 |
| 跨框架复用 | 天然支持 | 不可跨框架 |
何时选择 Web Components:
- 需要跨多个框架共享组件(公司级 Design System)
- 构建第三方嵌入式组件(不希望引入框架依赖)
- 微前端中需要框架无关的组件封装
- 对 bundle size 极度敏感的场景
何时选择框架组件:
- 构建完整的单页应用
- 需要丰富的生态工具支持
- 团队已有框架技术栈
- 需要 SSR/SSG 等高级特性
实际项目中,可以采用混合方案:用 Web Components 构建底层不含业务逻辑的原子组件(按钮、输入框、弹窗),用框架组件构建业务组件和页面。Shoelace、Spectrum Web Components 就是这种模式。
Q5: 如何在 React 中使用 Web Components?有什么注意事项?
答案:
React 18 及之前的注意事项:
-
属性传递问题:React 将所有 props 作为 HTML attribute(字符串)设置到 DOM 上,而 Web Components 通常需要 property(任意类型)。对象/数组类型的数据会被
toString()成[object Object]。 -
事件绑定问题:React 的事件系统基于合成事件,
onXxx只能绑定标准 DOM 事件。Web Components 的CustomEvent需要通过ref手动绑定。
function App() {
const ref = useRef<HTMLElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
// 问题 1:复杂数据需要通过 property 设置
(el as any).config = { theme: 'dark', items: [1, 2, 3] };
// 问题 2:自定义事件需要通过 addEventListener
const handler = (e: Event) => console.log(e);
el.addEventListener('my-event', handler);
return () => el.removeEventListener('my-event', handler);
}, []);
return <my-component ref={ref} name="hello" />;
}
React 19 的改进:
// React 19 自动处理 property vs attribute
function App() {
return (
<my-component
// 自动作为 property 传递(不再 toString)
config={{ theme: 'dark', items: [1, 2, 3] }}
// 自定义事件可以直接绑定
onMyEvent={(e: CustomEvent) => console.log(e.detail)}
name="hello"
/>
);
}
通用最佳实践:
- 使用
declare global声明自定义元素的 JSX 类型 - 在 React 18 中创建 wrapper 组件封装 ref 逻辑
- 确保 Web Components 的 script 在 React 渲染前加载
- 使用
composed: true确保自定义事件能穿透 Shadow DOM
Q6: Web Components 在微前端中有什么作用?
答案:
Web Components 在微前端中解决了三个核心问题:
1. 框架无关的组件封装
每个子应用可以用任意框架开发,最终导出为自定义元素。主应用不需要知道子应用使用了什么框架:
// 子应用导出
class SubAppA extends HTMLElement {
connectedCallback() {
// 内部使用 React
ReactDOM.createRoot(this.shadowRoot!).render(<App />);
}
disconnectedCallback() {
ReactDOM.unmountComponentAtNode(this.shadowRoot!);
}
}
customElements.define('sub-app-a', SubAppA);
// 主应用使用 - 不关心内部框架
document.body.innerHTML = '<sub-app-a></sub-app-a>';
2. 样式隔离
Shadow DOM 提供浏览器原生的样式隔离,比 CSS 前缀、CSS Modules 更彻底:
- 子应用的全局样式(如
* { box-sizing: border-box })不会影响主应用 - 主应用的 reset CSS 不会影响子应用
- 无需运行时的样式处理开销
3. 生命周期对齐
Custom Elements 的生命周期天然匹配微前端场景:
connectedCallback= 子应用挂载(加载资源、渲染)disconnectedCallback= 子应用卸载(清理状态、移除监听)attributeChangedCallback= 接收主应用参数变化
4. 通信方式
| 方式 | 适用场景 | 示例 |
|---|---|---|
| Attribute | 简单数据传递 | <sub-app route="/home"> |
| Property | 复杂数据传递 | el.userInfo = { name: 'Alice' } |
| CustomEvent | 子 -> 父通信 | dispatchEvent(new CustomEvent('navigate')) |
| 全局 EventBus | 跨组件通信 | 共享的发布订阅实例 |
关于微前端的完整架构设计,包括 JS 沙箱、路由劫持、应用生命周期管理等,请参考微前端架构。关于沙箱隔离的具体实现,请参考设计前端沙箱隔离系统。