Appearance
第12章 Web Components 与微前端
"最好的隔离不是框架给你的——是浏览器本来就有的。"
本章要点
- 深入理解 Shadow DOM 的两种模式(open/closed)及其在微前端场景中的隔离能力与边界
- 掌握 Custom Elements 的完整生命周期,将其作为微应用容器实现加载、通信与销毁
- 通过 Lit 框架的实战案例,体验 Web Components 驱动的微前端开发范式
- 正视 Web Components 的真实局限:SSR 兼容性、表单集成、无障碍访问的挑战与应对策略
- 理解 Web Components 在微前端技术版图中的独特定位:不是替代方案,而是基础设施
你可能已经注意到一个有趣的现象:前面章节中,无论是乾坤的 strictStyleIsolation,还是无界的组件级嵌入方案,底层都绕不开同一个东西——Web Components。
这不是巧合。
当我们费尽心思用 JavaScript 去模拟 CSS 隔离、用 Proxy 去拦截全局变量、用各种 hack 去阻止子应用之间的相互污染时,浏览器其实早就准备好了一套原生的隔离方案。Shadow DOM 提供 DOM 和样式的天然边界,Custom Elements 提供标准化的生命周期钩子,HTML Templates 和 Slots 提供灵活的内容分发机制。这三驾马车组成的 Web Components 标准,本身就是浏览器对"组件隔离"问题的官方回答。
那么问题来了:既然浏览器原生就支持隔离,为什么微前端框架们还要自己造轮子?
答案并不简单。这一章,我们将从 Shadow DOM 的隔离机制出发,一路走到 Custom Elements 容器化实践,再用 Lit 框架搭建一个完整的微前端方案,最后直面 Web Components 的真实局限。读完之后,你会理解:Web Components 不是微前端的银弹,但它是微前端架构师工具箱里最不该被忽视的那把瑞士军刀。
下图展示了 Web Components 三大标准在微前端中各自承担的角色及其协作关系:
12.1 Shadow DOM:浏览器原生的隔离机制
12.1.1 Shadow DOM 的本质:一面单向镜
要理解 Shadow DOM,最好忘掉所有技术文档里的抽象定义,想象一面单向镜。
从外面(Light DOM)看进去,你看不到里面的细节——内部的样式、结构、事件都被隔离在镜子后面。但从里面(Shadow DOM)看出去,你依然能感知到外部世界的存在——继承的 CSS 属性(如 font-family、color)会穿透进来。
typescript
// 创建一面"单向镜"
class IsolatedContainer extends HTMLElement {
constructor() {
super();
// attachShadow 就是安装这面镜子
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
/* 这些样式只在镜子内部生效 */
.title { color: red; font-size: 24px; }
.content { padding: 16px; background: #f5f5f5; }
</style>
<div class="title">我是隔离的标题</div>
<div class="content">
<slot></slot>
</div>
`;
}
}
customElements.define('isolated-container', IsolatedContainer);html
<style>
/* 外部样式:试图影响 Shadow DOM 内部 */
.title { color: blue; font-size: 48px; }
.content { background: yellow; }
</style>
<isolated-container>
<p>我是 Light DOM 中的内容,会被投影到 slot 中</p>
</isolated-container>
<!-- 结果:Shadow DOM 内部的 .title 是红色 24px,不受外部 .title 影响 -->
<!-- 外部的 .title 规则对 Shadow DOM 内部完全无效 -->这个例子揭示了 Shadow DOM 隔离的核心特征:CSS 选择器无法穿透 Shadow Boundary。无论外部写了多么激进的 * { color: blue !important; },Shadow DOM 内部的元素都不会被匹配到。这正是微前端梦寐以求的样式隔离能力。
12.1.2 open 与 closed:两种隔离哲学
下图展示了 Shadow DOM 的 open 和 closed 两种模式在微前端场景下的信息可访问性差异:
attachShadow 接受一个 mode 参数,它决定了外部代码能否通过 JavaScript 访问 Shadow DOM 内部:
typescript
// mode: 'open' —— 协作式隔离
const openShadow = element.attachShadow({ mode: 'open' });
// 外部可以通过 element.shadowRoot 访问内部 DOM
console.log(element.shadowRoot); // ShadowRoot {...}
console.log(element.shadowRoot.querySelector('.title')); // <div class="title">
// mode: 'closed' —— 强制式隔离
const closedShadow = element.attachShadow({ mode: 'closed' });
// 外部无法通过标准 API 访问内部 DOM
console.log(element.shadowRoot); // null这两种模式背后是截然不同的设计哲学:
| 特性 | open 模式 | closed 模式 |
|---|---|---|
element.shadowRoot | 返回 ShadowRoot | 返回 null |
| 外部 JS 可否操作内部 DOM | 可以 | 不可以(标准途径) |
| CSS 隔离 | 完全隔离 | 完全隔离 |
| 事件 retarget | 是 | 是 |
| 适用场景 | 组件库、微前端容器 | 安全敏感的第三方组件 |
| 浏览器原生使用 | <video>、<input> 等 | <video> 的内部控件 |
在微前端场景中,绝大多数时候应该选择 open 模式。原因很实际:
typescript
// 微前端主应用可能需要与子应用的 Shadow DOM 交互
class MicroAppContainer extends HTMLElement {
private shadow: ShadowRoot;
constructor() {
super();
// 使用 open 模式,允许主应用在必要时操作内部 DOM
// 比如:注入全局样式变量、监控子应用状态、错误捕获
this.shadow = this.attachShadow({ mode: 'open' });
}
// 主应用可能需要向子应用注入主题变量
injectThemeVariables(variables: Record<string, string>): void {
const styleEl = document.createElement('style');
const cssVars = Object.entries(variables)
.map(([key, value]) => `--${key}: ${value};`)
.join('\n');
styleEl.textContent = `:host { ${cssVars} }`;
this.shadow.appendChild(styleEl);
}
}而 closed 模式虽然看似更安全,但实际上存在一个尴尬的事实——它并不能真正阻止恶意访问。通过拦截 Element.prototype.attachShadow,攻击者完全可以在组件创建之前截获 ShadowRoot 引用:
typescript
// 绕过 closed 模式的"攻击"手段
const originalAttachShadow = Element.prototype.attachShadow;
const shadowRootMap = new WeakMap<Element, ShadowRoot>();
Element.prototype.attachShadow = function(init: ShadowRootInit): ShadowRoot {
const shadowRoot = originalAttachShadow.call(this, init);
// 即使是 closed 模式,这里也能拿到 shadowRoot 引用
shadowRootMap.set(this, shadowRoot);
return shadowRoot;
};
// 后续代码可以通过 shadowRootMap.get(element) 获取任何元素的 ShadowRoot💡 深度洞察:
closed模式的设计初衷不是防御恶意代码——那是安全沙箱(如 iframe)的工作。它的真正价值在于声明意图:告诉组件的使用者"请不要依赖我的内部结构,因为它随时可能变化"。这和面向对象编程中private的理念一致——防君子不防小人,但对代码维护极有价值。
12.1.3 样式隔离的细节:什么能穿透,什么不能
下图展示了 Shadow DOM 样式隔离的边界,区分了被阻断和可穿透的不同类型的样式规则:
Shadow DOM 的样式隔离不是"绝对的墙",更像是"有窗户的墙"。理解哪些东西能穿透、哪些不能,对于微前端的样式管理至关重要。
typescript
// 演示样式穿透行为
class StylePenetrationDemo extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
.box {
padding: 20px;
border: 1px solid #ccc;
}
</style>
<div class="box">
<p>观察我的字体和颜色</p>
<a href="#">观察我是否有下划线</a>
</div>
`;
}
}
customElements.define('style-demo', StylePenetrationDemo);html
<style>
body {
font-family: 'Microsoft YaHei', sans-serif;
color: #333;
font-size: 14px;
line-height: 1.6;
}
a { color: red; text-decoration: none; }
p { margin-bottom: 20px; }
</style>
<style-demo></style-demo>能穿透 Shadow Boundary 的:
| CSS 属性 | 穿透行为 | 原因 |
|---|---|---|
font-family | 继承穿透 | 可继承属性 |
color | 继承穿透 | 可继承属性 |
font-size | 继承穿透 | 可继承属性 |
line-height | 继承穿透 | 可继承属性 |
| CSS Custom Properties | 继承穿透 | 设计如此,这是特性 |
不能穿透 Shadow Boundary 的:
| CSS 属性/选择器 | 被阻挡 | 原因 |
|---|---|---|
标签选择器 p { } | 阻挡 | 选择器无法穿透 |
类选择器 .box { } | 阻挡 | 选择器无法穿透 |
a { color: red } | 阻挡 | 选择器无法穿透 |
全局重置 * { } | 阻挡 | 选择器无法穿透 |
这意味着在微前端场景中,CSS 自定义属性(Custom Properties)是主应用向子应用传递设计令牌(Design Tokens)的最佳通道:
typescript
// 主应用:通过 CSS Custom Properties 传递设计体系
class ThemeAwareMicroApp extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host {
display: block;
}
.header {
/* 使用主应用传递的设计令牌,提供合理的 fallback */
background: var(--theme-primary, #1890ff);
color: var(--theme-text-inverse, #fff);
padding: var(--theme-spacing-md, 16px);
border-radius: var(--theme-radius, 4px);
font-size: var(--theme-font-size-lg, 18px);
}
.body {
padding: var(--theme-spacing-md, 16px);
color: var(--theme-text-primary, #333);
background: var(--theme-bg-primary, #fff);
}
</style>
<div class="header">
<slot name="title">默认标题</slot>
</div>
<div class="body">
<slot></slot>
</div>
`;
}
}
customElements.define('theme-aware-app', ThemeAwareMicroApp);