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);html
<!-- 主应用通过 CSS 变量控制所有子应用的主题 -->
<style>
:root {
--theme-primary: #722ed1;
--theme-text-inverse: #fff;
--theme-text-primary: #262626;
--theme-bg-primary: #fafafa;
--theme-spacing-md: 20px;
--theme-radius: 8px;
--theme-font-size-lg: 20px;
}
</style>
<theme-aware-app>
<span slot="title">订单管理子应用</span>
<p>这里是子应用的内容区域</p>
</theme-aware-app>12.1.4 Slot 分发:Light DOM 与 Shadow DOM 的桥梁
Slot 是 Web Components 中最容易被低估的特性。在微前端场景中,它解决了一个关键问题:如何让主应用向子应用容器内注入内容,同时保持隔离。
typescript
class MicroAppShell extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host {
display: flex;
flex-direction: column;
height: 100%;
}
.shell-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: #fafafa;
border-bottom: 1px solid #e8e8e8;
}
.shell-body {
flex: 1;
overflow: auto;
position: relative;
}
.shell-footer {
padding: 8px 16px;
border-top: 1px solid #e8e8e8;
background: #fafafa;
}
</style>
<div class="shell-header">
<slot name="header">
<span>未命名应用</span>
</slot>
</div>
<div class="shell-body">
<!-- 默认 slot:子应用的主内容区域 -->
<slot></slot>
</div>
<div class="shell-footer">
<slot name="footer">
<span>v1.0.0</span>
</slot>
</div>
`;
}
}
customElements.define('micro-app-shell', MicroAppShell);html
<!-- 主应用可以灵活控制每个子应用容器的 header 和 footer -->
<micro-app-shell>
<div slot="header">
<h3>📦 订单管理</h3>
<button onclick="refreshApp()">刷新</button>
</div>
<!-- 没有 slot 属性的内容进入默认 slot -->
<div id="order-app-root"></div>
<div slot="footer">
<span>最后更新:2 分钟前</span>
<a href="/help">帮助</a>
</div>
</micro-app-shell>Slot 分发有一个关键特性:被分发的内容(Light DOM)的样式由外部(主应用)控制,而容器的布局由 Shadow DOM 内部控制。这种"内容与容器分离"的模型,天然适合微前端的"主应用管布局、子应用管内容"的职责划分。
12.2 Custom Elements 作为微应用容器
12.2.1 生命周期:Web 标准的 bootstrap-mount-unmount
Custom Elements 规范定义了一组生命周期回调,它们与微前端子应用的生命周期有着惊人的对应关系:
typescript
// Custom Elements 生命周期与微前端生命周期的映射
class MicroAppElement extends HTMLElement {
// ========== 生命周期回调 ==========
/**
* constructor: 元素被创建时调用
* 对应微前端:初始化阶段(类似 single-spa 的 bootstrap 前置)
* 注意:此时元素尚未插入 DOM,不要访问属性或子元素
*/
constructor() {
super();
console.log('[lifecycle] constructor - 元素被创建');
this.attachShadow({ mode: 'open' });
// 只做最基本的初始化:创建 Shadow DOM、声明内部状态
this._initialized = false;
this._appInstance = null;
}
/**
* connectedCallback: 元素被插入 DOM 时调用
* 对应微前端:mount 阶段
* 这是加载和挂载子应用的最佳时机
*/
connectedCallback(): void {
console.log('[lifecycle] connectedCallback - 元素插入 DOM');
this._mountApp();
}
/**
* disconnectedCallback: 元素从 DOM 移除时调用
* 对应微前端:unmount 阶段
* 必须在此处清理所有资源,防止内存泄漏
*/
disconnectedCallback(): void {
console.log('[lifecycle] disconnectedCallback - 元素从 DOM 移除');
this._unmountApp();
}
/**
* attributeChangedCallback: 被观察的属性变化时调用
* 对应微前端:props 更新阶段
* 主应用通过修改 attribute 向子应用传递数据
*/
static get observedAttributes(): string[] {
return ['src', 'app-name', 'active'];
}
attributeChangedCallback(
name: string,
oldValue: string | null,
newValue: string | null
): void {
console.log(`[lifecycle] attributeChanged: ${name} = ${oldValue} -> ${newValue}`);
if (name === 'src' && oldValue !== newValue && this.isConnected) {
// 资源地址变化,重新加载子应用
this._unmountApp();
this._mountApp();
}
if (name === 'active' && newValue === 'false') {
this._deactivateApp();
}
}
/**
* adoptedCallback: 元素被移动到新的 document 时调用
* 场景较少,但在 iframe 通信场景中可能触发
*/
adoptedCallback(): void {
console.log('[lifecycle] adoptedCallback - 元素被移至新文档');
}
// ========== 内部方法 ==========
private _initialized: boolean;
private _appInstance: any;
private async _mountApp(): Promise<void> {
const src = this.getAttribute('src');
if (!src) return;
// 在 Shadow DOM 中创建挂载点
this.shadowRoot!.innerHTML = `
<style>
:host { display: block; position: relative; }
.loading { text-align: center; padding: 40px; color: #999; }
.error { color: #ff4d4f; padding: 16px; background: #fff2f0; border-radius: 4px; }
.app-root { width: 100%; height: 100%; }
</style>
<div class="loading">加载中...</div>
<div class="app-root"></div>
`;
try {
// 加载子应用资源
const module = await import(/* @vite-ignore */ src);
const mountPoint = this.shadowRoot!.querySelector('.app-root')!;
const loadingEl = this.shadowRoot!.querySelector('.loading')!;
// 调用子应用的 mount 函数
this._appInstance = await module.mount({
container: mountPoint,
props: this._getProps()
});
loadingEl.remove();
this._initialized = true;
// 派发自定义事件通知主应用
this.dispatchEvent(new CustomEvent('app-mounted', {
bubbles: true,
composed: true, // composed: true 允许事件穿透 Shadow Boundary
detail: { appName: this.getAttribute('app-name') }
}));
} catch (error) {
this.shadowRoot!.querySelector('.loading')!.remove();
this.shadowRoot!.querySelector('.app-root')!.innerHTML = `
<div class="error">
子应用加载失败: ${(error as Error).message}
</div>
`;
this.dispatchEvent(new CustomEvent('app-error', {
bubbles: true,
composed: true,
detail: { appName: this.getAttribute('app-name'), error }
}));
}
}
private _unmountApp(): void {
if (this._appInstance && typeof this._appInstance.unmount === 'function') {
this._appInstance.unmount();
}
this._appInstance = null;
this._initialized = false;
}
private _deactivateApp(): void {
if (this._appInstance && typeof this._appInstance.deactivate === 'function') {
this._appInstance.deactivate();
}
}
private _getProps(): Record<string, string> {
const props: Record<string, string> = {};
for (const attr of this.attributes) {
if (attr.name !== 'src' && attr.name !== 'class' && attr.name !== 'style') {
props[attr.name] = attr.value;
}
}
return props;
}
}
customElements.define('micro-app', MicroAppElement);使用起来,就像使用一个普通的 HTML 标签一样自然:
html
<!-- 声明式的子应用加载 -->
<micro-app
src="https://cdn.example.com/order-app/main.js"
app-name="order"
active="true"
api-base="https://api.example.com"
></micro-app>
<!-- 主应用监听子应用事件 -->
<script>
document.querySelector('micro-app').addEventListener('app-mounted', (e) => {
console.log(`${e.detail.appName} 子应用已挂载`);
});
// 通过修改 attribute 控制子应用
document.querySelector('micro-app').setAttribute('active', 'false');
</script>💡 深度洞察:注意
connectedCallback可能被多次调用。当元素从 DOM 移除后再次插入(比如 DOM 重排或动画),disconnectedCallback和connectedCallback会成对触发。这意味着你的 mount/unmount 逻辑必须是幂等的——反复调用不会产生副作用。这也是很多初学者踩坑的地方:在constructor里做了应该在connectedCallback里做的事,导致二次挂载失败。
12.2.2 完整的微前端容器实现
让我们把上面的简单示例扩展为一个生产级的微前端容器。这个容器需要处理真实场景中的各种边界情况:
typescript
// 类型定义
interface MicroAppConfig {
name: string;
entry: string;
activeRule?: string | ((location: Location) => boolean);
props?: Record<string, unknown>;
sandbox?: boolean;
prefetch?: boolean;
}
interface MicroAppLifecycle {
bootstrap: () => Promise<void>;
mount: (props: MountProps) => Promise<void>;
unmount: () => Promise<void>;
update?: (props: MountProps) => Promise<void>;
}
interface MountProps {
container: HTMLElement;
props: Record<string, unknown>;
onGlobalStateChange: (callback: (state: Record<string, unknown>) => void) => void;
setGlobalState: (state: Record<string, unknown>) => void;
}
// 全局状态管理
class GlobalStateManager {
private state: Record<string, unknown> = {};
private listeners: Array<(state: Record<string, unknown>) => void> = [];
getState(): Record<string, unknown> {
return { ...this.state };
}
setState(partial: Record<string, unknown>): void {
this.state = { ...this.state, ...partial };
this.listeners.forEach(fn => fn(this.getState()));
}
onChange(callback: (state: Record<string, unknown>) => void): () => void {
this.listeners.push(callback);
return () => {
this.listeners = this.listeners.filter(fn => fn !== callback);
};
}
}
const globalState = new GlobalStateManager();
// 资源加载器
class ResourceLoader {
private static cache = new Map<string, MicroAppLifecycle>();
static async load(entry: string): Promise<MicroAppLifecycle> {
if (this.cache.has(entry)) {
return this.cache.get(entry)!;
}
// 判断入口类型
if (entry.endsWith('.js')) {
return this.loadJSEntry(entry);
} else {
return this.loadHTMLEntry(entry);
}
}
private static async loadJSEntry(url: string): Promise<MicroAppLifecycle> {
const module = await import(/* @vite-ignore */ url);
const lifecycle: MicroAppLifecycle = {
bootstrap: module.bootstrap || (async () => {}),
mount: module.mount,
unmount: module.unmount,
update: module.update,
};
this.cache.set(url, lifecycle);
return lifecycle;
}
private static async loadHTMLEntry(url: string): Promise<MicroAppLifecycle> {
const response = await fetch(url);
const html = await response.text();
// 解析 HTML,提取 JS 和 CSS 资源
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// 收集样式
const styles: string[] = [];
doc.querySelectorAll('link[rel="stylesheet"]').forEach(link => {
const href = (link as HTMLLinkElement).href;
if (href) styles.push(href);
});
doc.querySelectorAll('style').forEach(style => {
styles.push(style.textContent || '');
});
// 收集脚本
const scripts: string[] = [];
doc.querySelectorAll('script[src]').forEach(script => {
scripts.push((script as HTMLScriptElement).src);
});
// 返回从 HTML 中提取的生命周期
// 实际实现会更复杂,这里简化处理
const lifecycle: MicroAppLifecycle = {
bootstrap: async () => {},
mount: async (props: MountProps) => {
// 注入样式
for (const style of styles) {
if (style.startsWith('http')) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = style;
props.container.appendChild(link);
} else {
const styleEl = document.createElement('style');
styleEl.textContent = style;
props.container.appendChild(styleEl);
}
}
// 注入 HTML 模板
const body = doc.querySelector('body');
if (body) {
const fragment = document.createDocumentFragment();
Array.from(body.children).forEach(child => {
if (child.tagName !== 'SCRIPT') {
fragment.appendChild(child.cloneNode(true));
}
});
props.container.appendChild(fragment);
}
},
unmount: async () => {}
};
this.cache.set(url, lifecycle);
return lifecycle;
}
static prefetch(entry: string): void {
// 利用 requestIdleCallback 在空闲时预加载
if ('requestIdleCallback' in window) {
requestIdleCallback(() => this.load(entry));
} else {
setTimeout(() => this.load(entry), 1000);
}
}
}
// 生产级微前端容器
class MicroFrontendContainer extends HTMLElement {
private shadow: ShadowRoot;
private lifecycle: MicroAppLifecycle | null = null;
private mounted = false;
private unsubscribeState: (() => void) | null = null;
static get observedAttributes(): string[] {
return ['src', 'name', 'active', 'props'];
}
constructor() {
super();
this.shadow = this.attachShadow({ mode: 'open' });
this.renderLoading();
}
connectedCallback(): void {
const src = this.getAttribute('src');
if (src) {
this.loadAndMount(src);
}
}
disconnectedCallback(): void {
this.unmountApp();
if (this.unsubscribeState) {
this.unsubscribeState();
this.unsubscribeState = null;
}
}
attributeChangedCallback(name: string, oldVal: string | null, newVal: string | null): void {
if (!this.isConnected) return;
switch (name) {
case 'src':
if (oldVal !== newVal && newVal) {
this.unmountApp();
this.renderLoading();
this.loadAndMount(newVal);
}
break;
case 'active':
if (newVal === 'false' && this.mounted) {
this.unmountApp();
} else if (newVal !== 'false' && !this.mounted) {
const src = this.getAttribute('src');
if (src) this.loadAndMount(src);
}
break;
case 'props':
if (this.lifecycle?.update && this.mounted && newVal) {
try {
const props = JSON.parse(newVal);
this.lifecycle.update(this.createMountProps(props));
} catch (e) {
console.warn('Invalid props JSON:', newVal);
}
}
break;
}
}
private renderLoading(): void {
this.shadow.innerHTML = `
<style>
:host {
display: block;
position: relative;
min-height: 100px;
}
.mf-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
color: #8c8c8c;
font-size: 14px;
}
.mf-loading::before {
content: '';
width: 20px;
height: 20px;
border: 2px solid #e8e8e8;
border-top-color: #1890ff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-right: 8px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.mf-error {
padding: 16px;
background: #fff2f0;
border: 1px solid #ffccc7;
border-radius: 4px;
color: #ff4d4f;
font-size: 14px;
}
.mf-error-title {
font-weight: 600;
margin-bottom: 8px;
}
.mf-error-detail {
color: #595959;
font-size: 12px;
}
.mf-app-root {
width: 100%;
}
</style>
<div class="mf-loading">加载子应用中...</div>
<div class="mf-app-root"></div>
`;
}
private renderError(error: Error): void {
const appRoot = this.shadow.querySelector('.mf-app-root');
const loading = this.shadow.querySelector('.mf-loading');
if (loading) loading.remove();
if (appRoot) {
appRoot.innerHTML = `
<div class="mf-error">
<div class="mf-error-title">子应用 ${this.getAttribute('name') || '未知'} 加载失败</div>
<div class="mf-error-detail">${error.message}</div>
</div>
`;
}
}
private createMountProps(extraProps?: Record<string, unknown>): MountProps {
const container = this.shadow.querySelector('.mf-app-root') as HTMLElement;
return {
container,
props: {
...this.getAttributeProps(),
...extraProps,
},
onGlobalStateChange: (callback) => {
this.unsubscribeState = globalState.onChange(callback);
},
setGlobalState: (state) => {
globalState.setState(state);
},
};
}
private getAttributeProps(): Record<string, unknown> {
const props: Record<string, unknown> = {};
const skipAttrs = new Set(['src', 'name', 'active', 'props', 'class', 'style', 'id']);
for (const attr of this.attributes) {
if (!skipAttrs.has(attr.name)) {
props[attr.name] = attr.value;
}
}
// 合并 JSON props
const jsonProps = this.getAttribute('props');
if (jsonProps) {
try {
Object.assign(props, JSON.parse(jsonProps));
} catch (e) { /* ignore */ }
}
return props;
}
private async loadAndMount(src: string): Promise<void> {
try {
this.lifecycle = await ResourceLoader.load(src);
await this.lifecycle.bootstrap();
await this.lifecycle.mount(this.createMountProps());
// 移除 loading
const loading = this.shadow.querySelector('.mf-loading');
if (loading) loading.remove();
this.mounted = true;
this.dispatchEvent(new CustomEvent('micro-app:mounted', {
bubbles: true,
composed: true,
detail: { name: this.getAttribute('name') }
}));
} catch (error) {
this.renderError(error as Error);
this.dispatchEvent(new CustomEvent('micro-app:error', {
bubbles: true,
composed: true,
detail: { name: this.getAttribute('name'), error }
}));
}
}
private async unmountApp(): Promise<void> {
if (this.lifecycle && this.mounted) {
try {
await this.lifecycle.unmount();
} catch (error) {
console.error(`[micro-frontend] unmount error:`, error);
}
this.mounted = false;
}
}
}
customElements.define('micro-frontend', MicroFrontendContainer);