微前端源码精讲

第12章 Web Components 与微前端

作者 杨艺韬 · 13,361 字 · 发布于 · 更新于

第12章 Web Components 与微前端

最好的隔离不是框架给你的——是浏览器本来就有的。

本章要点

  • 深入理解 Shadow DOM 的两种模式(open/closed)及其在微前端场景中的隔离能力与边界
  • 掌握 Custom Elements 的完整生命周期,将其作为微应用容器实现加载、通信与销毁
  • 通过 Lit 框架的实战案例,体验 Web Components 驱动的微前端开发范式
  • 正视 Web Components 的真实局限:SSR 兼容性、表单集成、无障碍访问的挑战与应对策略
  • 理解 Web Components 在微前端技术版图中的独特定位:不是替代方案,而是基础设施

本章 Web Components 的 API 名称、默认值与浏览器行为以 WHATWG HTML/DOM 标准和 MDN 为准,浏览器版本号以 MDN 兼容性表为准;Lit 示例按 Lit 3 的 API 书写,使用 TypeScript 实验性装饰器,需在 tsconfig 中设 "experimentalDecorators": true、"useDefineForClassFields": false(Lit 官方文档 Decorators 一节的要求)。


你可能已经注意到一个有趣的现象:前面章节中,无论是乾坤的 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 三大标准在微前端中各自承担的角色及其协作关系:

flowchart TB
    WC["Web Components 标准"] --> SD["Shadow DOM\n样式与 DOM 隔离"]
    WC --> CE["Custom Elements\n自定义元素生命周期"]
    WC --> HT["HTML Templates & Slots\n内容分发"]
    SD --> SDUse["微前端中的作用"]
    SDUse --> CSSIsolate["CSS 天然隔离\n选择器无法穿透"]
    SDUse --> DOMBoundary["DOM 边界\n事件 retarget"]
    CE --> CEUse["微前端中的作用"]
    CEUse --> Container["子应用容器\nconnectedCallback / disconnectedCallback"]
    CEUse --> Lifecycle["标准化生命周期\n挂载 / 卸载 / 属性变化"]
    HT --> HTUse["微前端中的作用"]
    HTUse --> SlotProject["内容投影\n主应用内容嵌入子应用"]
    style WC fill:#e3f2fd,stroke:#1565c0
    style SD fill:#ffebee,stroke:#c62828
    style CE fill:#e8f5e9,stroke:#2e7d32
    style HT fill:#fff3e0,stroke:#e65100

12.1 Shadow DOM:浏览器原生的隔离机制

先明确一个容易混淆的概念:"Web Components"不是单一的 API,而是一组标准:Custom Elements(定义自定义 HTML 元素)、Shadow DOM(封装 DOM 和样式)、HTML Templates(可复用的 HTML 模板)——早年 webcomponents.org 还把 ES Modules 算作第四项,MDN 现在只列前三个。它们可以单独使用,也可以组合使用:你可以只用 Custom Elements 不用 Shadow DOM,也可以只用 Shadow DOM 不写成 Custom Element。本节先聚焦 Shadow DOM,因为它是"隔离"这个微前端核心问题的浏览器原生答案。它创建的 DOM 边界既不是完全密封,也不是完全透明:有些东西能穿过,有些被挡住,规则是明确的。

12.1.1 Shadow DOM 的本质:一面单向镜

要理解 Shadow DOM,最好忘掉所有技术文档里的抽象定义,想象一面单向镜。

从外面(Light DOM)看进去,你看不到里面的细节——内部的样式、结构、事件都被隔离在镜子后面。但从里面(Shadow DOM)看出去,你依然能感知到外部世界的存在——继承的 CSS 属性(如 font-family、color)会穿透进来。

// 创建一面"单向镜"
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);
<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:两种隔离哲学

closed 模式的好处是外部无法侵入内部实现,代价是外部无法调试、无法扩展;open 模式正好相反。微前端场景里可调试性往往比严格封装更重要,所以容器一般用 open。

下图展示了 Shadow DOM 的 open 和 closed 两种模式在微前端场景下的信息可访问性差异:

flowchart LR
    subgraph OpenMode["mode: 'open'"]
        Host1["宿主元素\nelement"] -->|"element.shadowRoot"| SR1["ShadowRoot\n可访问"]
        SR1 --> InternalDOM1["内部 DOM\n可查询/操作"]
        SR1 --> InternalCSS1["内部样式\n可注入主题变量"]
    end
    subgraph ClosedMode["mode: 'closed'"]
        Host2["宿主元素\nelement"] -->|"element.shadowRoot"| Null["null\n不可访问"]
        SR2["ShadowRoot\n仅内部持有引用"] --> InternalDOM2["内部 DOM\n外部无法操作"]
    end
    Note1["微前端推荐: open\n主应用需要注入主题\n需要监控子应用状态"]
    Note2["安全场景: closed\n第三方不可信组件\n但可被 monkey-patch 绕过"]
    style OpenMode fill:#e8f5e9,stroke:#2e7d32
    style ClosedMode fill:#ffebee,stroke:#c62828

attachShadow 接受一个 mode 参数,它决定了外部代码能否通过 JavaScript 访问 Shadow DOM 内部:

// 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 是 是
适用场景 组件库、微前端容器 安全敏感的第三方组件
谁在用 开发者写的组件(Lit 默认就是 open) 较少使用;浏览器内置元素(<video>、<input> 等)的 user-agent shadow tree 与之效果类似——页面脚本拿不到,shadowRoot 也返回 null

在微前端场景中,绝大多数时候应该选择 open 模式。原因很实际:

// 微前端主应用可能需要与子应用的 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 引用:

// 绕过 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 样式隔离的边界,区分了被阻断和可穿透的不同类型的样式规则:

flowchart TB
    External["外部样式\n(Light DOM)"] --> Boundary["Shadow Boundary\n样式隔离边界"]
    Boundary -->|"被阻断"| Blocked["选择器匹配类"]
    Blocked --> ClassSel[".container, #id, tag\n完全无法穿透"]
    Blocked --> Universal["* 通配选择器\n无法穿透"]
    Blocked --> Important["!important\n无法穿透"]
    Boundary -->|"可穿透"| Pass["继承属性类"]
    Pass --> Font["font-family\nfont-size"]
    Pass --> Color["color\nline-height"]
    Pass --> CSSVar["CSS Custom Properties\n--primary-color (设计如此)"]
    Boundary -->|"特殊机制"| Special["跨越边界的样式手段"]
    Special --> HostSel[":host / :host()\n从内部选择宿主"]
    Special --> Part["::part()\n从外部选择内部标记元素"]
    Special --> CSSVarInject["CSS 变量注入\n主题系统的标准方案"]
    style Blocked fill:#ffebee,stroke:#c62828
    style Pass fill:#fff3e0,stroke:#e65100
    style Special fill:#e8f5e9,stroke:#2e7d32

Shadow DOM 的样式隔离不是"绝对的墙",更像是"有窗户的墙"。理解哪些东西能穿透、哪些不能,对于微前端的样式管理至关重要。

// 演示样式穿透行为
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);
<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)的最佳通道:

// 主应用:通过 CSS Custom Properties 传递设计体系
class ThemeAwareMicroApp extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <style>
        :host {
          display: block;
        }
        .header {
          
          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);
<!-- 主应用通过 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 对应 React 的 children、Vue 的 slot、Angular 的 <ng-content>:组件内部保持封装,使用者又能"往组件里放自己的内容"(Vue 官方文档明言其 slot 受 Web Components 规范草案启发)。使用者放进去的内容物理上仍在使用者的 Light DOM 里,只是被"投影"到组件的 Shadow DOM 中渲染——所以样式作用域、事件冒泡路径、DOM 遍历结果都和直觉不同。

Slot 是 Web Components 中最容易被低估的特性。在微前端场景中,它解决了一个关键问题:如何让主应用向子应用容器内注入内容,同时保持隔离。

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);
<!-- 主应用可以灵活控制每个子应用容器的 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 规范定义了一组生命周期回调,它们与微前端子应用的生命周期有着惊人的对应关系:

// Custom Elements 生命周期与微前端生命周期的映射
class MicroAppElement extends HTMLElement {

  // ========== 生命周期回调 ==========

  
  constructor() {
    super();
    console.log('[lifecycle] constructor - 元素被创建');
    this.attachShadow({ mode: 'open' });
    // 只做最基本的初始化:创建 Shadow DOM、声明内部状态
    this._initialized = false;
    this._appInstance = null;
  }

  
  connectedCallback(): void {
    console.log('[lifecycle] connectedCallback - 元素插入 DOM');
    this._mountApp();
  }

  
  disconnectedCallback(): void {
    console.log('[lifecycle] disconnectedCallback - 元素从 DOM 移除');
    this._unmountApp();
  }

  
  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}`);
    // 元素升级(upgrade)时,已有的每个属性都会先触发一次 attributeChangedCallback(oldValue 为 null),
    // 随后才触发 connectedCallback;初始 src 交给 connectedCallback 处理,避免同一个子应用挂载两次
    if (name === 'src' && oldValue !== null && oldValue !== newValue && this.isConnected) {
      // 资源地址变化,重新加载子应用
      this._unmountApp();
      this._mountApp();
    }
    if (name === 'active' && newValue === 'false') {
      this._deactivateApp();
    }
  }

  
  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 不起作用;从 Shadow DOM 内部派发时才需要它穿透边界(见 12.2.3)
        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 标签一样自然:

<!-- 声明式的子应用加载 -->
<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 完整的微前端容器实现

让我们把上面的简单示例扩展为一个生产级的微前端容器。这个容器需要处理真实场景中的各种边界情况:

// 类型定义
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');
    // DOMParser 产出的文档以主应用页面的地址为 base,直接读 link.href / script.src
    // 会把子应用里的相对路径解析到主应用域名下;要用子应用 HTML 自己的地址重新解析
    const baseUrl = response.url || new URL(url, location.href).href;
    const resolve = (raw: string) => new URL(raw, baseUrl).href;

    // 收集样式
    const styles: string[] = [];
    doc.querySelectorAll('link[rel="stylesheet"]').forEach(link => {
      const href = link.getAttribute('href');
      if (href) styles.push(resolve(href));
    });
    doc.querySelectorAll('style').forEach(style => {
      styles.push(style.textContent || '');
    });

    // 收集脚本
    const scripts: string[] = [];
    doc.querySelectorAll('script[src]').forEach(script => {
      scripts.push(resolve(script.getAttribute('src')!));
    });

    // 返回从 HTML 中提取的生命周期
    // 实际实现会更复杂,这里简化处理:scripts 只收集不执行,
    // 真正的 HTML Entry 还要下载这些脚本、放进沙箱执行并从中取出生命周期(见 §3.2.3 import-html-entry)
    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':
        // 元素升级时对初始 src 的回调(oldVal 为 null)交给 connectedCallback,避免同一子应用挂载两次
        if (oldVal !== null && oldVal !== newVal && newVal) {
          this.unmountApp();
          this.renderLoading();
          this.loadAndMount(newVal);
        }
        break;
      case 'active':
        if (newVal === 'false' && this.mounted) {
          this.unmountApp();
        } else if (oldVal === 'false' && 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);

这个容器在主应用中的使用方式极为简洁:

<!DOCTYPE html>
<html>
<head>
  <title>微前端主应用</title>
  <style>
    :root {
      --theme-primary: #1890ff;
      --theme-success: #52c41a;
      --theme-spacing: 16px;
    }
    .app-layout {
      display: grid;
      grid-template-columns: 240px 1fr;
      grid-template-rows: 56px 1fr;
      height: 100vh;
    }
    .app-header { grid-column: 1 / -1; background: #001529; }
    .app-sidebar { background: #fff; border-right: 1px solid #e8e8e8; }
    .app-content { overflow: auto; padding: 24px; }
  </style>
</head>
<body>
  <div class="app-layout">
    <header class="app-header">
      <!-- 主应用自己的导航 -->
    </header>
    <aside class="app-sidebar">
      <nav>
        <a href="/order">订单管理</a>
        <a href="/product">商品管理</a>
        <a href="/user">用户中心</a>
      </nav>
    </aside>
    <main class="app-content">
      <!-- 微前端容器:声明式加载子应用 -->
      <micro-frontend
        id="main-app"
        name="order"
        src="https://cdn.example.com/apps/order/entry.js"
        api-base="https://api.example.com"
      ></micro-frontend>
    </main>
  </div>

  <script>
    // 浏览器前进/后退(popstate)时切换子应用;示例从简:侧边栏的 <a> 是整页跳转,
    // 且首屏 src 写死为 order,实际项目还要按当前路径初始化,并拦截 pushState/replaceState(见第 8 章)
    const appRoutes = {
      '/order': 'https://cdn.example.com/apps/order/entry.js',
      '/product': 'https://cdn.example.com/apps/product/entry.js',
      '/user': 'https://cdn.example.com/apps/user/entry.js',
    };

    window.addEventListener('popstate', () => {
      const container = document.getElementById('main-app');
      const src = appRoutes[location.pathname];
      if (src) {
        container.setAttribute('src', src);
        container.setAttribute('name', location.pathname.slice(1));
      }
    });

    // 监听子应用事件
    document.addEventListener('micro-app:mounted', (e) => {
      console.log(`子应用 ${e.detail.name} 已就绪`);
    });

    document.addEventListener('micro-app:error', (e) => {
      console.error(`子应用 ${e.detail.name} 加载失败`, e.detail.error);
      // 可以上报错误监控
    });
  </script>
</body>
</html>

12.2.3 事件通信:composed 的秘密

composed 控制一个事件能否穿过 Shadow 边界向外冒泡,默认值是 false:用 new CustomEvent() 在 Shadow DOM 内部派发的事件,外部监听不到(浏览器原生的 click 等 UI 事件本身就是 composed 的,见本节末的深度洞察)。微前端里子应用派发的事件通常要让主应用收到,所以需要显式设置 composed: true。

在 Web Components 微前端架构中,事件通信有一个极其关键但容易被忽视的细节:Shadow DOM 的事件 retarget 机制。

class EventDemo extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <button id="inner-btn">点击我</button>
    `;

    // 注意:必须从 Shadow DOM 内部的元素派发,composed 与 retarget 才有意义;
    // 如果直接 this.dispatchEvent(宿主本身在 Light DOM 里),事件根本不经过 Shadow Boundary
    const btn = shadow.querySelector('#inner-btn')!;
    btn.addEventListener('click', () => {
      // 事件 1:不设置 composed,事件冒泡到 ShadowRoot 就停止
      btn.dispatchEvent(new CustomEvent('internal-event', {
        bubbles: true,
        composed: false, // 默认值就是 false
        detail: { message: '我只能在 Shadow DOM 内部被捕获' }
      }));

      // 事件 2:设置 composed: true,事件穿透 Shadow Boundary
      btn.dispatchEvent(new CustomEvent('public-event', {
        bubbles: true,
        composed: true,
        detail: { message: '我可以被外部的主应用捕获' }
      }));
    });
  }
}
customElements.define('event-demo', EventDemo);

事件穿透 Shadow Boundary 时,会发生 event retarget——事件的 target 属性会被重写为宿主元素(host element),而不是 Shadow DOM 内部的实际触发元素:

// 主应用监听事件
document.querySelector('event-demo').addEventListener('public-event', (e) => {
  console.log(e.target);           // <event-demo> (宿主元素,而非内部按钮)
  console.log(e.composedPath());   // [button#inner-btn, ShadowRoot, event-demo, body, html, document, Window]
  console.log(e.detail.message);   // "我可以被外部的主应用捕获"
});

// 如果确实需要知道内部的触发元素,使用 composedPath()
document.querySelector('event-demo').addEventListener('public-event', (e) => {
  const path = e.composedPath();
  const realTarget = path[0]; // button#inner-btn
  console.log('实际触发元素:', realTarget);
});

💡 深度洞察:大多数浏览器原生 UI 事件(如 click、focus、input)都是 composed: true 的——这是为了确保表单、键盘导航等基础功能不被 Shadow DOM 阻断。但自定义事件(CustomEvent)默认 composed: false。这个不对称的设计背后有深意:浏览器认为原生交互行为应该是全局可感知的,而业务逻辑事件是否穿透边界应该由开发者显式决定。在微前端中,这种显式控制正是我们需要的——子应用的内部事件不应该泄漏到主应用,只有明确需要通信的事件才通过 composed: true 向上传递。

12.3 Lit 框架的微前端实践

Lit 是 Web Components 之上的一层薄封装:它不替代标准,你随时可以回到原生 API,用 Lit 写的组件也能和纯原生代码自由互操作。Lit 的作者 Justin Fagnani 和 Steve Orvell 都是 Polymer 团队成员,Lit 延续了 Polymer 的思路但只保留必要的部分,整个库约 5KB(min+gzip)。

12.3.1 为什么是 Lit

当我们决定用 Web Components 构建微前端时,面临一个现实问题:原生 Web Components API 虽然功能完备,但写起来相当冗长。光是 attachShadow、innerHTML、attributeChangedCallback 这套仪式感满满的代码,就让很多习惯了 React/Vue 声明式编程的开发者望而却步。

Lit(由 Google 的 Polymer 团队打造)是目前最主流的 Web Components 增强框架,它在不偏离 Web 标准的前提下,提供了三个关键能力:

  1. 响应式属性:类似 Vue 的 reactive,属性变化自动触发重渲染
  2. 声明式模板:基于 Tagged Template Literals 的高效模板系统
  3. 极小的体积:核心库仅约 5KB(gzip),几乎不增加子应用负担
// 原生 Web Components vs Lit 的对比

// ===== 原生写法(约 35 行)=====
class NativeCounter extends HTMLElement {
  private _count = 0;

  static get observedAttributes() { return ['count']; }

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._render();
  }

  get count() { return this._count; }
  set count(val: number) {
    this._count = val;
    this._render();
  }

  attributeChangedCallback(name: string, _: string, newVal: string) {
    if (name === 'count') this.count = Number(newVal);
  }

  private _render() {
    this.shadowRoot!.innerHTML = `
      <style>
        button { padding: 8px 16px; cursor: pointer; }
        span { margin: 0 12px; font-size: 18px; }
      </style>
      <button id="dec">-</button>
      <span>${this._count}</span>
      <button id="inc">+</button>
    `;
    this.shadowRoot!.getElementById('dec')!.onclick = () => { this.count--; };
    this.shadowRoot!.getElementById('inc')!.onclick = () => { this.count++; };
  }
}
customElements.define('native-counter', NativeCounter);

// ===== Lit 写法(约 20 行)=====
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';

@customElement('lit-counter')
class LitCounter extends LitElement {
  static styles = css`
    button { padding: 8px 16px; cursor: pointer; }
    span { margin: 0 12px; font-size: 18px; }
  `;

  @property({ type: Number }) count = 0;

  render() {
    return html`
      <button @click=${() => this.count--}>-</button>
      <span>${this.count}</span>
      <button @click=${() => this.count++}>+</button>
    `;
  }
}

代码量减半,可读性翻倍。而且 Lit 的模板系统不是简单的字符串拼接——它使用了 Tagged Template Literals,第一次渲染后会缓存模板结构,后续更新只对比变化的部分,性能接近手写 DOM 操作。

12.3.2 用 Lit 构建微前端子应用容器

import { LitElement, html, css, PropertyValues } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
// 下文用到的 globalState 即 12.2.2 中 GlobalStateManager 的实例,需从所在模块导入

// 子应用注册表
interface AppRegistration {
  name: string;
  entry: string;
  activeRule: string;
  prefetch?: boolean;
  props?: Record<string, unknown>;
}

// 子应用状态
type AppStatus = 'idle' | 'loading' | 'bootstrapping' | 'mounted' | 'unmounting' | 'error';

@customElement('lit-micro-frontend')
class LitMicroFrontend extends LitElement {
  static styles = css`
    :host {
      display: block;
      position: relative;
      min-height: 100px;
    }

    .container {
      width: 100%;
      height: 100%;
    }

    .loading-overlay {
      display: flex;
      align-items: center;
      justify-content: center;
      padding: 48px;
      color: var(--mf-text-secondary, #8c8c8c);
    }

    .spinner {
      width: 24px;
      height: 24px;
      border: 3px solid var(--mf-border-color, #e8e8e8);
      border-top-color: var(--mf-primary-color, #1890ff);
      border-radius: 50%;
      animation: spin 0.8s linear infinite;
      margin-right: 12px;
    }

    @keyframes spin {
      to { transform: rotate(360deg); }
    }

    .error-panel {
      padding: 24px;
      background: var(--mf-error-bg, #fff2f0);
      border: 1px solid var(--mf-error-border, #ffccc7);
      border-radius: 8px;
      margin: 16px;
    }

    .error-panel h3 {
      color: var(--mf-error-color, #ff4d4f);
      margin: 0 0 8px;
      font-size: 16px;
    }

    .error-panel p {
      color: var(--mf-text-secondary, #8c8c8c);
      margin: 0;
      font-size: 14px;
    }

    .error-panel button {
      margin-top: 16px;
      padding: 6px 16px;
      border: 1px solid var(--mf-border-color, #d9d9d9);
      border-radius: 4px;
      background: #fff;
      cursor: pointer;
      font-size: 14px;
    }

    .error-panel button:hover {
      color: var(--mf-primary-color, #1890ff);
      border-color: var(--mf-primary-color, #1890ff);
    }
  `;

  @property({ type: String }) src = '';
  @property({ type: String }) name = '';
  @property({ type: Object }) appProps: Record<string, unknown> = {};

  @state() private status: AppStatus = 'idle';
  @state() private errorMessage = '';

  private lifecycle: any = null;
  private appContainer: HTMLDivElement | null = null;

  // Lit 的响应式更新机制:属性变化自动触发 updated
  updated(changedProperties: PropertyValues): void {
    // 首次渲染由 firstUpdated 负责;这里只处理后续的 src 变化,避免首屏加载两次
    if (changedProperties.has('src') && changedProperties.get('src') !== undefined && this.src) {
      this.reloadApp();
    }
    if (changedProperties.has('appProps') && this.lifecycle?.update) {
      // props 变化时通知子应用
      this.lifecycle.update({ props: this.appProps });
    }
  }

  disconnectedCallback(): void {
    super.disconnectedCallback();
    this.unmountApp();
  }

  render() {
    return html`
      ${this.status === 'loading' || this.status === 'bootstrapping'
        ? html`
          <div class="loading-overlay">
            <div class="spinner"></div>
            <span>正在加载 ${this.name || '子应用'}...</span>
          </div>
        `
        : null
      }

      ${this.status === 'error'
        ? html`
          <div class="error-panel">
            <h3>${this.name || '子应用'} 加载失败</h3>
            <p>${this.errorMessage}</p>
            <button @click=${this.reloadApp}>重试</button>
          </div>
        `
        : null
      }

      <div class="container"></div>
    `;
  }

  firstUpdated(): void {
    this.appContainer = this.shadowRoot!.querySelector('.container');
    if (this.src) {
      this.reloadApp();
    }
  }

  private async reloadApp(): Promise<void> {
    await this.unmountApp();
    await this.loadAndMount();
  }

  private async loadAndMount(): Promise<void> {
    if (!this.src) return;

    this.status = 'loading';

    try {
      // 动态加载子应用模块
      const module = await import(/* @vite-ignore */ this.src);

      this.lifecycle = {
        bootstrap: module.bootstrap || (async () => {}),
        mount: module.mount,
        unmount: module.unmount,
        update: module.update,
      };

      // bootstrap 阶段
      this.status = 'bootstrapping';
      await this.lifecycle.bootstrap();

      // mount 阶段
      if (this.appContainer) {
        await this.lifecycle.mount({
          container: this.appContainer,
          props: { ...this.appProps, appName: this.name },
          onGlobalStateChange: (cb: Function) => {
            globalState.onChange(cb as any);
          },
          setGlobalState: (state: Record<string, unknown>) => {
            globalState.setState(state);
          },
        });
      }

      this.status = 'mounted';

      this.dispatchEvent(new CustomEvent('app-mounted', {
        bubbles: true,
        composed: true,
        detail: { name: this.name },
      }));
    } catch (error) {
      this.status = 'error';
      this.errorMessage = (error as Error).message;

      this.dispatchEvent(new CustomEvent('app-error', {
        bubbles: true,
        composed: true,
        detail: { name: this.name, error },
      }));
    }
  }

  private async unmountApp(): Promise<void> {
    if (this.lifecycle && this.status === 'mounted') {
      this.status = 'unmounting';
      try {
        await this.lifecycle.unmount();
      } catch (e) {
        console.error(`[lit-micro-frontend] ${this.name} unmount error:`, e);
      }
      if (this.appContainer) {
        this.appContainer.innerHTML = '';
      }
      this.lifecycle = null;
      this.status = 'idle';
    }
  }
}

12.3.3 基于 Lit 的主应用路由器

一个完整的微前端方案需要路由层来调度子应用。用 Lit 实现一个声明式的微前端路由器:

import { LitElement, html, css, PropertyValues } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';

interface RouteConfig {
  path: string;
  appName: string;
  entry: string;
  exact?: boolean;
  props?: Record<string, unknown>;
}

@customElement('micro-router')
class MicroRouter extends LitElement {
  static styles = css`
    :host {
      display: block;
      width: 100%;
      height: 100%;
    }
  `;

  @property({ type: Array }) routes: RouteConfig[] = [];
  @state() private currentRoute: RouteConfig | null = null;

  private handlePopState = () => this.matchRoute();

  connectedCallback(): void {
    super.connectedCallback();
    window.addEventListener('popstate', this.handlePopState);
    this.matchRoute();
  }

  disconnectedCallback(): void {
    super.disconnectedCallback();
    window.removeEventListener('popstate', this.handlePopState);
  }

  // routes 往往在元素连接之后才被赋值(见下方主应用 HTML),赋值时要重新匹配一次
  willUpdate(changed: PropertyValues): void {
    if (changed.has('routes')) this.matchRoute();
  }

  private matchRoute(): void {
    const pathname = window.location.pathname;
    const matched = this.routes.find(route => {
      if (route.exact) {
        return pathname === route.path;
      }
      return pathname.startsWith(route.path);
    });
    this.currentRoute = matched || null;
  }

  render() {
    if (!this.currentRoute) {
      return html`<slot name="not-found"><p>404 - 未找到对应的子应用</p></slot>`;
    }

    return html`
      <lit-micro-frontend
        .src=${this.currentRoute.entry}
        .name=${this.currentRoute.appName}
        .appProps=${{
          ...this.currentRoute.props,
          basePath: this.currentRoute.path
        }}
        @app-mounted=${this.onAppMounted}
        @app-error=${this.onAppError}
      ></lit-micro-frontend>
    `;
  }

  private onAppMounted(e: CustomEvent): void {
    console.log(`[router] ${e.detail.name} mounted at ${window.location.pathname}`);
  }

  private onAppError(e: CustomEvent): void {
    console.error(`[router] ${e.detail.name} failed:`, e.detail.error);
  }

  // 提供编程式导航方法
  navigate(path: string): void {
    window.history.pushState(null, '', path);
    this.matchRoute();
  }
}

在主应用 HTML 中:

<script type="module">
  import './components/micro-router.js';
  import './components/lit-micro-frontend.js';

  const router = document.querySelector('micro-router');
  router.routes = [
    {
      path: '/dashboard',
      appName: 'dashboard',
      entry: 'https://cdn.example.com/apps/dashboard/main.js',
      exact: true,
    },
    {
      path: '/order',
      appName: 'order',
      entry: 'https://cdn.example.com/apps/order/main.js',
      props: { permissions: ['read', 'write'] },
    },
    {
      path: '/product',
      appName: 'product',
      entry: 'https://cdn.example.com/apps/product/main.js',
    },
    {
      path: '/analytics',
      appName: 'analytics',
      entry: 'https://cdn.example.com/apps/analytics/main.js',
      props: { dateRange: 'last30days' },
    },
  ];
</script>

<body>
  <app-header></app-header>
  <div class="layout">
    <app-sidebar></app-sidebar>
    <main>
      <micro-router>
        <div slot="not-found">
          <h2>页面未找到</h2>
          <p>请检查 URL 是否正确</p>
        </div>
      </micro-router>
    </main>
  </div>
</body>

12.3.4 子应用如何适配 Web Components 容器

不管子应用是 React、Vue 还是 Angular,只要导出标准的生命周期函数,就可以被 Web Components 容器加载:

// React 子应用的适配层 —— order-app/main.ts
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import App from './App';

let root: Root | null = null;

export async function bootstrap(): Promise<void> {
  console.log('[order-app] bootstrap');
}

export async function mount(props: {
  container: HTMLElement;
  props: Record<string, unknown>;
  onGlobalStateChange: (cb: (state: any) => void) => void;
  setGlobalState: (state: any) => void;
}): Promise<void> {
  const { container, props: appProps, onGlobalStateChange, setGlobalState } = props;

  // 在 Shadow DOM 内部创建挂载点
  const mountPoint = document.createElement('div');
  mountPoint.id = 'order-app-root';
  container.appendChild(mountPoint);

  root = createRoot(mountPoint);
  root.render(
    React.createElement(App, {
      ...appProps,
      onGlobalStateChange,
      setGlobalState,
    })
  );
}

export async function unmount(): Promise<void> {
  if (root) {
    root.unmount();
    root = null;
  }
}

export async function update(props: { props: Record<string, unknown> }): Promise<void> {
  // React 子应用可以通过 context 或 store 来响应 props 更新
  console.log('[order-app] props updated:', props);
}
// Vue 子应用的适配层 —— product-app/main.ts
import { createApp, App as VueApp } from 'vue';
import AppComponent from './App.vue';
import router from './router';
import { createPinia } from 'pinia';

let app: VueApp | null = null;

export async function bootstrap(): Promise<void> {
  console.log('[product-app] bootstrap');
}

export async function mount(props: {
  container: HTMLElement;
  props: Record<string, unknown>;
}): Promise<void> {
  const { container, props: appProps } = props;

  const mountPoint = document.createElement('div');
  mountPoint.id = 'product-app-root';
  container.appendChild(mountPoint);

  app = createApp(AppComponent);
  app.use(router);
  app.use(createPinia());

  // 注入全局属性,子应用内部可通过 inject 获取
  app.provide('microAppProps', appProps);

  // 如果子应用有自己的路由,需要设置 base
  const basePath = (appProps as any).basePath || '/';
  router.replace(location.pathname.replace(basePath, '/') || '/');

  app.mount(mountPoint);
}

export async function unmount(): Promise<void> {
  if (app) {
    app.unmount();
    app = null;
  }
}

💡 深度洞察:注意 React 子应用在 Shadow DOM 内部运行时有一个隐藏的陷阱。React 17+ 将事件委托到根容器(传给 createRoot / ReactDOM.render 的那个 DOM 节点)而非 document——如果根容器是 Shadow DOM 内部的元素,React 的事件系统可以正常工作。但 React 16 及更早版本将事件委托到 document,这会导致 Shadow DOM 内部的事件冒泡路径出现问题,event.target 指向宿主元素而非实际点击的元素。如果你的子应用不得不使用 React 16,需要在容器层做事件转发处理。

12.4 Web Components 的局限:SSR、表单、Accessibility

Web Components 不是银弹。当你决定在微前端架构中重度使用 Web Components 时,必须正视它的三大局限。这些局限不是"小问题"——在某些场景下,它们是架构级的阻碍。

12.4.1 SSR:最大的痛点

Web Components 在 SSR 上的困难来自一个根本设计:Shadow DOM 原本是运行时概念,要等 JavaScript 执行 attachShadow() 后才存在,而 SSR 追求首屏不依赖浏览器端 JavaScript。直到 2023–2024 年 Declarative Shadow DOM 在三大浏览器陆续落地(Safari 16.4、Firefox 123),这个局面才开始好转,但 Web Components + SSR 的开发体验至今仍不如 Next.js + React 成熟,这也是不少团队在微前端选型时不选 Web Components 的原因之一。

服务端渲染是 Web Components 最突出的短板。

问题的根源在于:Shadow DOM 长期以来在 HTML 规范中没有声明式的序列化格式。传统的 SSR 把组件渲染成 HTML 字符串发送给浏览器,但 Shadow DOM 无法用 HTML 字符串来表达。

<!-- 这是 React SSR 的输出,浏览器可以直接渲染 -->
<div class="counter">
  <button>-</button>
  <span>0</span>
  <button>+</button>
</div>

<!-- 但 Shadow DOM 的"SSR 输出"应该长什么样? -->
<!-- 答案:直到 Declarative Shadow DOM 出现之前,没有标准方式 -->

Declarative Shadow DOM (DSD) 是 Chrome 90(2021)率先实现的特性——当时属性叫 shadowroot、进入 HTML 标准后改名为 shadowrootmode、Chrome 111 起才认新名字——它试图解决这个问题:

<!-- Declarative Shadow DOM:在 HTML 中声明 Shadow DOM -->
<my-counter>
  <template shadowrootmode="open">
    <style>
      button { padding: 8px 16px; }
      span { margin: 0 12px; font-size: 18px; }
    </style>
    <button id="dec">-</button>
    <span>0</span>
    <button id="inc">+</button>
  </template>
</my-counter>

但现实并不乐观:

// 使用 Lit 的 SSR 方案(@lit-labs/ssr)
// 这是目前最成熟的 Web Components SSR 方案

// server.ts
// 渲染含 <!DOCTYPE html> 的整页必须用 @lit-labs/ssr 导出的"仅服务端模板" html,lit 的普通 html 不行
import { render, html } from '@lit-labs/ssr';
import './components/my-counter.js'; // 服务端也需要加载组件定义

async function renderPage(): Promise<string> {
  const templateResult = html`
    <!DOCTYPE html>
    <html>
      <body>
        <my-counter count="5"></my-counter>
        <script type="module" src="/components/my-counter.js"></script>
      </body>
    </html>
  `;

  // Lit SSR 会生成包含 Declarative Shadow DOM 的 HTML
  const chunks: string[] = [];
  for await (const chunk of render(templateResult)) {
    chunks.push(chunk as string);
  }
  return chunks.join('');
}

DSD 的兼容性与现状:

浏览器 Declarative Shadow DOM 支持
Chrome 111+(90–110 只认旧的 shadowroot 属性) 支持
Edge 111+ 支持
Safari 16.4+ 支持
Firefox 123+ 支持

到 2026 年,主流浏览器已经全部支持 DSD。但在微前端场景中,问题更加复杂:

// 微前端 SSR 的挑战:主应用和子应用的渲染时序

// 场景:主应用在服务端渲染时,需要同时渲染子应用容器
// 但子应用可能部署在不同的服务上,有自己的 SSR 流程

// 主应用的 SSR
async function renderMainApp(url: string): Promise<string> {
  const matchedRoute = matchRoute(url);

  // 问题 1:需要跨服务调用子应用的 SSR
  const subAppHtml = await fetch(`${matchedRoute.ssrEndpoint}/render?path=${encodeURIComponent(url)}`)
    .then(res => res.text());

  // 问题 2:子应用的 Shadow DOM HTML 需要嵌入主应用的 HTML 中
  // 但 Shadow DOM 的样式和 DOM 结构是隔离的,组合起来很复杂

  // 问题 3:Hydration 阶段,Custom Element 的 upgrade 时序
  // 浏览器解析 HTML 时会先看到 Declarative Shadow DOM
  // 然后 JS 加载后 Custom Element 定义被注册
  // 此时需要确保 hydration 不会丢失已有的 Shadow DOM 状态

  return `
    <!DOCTYPE html>
    <html>
      <body>
        <micro-frontend name="${matchedRoute.appName}">
          <template shadowrootmode="open">
            <style>/* 容器样式 */</style>
            <div class="container">
              ${subAppHtml}  <!-- 子应用 SSR 的 HTML 嵌入此处 -->
            </div>
          </template>
        </micro-frontend>
        <script type="module" src="/micro-frontend.js"></script>
        <script type="module" src="${matchedRoute.clientEntry}"></script>
      </body>
    </html>
  `;
}

💡 深度洞察:Web Components SSR 的根本困难不在技术实现,而在架构理念的冲突。SSR 的核心假设是"组件可以被序列化为字符串",而 Shadow DOM 的核心设计是"创建一个独立的文档片段"。这两个目标天然矛盾。Declarative Shadow DOM 是一个妥协方案——它让 Shadow DOM 可以被声明式地表达在 HTML 中,但这个表达并不完美(比如无法表达事件监听器、无法表达组件内部状态)。在微前端场景中,如果你的应用对首屏渲染速度和 SEO 有强需求,目前最务实的策略是:主应用的外壳(导航、侧边栏)做 SSR,子应用内容区域做 CSR。不要试图在微前端场景下追求完美的全量 SSR。

12.4.2 表单集成:Shadow DOM 与原生表单的断裂

表单是 Web Components 的另一个难点:HTML 表单假设所有输入控件都在同一个 document 里,可以被 form.elements 统一收集;Shadow DOM 则让每个 Shadow Root 成为独立的子树,外部表单看不到里面的控件。解决这个冲突的 ElementInternals API,Chrome 77(2019)率先实现,Firefox 93(2021)跟进,Safari 直到 16.4(2023)才补齐——也就是说,2023 年之后 Web Components 的表单场景才在三大浏览器里都可用。

<!-- 问题演示:Shadow DOM 内部的 input 不会参与外部 form 的提交 -->
<form id="outer-form" onsubmit="handleSubmit(event)">
  <label>用户名:<input name="username" value="test"></label>

  <!-- 这个自定义元素内部有一个 input -->
  <custom-input name="email"></custom-input>

  <button type="submit">提交</button>
</form>

<script>
  function handleSubmit(e) {
    e.preventDefault();
    const formData = new FormData(e.target);
    console.log('username:', formData.get('username')); // "test" ✅
    console.log('email:', formData.get('email'));        // null ❌ Shadow DOM 内部的 input 不参与
  }
</script>

ElementInternals API 是解决这个问题的标准方案:

// 使用 ElementInternals 让 Custom Element 参与表单
class FormInput extends HTMLElement {
  private internals: ElementInternals;
  private input: HTMLInputElement;

  // 声明此元素可以参与表单
  static formAssociated = true;

  constructor() {
    super();
    // attachInternals 获取 ElementInternals 实例
    this.internals = this.attachInternals();

    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <style>
        :host {
          display: inline-block;
        }
        input {
          padding: 8px 12px;
          border: 1px solid #d9d9d9;
          border-radius: 4px;
          font-size: 14px;
          outline: none;
          transition: border-color 0.3s;
        }
        input:focus {
          border-color: #1890ff;
          box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
        }
        :host(:invalid) input {
          border-color: #ff4d4f;
        }
        .error-message {
          color: #ff4d4f;
          font-size: 12px;
          margin-top: 4px;
          display: none;
        }
        :host(:invalid) .error-message {
          display: block;
        }
      </style>
      <input type="text" />
      <div class="error-message">
        <slot name="error">请填写此字段</slot>
      </div>
    `;

    this.input = shadow.querySelector('input')!;

    this.input.addEventListener('input', () => {
      // 通过 internals.setFormValue 设置表单值
      this.internals.setFormValue(this.input.value);
      this.validate();
    });
  }

  // 表单相关的回调
  formResetCallback(): void {
    this.input.value = '';
    this.internals.setFormValue('');
    this.validate();
  }

  formStateRestoreCallback(state: string): void {
    this.input.value = state;
    this.internals.setFormValue(state);
  }

  formDisabledCallback(disabled: boolean): void {
    this.input.disabled = disabled;
  }

  // 自定义校验
  private validate(): void {
    if (this.hasAttribute('required') && !this.input.value) {
      this.internals.setValidity(
        { valueMissing: true },
        '此字段为必填项',
        this.input
      );
    } else if (this.hasAttribute('pattern') && this.input.value) {
      // 与原生 pattern 一致:整串匹配,空值不参与 pattern 校验
      const pattern = new RegExp(`^(?:${this.getAttribute('pattern')})$`);
      if (!pattern.test(this.input.value)) {
        this.internals.setValidity(
          { patternMismatch: true },
          this.getAttribute('title') || '格式不正确',
          this.input
        );
      } else {
        this.internals.setValidity({});
      }
    } else {
      this.internals.setValidity({});
    }
  }

  connectedCallback(): void {
    if (this.hasAttribute('value')) {
      this.input.value = this.getAttribute('value')!;
      this.internals.setFormValue(this.input.value);
    }
    if (this.hasAttribute('placeholder')) {
      this.input.placeholder = this.getAttribute('placeholder')!;
    }
    // 初始就校验一次:否则用户什么都不填,required 字段也会被当成合法
    this.validate();
  }
}

customElements.define('form-input', FormInput);
<!-- 现在 Custom Element 可以正常参与表单了 -->
<form id="registration" onsubmit="handleSubmit(event)">
  <form-input name="username" required placeholder="请输入用户名"></form-input>
  <form-input name="email" required pattern="[^@]+@[^@]+"
              title="请输入有效的邮箱地址" placeholder="请输入邮箱"></form-input>
  <button type="submit">注册</button>
</form>

<script>
  function handleSubmit(e) {
    e.preventDefault();
    const form = e.target;

    if (form.checkValidity()) {
      const formData = new FormData(form);
      console.log('username:', formData.get('username')); // 正常获取 ✅
      console.log('email:', formData.get('email'));        // 正常获取 ✅
    } else {
      form.reportValidity(); // 触发浏览器原生的校验 UI
    }
  }
</script>

在微前端场景中,表单集成的问题主要出现在:主应用和子应用需要共享表单上下文时。比如一个结算页面,地址部分由子应用 A 提供,支付部分由子应用 B 提供,但最终需要作为一个整体提交。ElementInternals 可以解决单个 Custom Element 参与表单的问题,但跨多个 Shadow DOM 的表单编排,仍然需要在应用层做状态协调。

12.4.3 Accessibility:被 Shadow DOM 打碎的无障碍树

无障碍访问(Accessibility,简称 A11y)是 Web Components 最容易被忽视、但后果最严重的问题。

浏览器会为页面构建一棵无障碍树(Accessibility Tree),屏幕阅读器通过这棵树来理解页面结构。Shadow DOM 会影响这棵树的构建方式:

// 问题演示:焦点管理在 Shadow DOM 中的行为
class FocusTrapDemo extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open', delegatesFocus: true });
    shadow.innerHTML = `
      <style>
        :host { display: block; padding: 16px; border: 2px solid #e8e8e8; }
        :host(:focus-within) { border-color: #1890ff; }
        input { display: block; margin: 8px 0; padding: 8px; width: 200px; }
      </style>
      <label>
        <span>Shadow DOM 内的输入框 1</span>
        <input type="text" />
      </label>
      <label>
        <span>Shadow DOM 内的输入框 2</span>
        <input type="text" />
      </label>
    `;
  }
}

customElements.define('focus-trap-demo', FocusTrapDemo);

几个关键的 A11y 问题和解决方案:

1. ARIA 引用穿不透 Shadow Boundary

<!-- 这样写不报错:label 和 aria-labelledby 都在文档这棵树里,
     但名字落在宿主 <my-input> 上,真正获得焦点的是 Shadow DOM 里的 <input>,它仍然没有可访问名称 -->
<label id="outer-label">这是外部标签</label>
<my-input aria-labelledby="outer-label"></my-input>

<!-- 反过来也不行:Shadow DOM 里的 <input aria-labelledby="outer-label"> 找不到外部的 id——
     ID 引用只在各自所在的树(document 或某个 shadow root)里解析 -->

ElementInternals 的 internals.ariaLabel、internals.role 等属性设置的是宿主元素自己的默认语义,只在宿主本身就是那个被聚焦、被朗读的控件时才管用(比如一个 role="button" 的自定义按钮);像上面这种焦点落在内部 <input> 上的组件,给宿主加标签解决不了问题。可行的做法是不跨边界引用 id,而是把标签文字本身带进 Shadow DOM,标在内部控件上:

// 可行做法:把 label 文本传进组件,在 shadow root 内部用 <label> 包住 <input>,
// 关联关系发生在同一棵树里,不需要跨边界的 ID 引用
class AccessibleInput extends HTMLElement {
  static observedAttributes = ['label', 'required'];
  private labelText: HTMLSpanElement;
  private input: HTMLInputElement;

  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open', delegatesFocus: true });
    shadow.innerHTML = `<label><span></span><input type="text" /></label>`;
    this.labelText = shadow.querySelector('span')!;
    this.input = shadow.querySelector('input')!;
  }

  // 元素升级时,已存在的 observed attribute 也会各触发一次
  attributeChangedCallback() {
    this.labelText.textContent = this.getAttribute('label') ?? '';
    this.input.required = this.hasAttribute('required');   // 原生 required 自带"必填"语义
  }
}
customElements.define('accessible-input', AccessibleInput);

用法是 <accessible-input label="用户名" required></accessible-input>。代价是标签文字必须作为属性传进来,外部已有的 <label for> / aria-labelledby 仍然无法直接指向内部控件。

2. 焦点顺序(Tab Order)的控制

// delegatesFocus: true 的作用
class DelegatedFocus extends HTMLElement {
  constructor() {
    super();
    // delegatesFocus: true 意味着:
    // 1. 调用 host.focus() 或点击宿主内不可聚焦的区域时,焦点会委托给 Shadow DOM 内部第一个可聚焦的元素
    // 2. 宿主本身不再作为独立的 Tab 停靠点,Tab 顺序直接落到内部元素上
    // 3. 宿主匹配 :focus / :focus-within 并不依赖这个选项——只要 shadow tree 里有元素聚焦,宿主就匹配
    const shadow = this.attachShadow({
      mode: 'open',
      delegatesFocus: true
    });

    shadow.innerHTML = `
      <style>
        :host(:focus-within) {
          outline: 2px solid #1890ff;
          outline-offset: 2px;
        }
        button {
          padding: 8px 16px;
          margin: 4px;
        }
      </style>
      <button>操作 A</button>
      <button>操作 B</button>
      <button>操作 C</button>
    `;
  }
}

customElements.define('delegated-focus', DelegatedFocus);

3. 屏幕阅读器的 Shadow DOM 遍历

// 为微前端容器添加完整的 ARIA 语义
class AccessibleMicroApp extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });

    // 关键:为容器设置合适的 ARIA role 和属性
    shadow.innerHTML = `
      <style>
        :host { display: block; }
        .app-region {
          position: relative;
        }
        .loading-announcement {
          
          position: absolute;
          width: 1px;
          height: 1px;
          padding: 0;
          margin: -1px;
          overflow: hidden;
          clip: rect(0, 0, 0, 0);
          white-space: nowrap;
          border: 0;
        }
      </style>
      <div class="app-region"
           role="region"
           aria-label="子应用">
        <!-- 使用 aria-live 区域通知屏幕阅读器加载状态 -->
        <div class="loading-announcement"
             role="status"
             aria-live="polite"></div>
        <slot></slot>
      </div>
    `;
  }

  connectedCallback(): void {
    // 属性在这里读:由 HTML 解析器直接创建元素时,constructor 执行时属性还没挂上
    const name = this.getAttribute('aria-app-name');
    if (name) {
      this.shadowRoot!.querySelector('.app-region')!.setAttribute('aria-label', name);
    }
    this.announceToScreenReader('子应用正在加载');
  }

  announceToScreenReader(message: string): void {
    const announcement = this.shadowRoot!.querySelector('.loading-announcement');
    if (announcement) {
      announcement.textContent = message;
    }
  }

  onAppMounted(): void {
    this.announceToScreenReader('子应用已加载完成');
  }

  onAppError(): void {
    this.announceToScreenReader('子应用加载失败,请稍后重试');
  }
}

customElements.define('accessible-micro-app', AccessibleMicroApp);

💡 深度洞察:无障碍访问不是"可选的附加功能"——在欧盟的《欧洲无障碍法案》(European Accessibility Act,2025 年 6 月 28 日起适用)和美国的 ADA 合规要求下,落在其适用范围内的 Web 应用,无障碍能力已经是法律义务。如果你的微前端架构重度使用 Shadow DOM,必须在架构设计阶段就将 A11y 纳入考量,而不是事后补救。ElementInternals 和 delegatesFocus 是两个最关键的 API,确保你的团队从第一天就使用它们。

12.4.4 一张决策表:什么时候该用 Web Components

综合以上分析,我们可以得出一张实用的决策矩阵:

场景 Web Components 适合度 说明
CSS 隔离需求强 ★★★★★ Shadow DOM 的 CSS 隔离最彻底(但挂到 body 的弹窗会逃逸,见第 5 章)
多框架共存(React + Vue) ★★★★★ Custom Elements 是唯一的框架无关标准
纯 CSR 应用 ★★★★☆ 完全没有 SSR 的困扰
子应用是独立团队维护的 ★★★★☆ 标准化的接口契约,降低协作成本
需要全量 SSR ★★☆☆☆ DSD 已可用但微前端 SSR 编排仍然复杂
复杂的跨应用表单 ★★★☆☆ ElementInternals 可解决单层,多层仍需协调
强 A11y 要求 ★★★☆☆ 可行但需要额外的 ARIA 处理工作
已有大量 jQuery/原生 JS 遗留代码 ★★★★★ Web Components 与任何技术栈兼容
对包体积极度敏感 ★★★★★ 零框架依赖,原生 API 无额外开销
需要 IE 11 支持 ☆☆☆☆☆ 原生不支持,只能靠 polyfill,且问题极多

最终,Web Components 在微前端架构中的定位不是"取代乾坤/Module Federation",而是一组可以被各方案借用的浏览器原生能力。乾坤用 Shadow DOM 做样式隔离——strictStyleIsolation 打开时就是一句 appElement.attachShadow({ mode: 'open' })(qiankun src/loader.ts:88);无界用 Custom Elements 做容器——它把 wujie-app 注册成自定义元素,在 connectedCallback / disconnectedCallback 里接管子应用的激活与销毁(wujie packages/wujie-core/src/shadow.ts:60-78);Module Federation 则并不依赖 Web Components——它共享的是 JS 模块,只是业务上可以选择把远程组件包装成 Custom Element 来跨框架消费。理解 Web Components,有助于看懂乾坤、无界这些方案里那部分直接建立在浏览器原生能力上的实现。


本章小结

  • Shadow DOM 提供浏览器原生的 DOM/CSS 隔离,open 模式适合微前端容器,CSS 自定义属性可以穿透 Shadow Boundary 传递设计令牌
  • Custom Elements 的构造函数与三个常用生命周期回调(connectedCallback、disconnectedCallback、attributeChangedCallback)与微前端的 bootstrap-mount-unmount 模型天然对应
  • Lit 框架 在不偏离 Web 标准的前提下,将 Web Components 的开发体验提升到接近 React/Vue 的水平,适合构建微前端容器和路由系统
  • SSR 是 Web Components 最大的短板,Declarative Shadow DOM 已在主流浏览器全面支持,但微前端场景下的 SSR 编排仍然复杂,务实策略是"外壳 SSR + 子应用 CSR"
  • 表单集成 依赖 ElementInternals API,单层 Custom Element 可以完整参与原生表单,但跨多个 Shadow DOM 的表单编排需要应用层协调
  • Accessibility 需要从架构设计阶段纳入考量,跨边界的 ID 引用不成立,要把标签文字带进 Shadow DOM 标在内部控件上,再配合 delegatesFocus 处理焦点
  • Web Components 在微前端中更多是被借用的原生能力(乾坤的 Shadow DOM 样式隔离、无界的 Custom Element 容器),而不是替代方案;Module Federation 并不依赖它

读完这一章,Web Components 的定位应该清楚了:它不是乾坤、single-spa、Module Federation 的"竞争对手"。乾坤用 Shadow DOM 做 strictStyleIsolation,Wujie 用 Custom Element 作为应用容器;Module Federation 不依赖它,但也有 MF 应用把 Web Components 作为"跨框架组件"的统一封装。

Web Components 的十几年历史,也是一段关于"浏览器标准 vs 前端框架"的有趣故事。2011 年,Alex Russell 在 Fronteers 大会上提出 Web Components 时,它一度被寄望为"让前端框架变得多余"的浏览器原生方案(React 要到 2013 年才开源)。但十几年过去,Web Components 没有取代框架,主流框架都陆续提供了与它互通的能力。React 19 补齐了对 Custom Elements 属性与事件的完整支持;Vue 3.2 起提供 defineCustomElement 把组件包装成 Custom Element;Stencil 把 Web Components 作为默认输出格式,Svelte 也能通过 customElement 编译选项输出;Angular 早在 v6 就通过 Angular Elements 把组件输出为 Custom Elements。一项技术的成功不一定是"取代别人",也可以是"被别人吸收",Web Components 走的是后者。

我们在《Vue 3 设计与实现》专栏第 10 章讨论的组件系统、在《React 19 内核探秘》专栏讨论虚拟 DOM 与 Fiber 的章节、在《Claude Code 源码深度解析》专栏第 17 章讨论的 React + Ink 终端 UI,都和 Web Components 相关:它们都在回答一个共同问题:如何把 UI 组织成可复用、可组合、可隔离的单元? Web Components 给出的是浏览器原生的答案,React/Vue 给出的是运行时框架的答案,Ink 给出的是终端适配的答案。

思考题

  1. 概念辨析:Shadow DOM 的 open 模式和 closed 模式在安全性上有何本质区别?为什么说 closed 模式不能用于安全隔离?如果一个微前端场景确实需要防止子应用读取容器内部的 DOM 结构,应该使用什么方案?

  2. 实践设计:假设你需要设计一个微前端容器,要求:(a) 子应用之间完全样式隔离;(b) 主应用可以通过设计令牌统一所有子应用的视觉风格;(c) 子应用可以向主应用派发事件。请画出这个容器的架构图,并说明 Shadow DOM、CSS Custom Properties 和 Custom Events 分别在其中扮演什么角色。

  3. 框架对比:对比原生 Web Components、Lit、Stencil 三种 Web Components 开发方式在微前端场景中的优劣。重点考虑:开发体验、运行时开销、SSR 支持、社区生态四个维度。

  4. 难题攻坚:你的团队正在将一个大型电商应用迁移到微前端架构。结算页面需要跨三个子应用收集表单数据(地址信息、商品确认、支付方式)。每个子应用都使用了 Shadow DOM 做样式隔离。请设计一个方案,让这三个子应用的表单数据可以作为一个整体提交,同时保持样式隔离不被破坏。

  5. 前瞻思考:随着 Declarative Shadow DOM 的全面普及和 ElementInternals API 的成熟,你认为 Web Components 在微前端领域会从"被借用的原生能力"演变为"主流方案"吗?需要什么条件才能促成这种转变?