Skip to content

第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-familycolor)会穿透进来。

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 重排或动画),disconnectedCallbackconnectedCallback 会成对触发。这意味着你的 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);

基于 VitePress 构建