React 基础体系 · 第 60/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React 微前端:路由、状态、样式、依赖隔离和迁移取舍

微前端不是“把一个 React 应用拆成几个仓库”这么简单。真正的微前端需要同时回答五个问题:

  1. 哪个应用决定当前 URL 和页面生命周期?
  2. 跨应用数据由谁拥有,如何更新,如何处理并发和失败?
  3. 一个应用的 CSS 是否会影响另一个应用?
  4. React、路由库和其他依赖是在同一个 JavaScript 运行时中共享,还是彼此隔离?
  5. 旧系统如何逐步迁移,而不是一次性重写?

这些问题彼此耦合。例如,把子应用放入 iframe 可以获得强隔离,但路由、状态和 SEO 会变得复杂;把所有子应用打包到同一个页面中,通信方便,却必须处理 React 版本、全局 CSS 和依赖重复加载的问题。


一、先定义微前端边界

微前端是把一个面向用户的前端系统拆分为多个具有相对独立交付能力的前端单元,并通过某种组合方式共同呈现一个产品。

“相对独立”至少包括以下维度中的一部分:

  • 独立开发和测试;
  • 独立构建;
  • 独立部署或回滚;
  • 明确的路由边界;
  • 明确的数据和事件接口;
  • 对依赖、样式或运行时故障有一定隔离能力。

因此,下列结构不一定是微前端:

  • 只有一个仓库、一个构建产物,仅按目录拆分组件;
  • 把大型组件拆成多个 npm 包,但仍然必须同时发布;
  • 使用 monorepo 管理多个包,但所有页面仍然由同一个应用统一构建。

这些方案依然有价值,但更准确地说,它们是模块化单体组件库工程。微前端的关键不是代码目录,而是组合边界和交付边界

1. 常见组合方式

可以把组合方式按 JavaScript 运行时边界分成三类:

方式 运行时关系 隔离能力 交互成本 常见用途
构建时组合 最终打成一个应用 同团队拆包、渐进重构
同页面运行时组合 多个应用在同一页面、同一 JS realm 中低 Module Federation、动态加载
iframe 或独立文档组合 每个应用是独立文档 遗留系统、第三方系统、高风险模块

这里的 JavaScript realm 可以理解为一套独立的全局对象、原型链和事件循环环境。同一页面中的两个应用通常共享:

window
document
globalThis
CSSOM
浏览器历史记录

iframe 中的应用拥有自己的 windowdocument 和 CSS 环境。这个差异决定了后续的依赖隔离、样式隔离和通信方式。


二、先画清楚整体数据流

一个典型的微前端系统可以分成:

flowchart LR
    B[浏览器] --> S[主应用 Shell]
    S --> R[路由解析]
    R --> A[子应用 A]
    R --> C[子应用 B]
    S --> G[全局能力]
    G --> Auth[认证]
    G --> Obs[日志与监控]
    G --> Config[运行时配置]

    A --> AState[应用 A 本地状态]
    C --> CState[应用 B 本地状态]
    A -. 领域事件 .-> Bus[受控事件接口]
    C -. 领域事件 .-> Bus
    Bus --> S

    A --> API1[后端 API]
    C --> API2[后端 API]

其中:

  • 主应用 Shell 负责页面骨架、登录态接入、顶层路由和子应用挂载;
  • 子应用 负责一个业务领域内的页面、局部路由和领域状态;
  • 全局能力 提供认证、监控、配置等基础设施,但不应自动拥有所有业务状态;
  • 领域事件 是应用之间传递事实的接口,例如“购物车已更新”,而不是让一个应用直接修改另一个应用的内部 store。

一个健康的边界通常满足:

业务状态所有权页面展示位置\text{业务状态所有权} \neq \text{页面展示位置}

例如,订单列表页面显示用户信息,并不意味着订单应用可以随意修改认证系统内部的 token store。它只能读取经过定义的认证能力,或者请求主应用提供用户上下文。


三、路由:谁拥有 URL、历史记录和页面生命周期

1. 路由不只是组件映射

React Router 等路由库解决的主要问题是:

URL路由匹配元素树数据加载和错误边界\text{URL} \rightarrow \text{路由匹配} \rightarrow \text{元素树} \rightarrow \text{数据加载和错误边界}

微前端中的路由还增加了三个问题:

  1. 主应用是否知道子应用内部的每一条路径?
  2. 浏览器后退、前进和直接刷新时,哪个应用恢复状态?
  3. 子应用被卸载时,正在进行的请求、订阅和定时器是否结束?

因此,路由设计必须先确定路由所有权

一种稳定的分层方式是:

主应用:
  /dashboard/*
  /orders/*
  /settings/*

订单子应用:
  /orders
  /orders/:orderId
  /orders/:orderId/items

主应用只负责 /orders/* 这个路由段是否交给订单应用;订单应用负责该段内部的匹配。

形式化地说,设主应用路由集合为 HH,订单应用路由集合为 OO,则应满足:

HO=H \cap O = \varnothing

这里的“不相交”不是字面上 URL 字符串完全不同,而是指同一层级上只能有一个权威匹配者。主应用可以拥有 /orders/* 的入口,订单应用拥有其内部子路径,但不能让两个路由器都在顶层竞争 /orders/123

2. 推荐的路由分层模型

主应用:

// host/src/routes.tsx
import { Routes, Route, Navigate } from "react-router";
import { Shell } from "./Shell";
import { OrdersEntry } from "./OrdersEntry";

export function AppRoutes() {
  return (
    <Routes>
      <Route element={<Shell />}>
        <Route index element={<Navigate to="/orders" replace />} />
        <Route path="/orders/*" element={<OrdersEntry />} />
        <Route path="/settings/*" element={<SettingsEntry />} />
      </Route>
    </Routes>
  );
}

订单子应用:

// orders/src/OrdersApp.tsx
import { Routes, Route, Navigate } from "react-router";

export function OrdersApp() {
  return (
    <Routes>
      <Route index element={<OrderList />} />
      <Route path=":orderId" element={<OrderDetail />} />
      <Route path=":orderId/items" element={<OrderItems />} />
      <Route path="*" element={<Navigate to="." replace />} />
    </Routes>
  );
}

如果主应用已经把 /orders/* 交给了子应用,子应用内部通常使用相对路径:

<Link to="123">查看订单</Link>
<Link to="../">返回订单列表</Link>

而不是重新写成:

<Link to="/orders/123">查看订单</Link>

相对路径减少了子应用对部署前缀的耦合,也便于把 /orders 迁移到其他前缀。

3. 路由加载的生命周期

一次导航可能经历以下状态:

stateDiagram-v2
    [*] --> Idle
    Idle --> Resolving: 用户访问 /orders/123
    Resolving --> Loading: 匹配子应用并加载入口
    Loading --> Mounted: 入口加载成功
    Mounted --> Rendering: 子应用匹配内部路由
    Rendering --> Ready: 数据加载完成
    Loading --> Failed: JS 加载失败
    Rendering --> Error: 渲染或数据加载失败
    Failed --> Retry: 用户重试
    Error --> Retry: 用户重试
    Ready --> Idle: 离开 /orders

每个状态都需要对应的用户可见行为:

  • Resolving:显示路由级 loading;
  • Loading:显示“应用加载失败,可重试”,而不是白屏;
  • Rendering:显示数据级 skeleton;
  • Error:由错误边界捕获,并保留返回上一级的能力;
  • 离开路由:取消尚未完成的请求和订阅。

4. 路由失败的一个真实问题

假设用户访问:

/orders/1001

主应用成功匹配了 /orders/*,但子应用 JavaScript 请求返回 404。此时不能简单地让主应用继续渲染空容器,因为:

  • 页面 URL 已经变化;
  • 用户可能点击了浏览器后退;
  • 监控系统需要知道是资源加载失败,而非业务数据为空;
  • 重试时应重新获取资源,而不是继续使用已失败的 Promise。

主应用可以为动态入口建立错误边界:

import { Component, type ReactNode } from "react";

type Props = {
  children: ReactNode;
};

type State = {
  error: Error | null;
};

export class RemoteErrorBoundary extends Component<Props, State> {
  state: State = { error: null };

  static getDerivedStateFromError(error: Error): State {
    return { error };
  }

  componentDidCatch(error: Error, info: unknown) {
    console.error("orders-app failed", { error, info });
  }

  render() {
    if (this.state.error) {
      return (
        <section role="alert">
          <h2>订单模块暂时不可用</h2>
          <button
            onClick={() => {
              this.setState({ error: null });
              window.location.reload();
            }}
          >
            重试
          </button>
        </section>
      );
    }

    return this.props.children;
  }
}

这里的 ErrorBoundary 只能捕获渲染阶段、生命周期和部分异步渲染错误,不能自动捕获普通事件处理函数中的异常,也不能替代网络请求错误处理。数据请求仍应在请求层或路由数据层显式处理。

5. 浏览器历史记录的取舍

同页面组合时,主应用和子应用通常共享 window.history。如果两个应用都直接调用顶层路由器,就可能出现:

用户点击一次
  -> 主应用 pushState
  -> 子应用也 pushState
  -> 历史记录增加两条
  -> 后退一次只回退到中间状态

因此应选择一种模式:

  • 单一顶层路由器:主应用管理所有 URL,子应用接收当前路径;
  • 分层路由器:主应用管理前缀,子应用只管理前缀内部;
  • iframe 独立路由器:主页面和 iframe 各自拥有历史记录,需要额外同步。

iframe 中的路由默认不会改变父页面 URL。若希望地址栏反映子应用路径,子应用必须向父页面发送导航消息,由父页面调用 history.pushState,然后再把路径传回 iframe。这是一个显式协议,而不是浏览器自动提供的能力。


四、状态:区分本地状态、服务器状态和跨应用状态

1. 三种状态不能混为一谈

在 React 应用中,至少应区分:

本地 UI 状态

例如:

  • 弹窗是否打开;
  • 当前 tab;
  • 输入框草稿;
  • 某个组件是否展开。

它的拥有者通常是最近的共同父组件,或者某个应用内部的 store。

服务器状态

例如:

  • 当前用户;
  • 订单列表;
  • 商品库存;
  • 权限和配置。

服务器状态具有缓存、过期、重新验证、请求竞态和错误重试等性质。它不等于“把 API 返回值放进 Redux”。

跨应用状态

例如:

  • 登录用户身份;
  • 租户切换结果;
  • 语言和主题;
  • 购物车摘要;
  • 全局 feature flag。

跨应用状态必须有明确的所有权和协议,否则任意应用都可以修改同一个全局对象,最终形成无法追踪的隐式耦合。

2. 状态所有权规则

设状态 xx 的写入者集合为 W(x)W(x)。为了让系统可以推理,核心业务状态通常应满足:

W(x)=1|W(x)| = 1

也就是一个状态只有一个权威写入者。其他应用通过以下方式获得它:

  • 读取只读快照;
  • 调用明确的命令接口;
  • 订阅领域事件;
  • 请求后端重新读取。

如果多个应用都能直接写入同一个对象,例如:

(window as any).userStore.user = ...

则状态变化无法回答:

  • 谁写的?
  • 写入顺序是什么?
  • 写入失败后是否回滚?
  • 当前值对应哪一次请求?
  • 应用卸载后订阅是否仍在运行?

3. 优先使用能力接口,而不是共享 store

主应用可以向子应用提供有限的能力:

export type AuthSnapshot = {
  userId: string | null;
  roles: readonly string[];
};

export type HostServices = {
  auth: {
    getSnapshot(): AuthSnapshot;
    subscribe(listener: () => void): () => void;
  };
  navigate(to: string): void;
  telemetry: {
    event(name: string, data?: Record<string, unknown>): void;
  };
};

子应用只依赖这个接口:

import { useSyncExternalStore } from "react";

export function useAuth(services: HostServices) {
  return useSyncExternalStore(
    services.auth.subscribe,
    services.auth.getSnapshot,
    services.auth.getSnapshot
  );
}

useSyncExternalStore 是 React 提供的、用于订阅外部 store 的 API。关键要求是:

  1. subscribe 返回取消订阅函数;
  2. getSnapshot 在状态未变化时返回稳定快照;
  3. 状态变化后先更新内部数据,再通知订阅者;
  4. 服务端渲染时提供一致的 getServerSnapshot,避免服务端和客户端初始结果不同。

一个最小外部 store 可以这样实现:

type Listener = () => void;

export function createAuthStore(initial: AuthSnapshot) {
  let snapshot = initial;
  const listeners = new Set<Listener>();

  return {
    getSnapshot() {
      return snapshot;
    },

    subscribe(listener: Listener) {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },

    set(next: AuthSnapshot) {
      if (
        next.userId === snapshot.userId &&
        next.roles.join(",") === snapshot.roles.join(",")
      ) {
        return;
      }

      snapshot = next;
      for (const listener of listeners) {
        listener();
      }
    },
  };
}

这里先更新 snapshot 再通知,是因为订阅者收到通知后会立即再次调用 getSnapshot。如果顺序反过来,订阅者可能读到旧值。

实际生产代码还应使用更可靠的不可变比较、错误处理和批量更新策略;示例只展示协议的最小机制。

4. 事件协议与请求-响应协议

跨应用通信有两种不同语义。

事件:描述已经发生的事实

type DomainEvent =
  | {
      type: "cart/changed";
      version: 3;
      itemCount: number;
    }
  | {
      type: "tenant/changed";
      tenantId: string;
    };

事件适合通知,不适合要求对方立即返回结果。事件应包含:

  • 稳定的事件名;
  • 可版本化的 payload;
  • 必要的来源信息;
  • 幂等或去重策略。

命令或请求:要求对方执行动作

type NavigationCommand = {
  type: "navigate";
  to: string;
};

命令的接收方可以拒绝、延迟或失败。不要把“命令”伪装成事件,否则调用方会误以为操作一定成功。

5. 跨 iframe 通信示例

postMessage 能跨文档边界通信,但必须校验来源和消息结构。

父页面:

const frame = document.querySelector<HTMLIFrameElement>("#orders-frame");

function navigateOrders(to: string) {
  frame?.contentWindow?.postMessage(
    {
      type: "host/navigate",
      version: 1,
      to,
    },
    "https://orders.example.com"
  );
}

window.addEventListener("message", (event: MessageEvent) => {
  if (event.origin !== "https://orders.example.com") {
    return;
  }

  if (event.source !== frame?.contentWindow) {
    return;
  }

  const message = event.data;

  if (
    message?.type === "orders/ready" &&
    message?.version === 1
  ) {
    console.log("订单应用已就绪");
  }
});

iframe 子应用:

window.addEventListener("message", (event: MessageEvent) => {
  if (event.origin !== "https://shell.example.com") {
    return;
  }

  const message = event.data;

  if (
    message?.type === "host/navigate" &&
    message?.version === 1 &&
    typeof message.to === "string"
  ) {
    // 这里由子应用自己的路由器处理相对路径
    router.navigate(message.to);
  }
});

window.parent.postMessage(
  {
    type: "orders/ready",
    version: 1,
  },
  "https://shell.example.com"
);

targetOrigin 不应写成 "*",除非消息本身不包含任何敏感信息且确实需要向任意来源发送。只检查 event.data.type 也不够,因为恶意页面可以伪造相同字段。

6. 请求竞态和卸载

微前端切换路由时,旧子应用可能还有请求未完成:

用户进入 /orders/1
  -> 请求订单 1
用户立即进入 /orders/2
  -> 卸载订单 1 页面
  -> 请求订单 1 晚到
  -> 旧请求错误地覆盖当前页面

请求应与组件生命周期关联:

import { useEffect, useState } from "react";

export function useOrder(orderId: string) {
  const [state, setState] = useState<
    | { status: "loading" }
    | { status: "ready"; data: Order }
    | { status: "error"; error: Error }
  >({ status: "loading" });

  useEffect(() => {
    const controller = new AbortController();

    setState({ status: "loading" });

    fetch(`/api/orders/${encodeURIComponent(orderId)}`, {
      signal: controller.signal,
    })
      .then(async (response) => {
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}`);
        }
        return response.json() as Promise<Order>;
      })
      .then((data) => setState({ status: "ready", data }))
      .catch((error: unknown) => {
        if (error instanceof DOMException && error.name === "AbortError") {
          return;
        }
        setState({
          status: "error",
          error: error instanceof Error ? error : new Error("未知错误"),
        });
      });

    return () => controller.abort();
  }, [orderId]);

  return state;
}

取消请求不能保证服务器端已经停止处理,但可以阻止已卸载页面继续消费结果。这解决的是客户端生命周期问题,不是后端事务回滚问题。


五、样式:CSS 的作用域和资源边界

1. 为什么 CSS 在微前端中容易泄漏

同一文档中的 CSS 选择器默认共享作用域。子应用写下:

button {
  border-radius: 0;
}

它可能影响主应用和其他子应用的所有按钮。主应用写下:

.modal {
  position: fixed;
}

也可能改变子应用内部同名类。

CSS 的问题不只在类名冲突,还包括:

  • bodyhtml:root 的全局规则;
  • CSS 自定义属性继承;
  • z-index 和 stacking context;
  • 字体、动画名称和 keyframes;
  • reset 和 normalize;
  • 资源 URL 的解析基准;
  • 未加载样式导致的布局跳动。

2. CSS 隔离的四个层级

命名约定

.orders-card {}
.orders-card__title {}
.orders-card--compact {}

优点是简单、兼容性好;缺点是依赖纪律,无法从机制上阻止冲突。

CSS Modules

import styles from "./OrderCard.module.css";

export function OrderCard() {
  return <article className={styles.card}>...</article>;
}

构建工具会把 card 编译为类似 OrderCard_card__x7f3a 的局部类名。它只能隔离经过模块化处理的类名,不能自动隔离:

body {}
:root {}
@keyframes fade {}

Shadow DOM

Web Component 可以使用 Shadow DOM:

class OrdersWidget extends HTMLElement {
  connectedCallback() {
    const root = this.attachShadow({ mode: "open" });

    const style = document.createElement("style");
    style.textContent = `
      :host { display: block; }
      .title { color: blue; }
    `;

    const title = document.createElement("h2");
    title.className = "title";
    title.textContent = "订单";

    root.append(style, title);
  }
}

customElements.define("orders-widget", OrdersWidget);

Shadow DOM 能阻止普通页面 CSS 穿透到内部,也能阻止内部普通选择器影响外部。但它不是完全隔离:

  • 继承来的字体和部分 CSS 自定义属性仍可能影响内部;
  • :host::part 提供了有意暴露的穿透点;
  • 页面布局仍受宿主元素尺寸影响;
  • React 组件要正确挂载到 Shadow Root,事件、样式注入和第三方库可能需要适配。

iframe

iframe 使用独立文档,CSS、document 和全局变量天然隔离。代价是跨文档布局、焦点管理、自动高度、路由和通信都需要协议。

3. CSS 自定义属性的边界

主题变量经常被误认为是“共享状态”:

:root {
  --color-primary: #1677ff;
}

在同页面组合中,这确实是一个全局 CSS API。子应用可能依赖它,但主应用改变变量后,所有应用会同时变化。

更稳妥的方式是定义版本化的主题契约:

:root {
  --wr-color-primary: #1677ff;
  --wr-space-2: 8px;
}

并约定:

  • 变量名称带产品或平台前缀;
  • 删除变量属于破坏性变更;
  • 子应用对变量缺失提供 fallback;
  • 组件不能随意覆盖平台变量。
.orders-button {
  color: var(--wr-color-primary, #1677ff);
}

4. 样式加载和卸载

同页面动态加载子应用时,样式通常被插入主文档。卸载 React 树不一定会自动移除 <style><link>。如果重新进入应用时重复注入,可能出现:

  • 样式节点不断增长;
  • 后加载规则改变优先级;
  • HMR 或切换后出现“旧页面样式残留”。

因此,子应用入口应把资源生命周期纳入卸载协议:

type MountedApp = {
  unmount(): void;
};

export function mount(container: HTMLElement): MountedApp {
  const style = document.createElement("style");
  style.dataset.owner = "orders-app";
  style.textContent = `.orders-root { contain: layout; }`;
  document.head.append(style);

  const root = createRoot(container);
  root.render(<OrdersApp />);

  return {
    unmount() {
      root.unmount();
      style.remove();
    },
  };
}

这不是 React 的自动保证,而是应用入口自己承诺的资源管理行为。


六、依赖隔离:React 是否共享,决定很多故障形态

1. 同页面中的依赖不是天然独立的

当两个子应用在同一个页面中运行时,它们可能同时加载:

  • react
  • react-dom
  • 路由库;
  • 状态库;
  • UI 组件库;
  • polyfill;
  • CSS-in-JS 运行时。

如果每个应用都携带自己的 React 副本,可能增加下载体积,也可能产生上下文和运行时不兼容。尤其要注意:

React 组件不能简单地在两个不同 React 副本之间传递并假设行为完全一致。

常见风险包括:

  • Context 在不同 React 副本之间不共享;
  • renderer 和 React 核心版本不匹配;
  • 两套 CSS-in-JS runtime 生成的样式顺序不同;
  • 全局 polyfill 的加载顺序互相覆盖;
  • 同名但不同版本的路由上下文不能互认。

2. 依赖共享和依赖隔离是相反目标

运行时共享依赖的目标是:

减少重复下载 + 共享同一个 React 上下文

隔离依赖的目标是:

允许独立升级 + 避免一个版本影响另一个应用

两者不能同时无限满足。若共享 react,就必须约束版本兼容;若完全隔离,就要接受重复下载和跨应用组件传递受限。

可以把决策写成约束:

共享 React版本兼容加载顺序可控单一 renderer 约束\text{共享 React} \Rightarrow \text{版本兼容} \land \text{加载顺序可控} \land \text{单一 renderer 约束}

若这三个条件不能稳定满足,iframe 或编译时组合通常更安全。

3. Module Federation 的位置

Module Federation 是主流构建工具生态中的运行时模块共享机制,但它不是 React 官方 API。不同 bundler 和插件的配置、版本兼容性和错误表现并不完全相同。

它通常涉及:

  • host:消费远程模块;
  • remote:暴露模块;
  • shared:声明希望共享的依赖;
  • remote entry:描述远程模块地址和加载信息。

概念配置可能类似:

shared: {
  react: { singleton: true, requiredVersion: "^19.0.0" },
  "react-dom": { singleton: true, requiredVersion: "^19.0.0" }
}

这里的 singleton 通常意味着同一页面尽量只使用一份依赖实例,但具体行为取决于实现和配置。不能把这段配置理解为“React 版本自动兼容”。

生产系统需要验证:

  1. host 和 remote 的 React 主版本是否兼容;
  2. reactreact-dom 是否来自兼容组合;
  3. remote entry 是否可缓存、可回滚;
  4. 远程模块超时、404、CORS、SRI 或 CSP 失败时如何降级;
  5. remote 是否在初始化阶段执行了危险的全局副作用;
  6. 远程版本切换是否可能导致旧页面和新资源混用。

4. 依赖加载失败的故障路径

假设主应用依赖 react@19.x,远程应用声明共享 React,但 CDN 上的远程入口被更新为不兼容版本:

加载 remoteEntry 成功
  -> 解析共享依赖
  -> 发现版本不满足
  -> 运行时选择另一份 React 或直接初始化失败
  -> 组件可能无法渲染,或者出现上下文丢失

应将远程资源视为外部依赖处理:

  • 使用不可变版本或内容哈希 URL;
  • 发布 manifest 指向具体版本;
  • 先预发布验证,再切换 manifest;
  • 保留上一版本用于快速回滚;
  • 记录 host、remote、React 和路由库版本;
  • 对远程模块加载耗时和失败率单独监控。

5. 什么时候应选择 iframe

以下情况更适合 iframe:

  • 子应用使用完全不同的前端框架;
  • 旧系统依赖大量全局变量和全局 CSS;
  • 不信任的团队或第三方代码需要边界;
  • 允许独立部署、独立刷新和独立故障;
  • 页面布局对跨文档交互要求不高。

iframe 并不自动解决安全问题。仍应考虑:

  • Content-Security-Policy
  • frame-ancestors
  • sandbox 属性;
  • postMessage 的来源校验;
  • cookie 的 SameSite、第三方 cookie 限制;
  • 跨域认证和 token 暴露风险。

如果 iframe 设置了 sandbox,例如:

<iframe
  src="https://orders.example.com"
  sandbox="allow-scripts allow-forms"
  referrerpolicy="strict-origin-when-cross-origin"
></iframe>

它可能限制脚本、表单、弹窗、同源访问等能力。具体 token 认证、下载和支付流程必须在目标浏览器策略下验证,不能只在本地开发环境判断可行。


七、客户端与服务端边界

1. React 组件能否直接跨应用传递

在同一个 React 树中,可以传递 React 元素、Context 和 props。但在微前端中,子应用常常拥有自己的 createRoot

const root = createRoot(container);
root.render(<RemoteApp />);

这意味着它与主应用不是同一棵 React 树。主应用中的 Context 通常不会自动传入子应用:

<AuthContext.Provider value={...}>
  <RemoteMount />
</AuthContext.Provider>

如果 RemoteMount 内部另行创建了 root,远程应用不会自动获得这个 AuthContext。必须通过 props、能力接口、外部 store 或事件协议显式传递。

React 19 的 createRoothydrateRoot、Hooks 和错误处理能力可以用于各应用内部,但 React 并没有提供一个“跨独立 root 自动共享 Context”的微前端 API。

2. 服务端渲染不是客户端拼装的自然延伸

服务端渲染(SSR)是服务端生成 HTML,客户端再通过 hydration 接管。React Server Components(RSC)则进一步把一部分组件和数据处理放在服务端执行。

这些机制都要求服务端和客户端对组件树、资源映射及数据边界有一致理解。若主应用在服务端渲染:

主应用服务端生成 Shell HTML
子应用只在浏览器加载

结果可能是:

  • 首屏子应用位置为空;
  • 客户端加载后发生布局变化;
  • SEO 只能看到 Shell;
  • hydration 只作用于 Shell,子应用另行 createRoot

这不是错误,但必须明确它是客户端微前端 + 服务端渲染 Shell,而不是完整的服务端组合。

3. 服务端组合的三种方式

构建时组合

服务端和客户端都使用同一个构建产物。最容易保证 hydration 一致性,但独立部署能力弱。

边缘或服务端 HTML 组合

服务器把多个应用输出的 HTML 拼成页面。必须解决:

  • 每个应用的资源清单;
  • 数据预取;
  • CSS 收集与顺序;
  • hydration root 的边界;
  • 错误时是否回退到空壳;
  • CSP nonce 和脚本加载顺序。

iframe

服务端只输出 iframe 标签,子应用独立渲染。边界最清晰,但父页面无法直接获得子应用 HTML 内容,SEO 和无障碍体验需要额外设计。

不能把 RSC 的服务端组件结果随意当成远程 JavaScript 组件传给另一个独立 React 应用。RSC 使用特定的服务端协议和 bundler 配置,远程组件能否跨边界组合取决于具体框架和构建系统,而不是 React 19 单独保证的能力。


八、一个可工作的最小组合协议

无论采用哪种技术,子应用都应暴露稳定的生命周期,而不是让主应用操作子应用内部实现。

export type MountContext = {
  container: HTMLElement;
  basePath: string;
  services: HostServices;
};

export type MountedApplication = {
  unmount(): void;
  update?(context: Partial<MountContext>): void;
};

export type ApplicationEntry = {
  mount(context: MountContext): MountedApplication;
};

子应用实现:

import { createRoot, type Root } from "react-dom/client";

export function mount({
  container,
  basePath,
  services,
}: MountContext): MountedApplication {
  const root: Root = createRoot(container);

  root.render(
    <OrdersApp
      basePath={basePath}
      services={services}
    />
  );

  return {
    unmount() {
      root.unmount();
    },
  };
}

主应用使用:

export function OrdersEntry() {
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!containerRef.current) {
      throw new Error("orders container is missing");
    }

    let mounted: MountedApplication | undefined;

    loadOrdersEntry()
      .then((entry) => {
        mounted = entry.mount({
          container: containerRef.current!,
          basePath: "/orders",
          services,
        });
      })
      .catch((error) => {
        reportError(error);
        renderRemoteLoadFailure(containerRef.current!);
      });

    return () => {
      mounted?.unmount();
    };
  }, []);

  return <div ref={containerRef} className="orders-host" />;
}

这个协议有几个重要性质:

  • mount 明确创建资源;
  • unmount 明确释放资源;
  • 主应用不依赖子应用内部 store;
  • basePath 显式传递,避免子应用猜测部署位置;
  • 加载失败与渲染失败可以分别监控;
  • React root 的创建和销毁由同一应用入口管理。

useEffect 适合处理浏览器端挂载副作用;如果该入口在服务端渲染,服务端不会执行 useEffect,因此不能依赖它生成服务端 HTML。


九、迁移:不要按技术组件拆,要按风险和边界拆

1. 迁移的基本目标

旧系统迁移到微前端,通常有两个互相冲突的目标:

  • 降低单次变更风险;
  • 获得独立部署和团队自治。

若为了“独立部署”直接拆出十几个子应用,往往会先得到:

  • 多套 CI/CD;
  • 多份 React 和 UI 库;
  • 多个监控入口;
  • 复杂的本地联调;
  • 难以定位的跨应用故障。

因此,迁移的第一步不是拆仓库,而是识别领域边界:

认证与 Shell
订单
商品
结算
报表

一个候选边界至少应满足:

  1. 业务目标清晰;
  2. 路由前缀可以稳定划分;
  3. 数据写入者相对集中;
  4. 与其他模块的接口可以描述;
  5. 即使加载失败,也能定义降级行为。

2. 推荐的渐进迁移路径

第一步:先在单体内建立边界

即使仍然只有一个构建产物,也可以先做到:

host/
  shell/
  contracts/
  orders/
  settings/

把跨模块调用从:

ordersStore.internal.dispatch(...)

改为:

ordersApi.openOrder(orderId)

先建立接口,再改变部署方式。否则迁移只是把隐式耦合搬到网络和远程加载层。

第二步:先迁移低耦合、可回退的页面

适合优先迁移的页面:

  • 管理后台中的独立报表;
  • 只读列表;
  • 路由边界清晰的设置页面;
  • 有明确空状态和错误页面的模块。

不适合第一批迁移的页面:

  • 支付和结算;
  • 跨多个域的复杂编辑器;
  • 依赖大量全局 CSS 的旧页面;
  • 认证、权限和导航核心逻辑。

第三步:保留反向切换开关

主应用可以通过配置决定使用旧实现还是新子应用:

function OrdersEntry() {
  const useMicroFrontend = config.flags.ordersRemote;

  if (!useMicroFrontend) {
    return <LegacyOrdersPage />;
  }

  return <RemoteOrdersPage />;
}

切换开关必须:

  • 能按环境或租户控制;
  • 能记录实际命中的版本;
  • 能在远程加载失败时回退;
  • 防止新旧实现同时写入同一业务数据。

第四步:最后迁移全局能力

认证、主题、监控和导航一旦变成跨应用平台能力,兼容性成本会显著增加。应先把接口定义稳定,再决定是否共享实现。

例如,子应用依赖:

services.auth.getSnapshot()

比依赖:

import { globalAuthStore } from "@company/auth-internal";

更容易在 iframe、同页面组合和测试环境之间迁移。

3. 迁移中的双写风险

假设旧订单页面和新订单子应用都订阅同一个“订单更新”事件,并且两者都允许保存:

用户在新页面点击保存
  -> 新页面写入后端
  -> 事件广播
  -> 旧页面也执行保存逻辑
  -> 后端出现重复写入或版本冲突

迁移期间应确保:

同一业务命令的有效执行者=1\text{同一业务命令的有效执行者} = 1

可以采用:

  • 新旧页面只允许一个处于写入模式;
  • 后端使用幂等键;
  • 请求携带版本号,后端做乐观并发控制;
  • 旧实现改为只读观察者;
  • 切换时清理旧订阅和定时器。

十、常见误解、失败表现和诊断方法

误解一:monorepo 就是微前端

monorepo 解决的是代码管理和依赖协作问题,不自动提供独立部署、运行时隔离或故障隔离。一个 monorepo 可以构建出一个单体,也可以构建出多个微前端。

诊断方法是看发布单元:

修改 orders 是否必须重新构建并发布整个 host?

如果必须,那么它更接近构建时组合,而不是独立运行时微前端。

误解二:共享一个全局 Redux store 就能解决状态问题

共享 store 降低了通信成本,却扩大了耦合面。只要一个应用修改 state shape、action 名称或中间件假设,其他应用就可能失败。

如果确实需要共享,应把它当成版本化平台契约:

  • 公共 state 结构有限;
  • action 具备版本;
  • 变更有兼容策略;
  • 订阅和销毁明确;
  • 不暴露内部 reducer 和私有字段。

误解三:CSS Modules 能完全隔离样式

CSS Modules 只对模块化类名提供局部化。以下内容仍可能全局影响页面:

body {}
html {}
:root {}
@font-face {}
@keyframes {}

诊断时应检查最终生成的 CSS,而不是只看源文件。浏览器开发者工具中的“Matched CSS Rules”和 <head> 中的样式节点,通常比源码搜索更快发现问题。

误解四:错误边界能捕获所有子应用错误

错误边界不能替代:

  • 远程脚本加载失败处理;
  • fetch 网络错误处理;
  • iframe 内部错误监控;
  • Web Worker 错误处理;
  • 事件回调中的异常处理。

iframe 中的 React 错误边界也不会自动把错误传给父页面。子应用需要显式向监控系统上报,或者通过 postMessage 发送经过筛选的错误摘要。

误解五:只要 URL 能打开,路由迁移就完成了

还必须验证:

  • 直接刷新深层 URL;
  • 浏览器后退和前进;
  • 子应用加载失败;
  • 子应用切换后的请求取消;
  • 页面标题和焦点;
  • 404 和权限拒绝;
  • SSR 或预渲染时的初始 HTML;
  • 部署前缀和 CDN 缓存。

例如,开发环境能打开 /orders/1,生产环境却返回服务器根页面或静态资源 404,通常是服务器 fallback 或资源 public path 配置不一致,而不是 React 路由匹配本身的问题。


十一、如何选择方案

可以用以下问题做决策,而不是先决定采用某个框架:

问题 倾向构建时组合 倾向同页面运行时组合 倾向 iframe
是否必须独立部署
是否需要跨应用无缝布局
是否允许不同框架或版本
是否要求 CSS 强隔离 需额外方案
是否需要共享 React Context 可设计 不适用
是否允许子应用独立故障
是否重视首屏和 SEO 易处理 需 SSR 方案 较困难
是否有遗留全局变量 可容纳 风险较高 易容纳

一个简单的取舍逻辑是:

总复杂度=组合复杂度+通信复杂度+隔离成本+运维成本\text{总复杂度} = \text{组合复杂度} + \text{通信复杂度} + \text{隔离成本} + \text{运维成本}

构建时组合降低通信和运维复杂度,但牺牲独立发布;iframe 提高隔离能力,却增加路由、布局和通信成本;同页面运行时组合处于中间位置,但对依赖和生命周期的要求最高。


十二、上线前的验证重点

路由

  • 主应用和子应用是否只有一个顶层 URL 权威者;
  • 深层链接是否可直接刷新;
  • 子应用退出时是否清理请求、订阅、定时器和样式;
  • 远程入口 404、超时、CORS 失败时是否有可见降级;
  • 后退、前进和重复点击是否产生正确历史记录。

状态

  • 每个跨应用状态是否有唯一写入者;
  • 事件是否有版本和来源校验;
  • 请求是否支持取消或过期结果丢弃;
  • 登录失效、租户切换和权限变化是否能传播;
  • 新旧实现迁移期间是否存在双写。

样式

  • 是否限制 body:root、通用标签和全局 keyframes;
  • CSS-in-JS 或 <style> 节点是否会重复注入;
  • 主题变量是否有命名空间和 fallback;
  • iframe 或 Shadow DOM 中的字体、弹层和焦点是否符合预期。

依赖

  • React 与 react-dom 版本是否兼容;
  • 共享依赖失败时是否会阻止整个页面;
  • 远程资源是否可回滚;
  • 是否记录实际加载的 host、remote 和依赖版本;
  • 是否避免把不同 React root 的 Context 当作共享 Context。

客户端与服务端

  • SSR HTML 与客户端初始树是否一致;
  • 子应用是否明确只在客户端加载;
  • RSC、SSR、动态远程模块的构建边界是否经过框架支持;
  • 认证 cookie、CSP、CORS 和 iframe 策略是否在生产域名下验证。

微前端的核心不是“把前端拆开”,而是把路由、状态、样式、依赖和生命周期的所有权显式化。当边界清楚时,构建时组合、同页面运行时组合和 iframe 都可以成为合理方案;当边界不清楚时,任何微前端框架都会把原来的隐式耦合变成更难排查的网络、版本和运行时故障。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。