微前端源码精讲

第8章 single-spa 的路由拦截

作者 杨艺韬 · 12,458 字

第8章 single-spa 的路由拦截

“所有微前端框架的路由系统,本质上都是在回答同一个问题:如何在浏览器只有一个地址栏的约束下,让多个独立应用各自认为自己拥有完整的路由控制权。”

本章要点

  • 理解 single-spa 对 history.pushState / replaceState 的 monkey-patch 机制及其设计动机
  • 掌握 popstatehashchange 事件的统一拦截与延迟触发策略
  • 追踪从 URL 变化到子应用加载/挂载/卸载的完整调用链路
  • 深入理解 reroute 函数——single-spa 路由系统的心脏
  • 掌握 single-spa 与 React Router / Vue Router 共存的原理与实战策略
  • 理解路由拦截中的边界条件:快速连续导航、前进/后退、hash 模式兼容

打开浏览器的开发者工具,在控制台输入 history.pushState,你会得到一个原生函数。再在一个接入了 single-spa 的项目里做同样的事——你得到的不再是浏览器的原生实现,而是一个被 single-spa 精心包装过的函数。

这不是 bug,这是 single-spa 整个路由系统的基石。

微前端的核心难题之一,是路由的所有权归属。在传统单页应用(SPA)中,整个应用只有一个路由系统——React Router、Vue Router 或者 Angular Router,它们独占浏览器的 History API,监听 URL 变化,决定渲染什么内容。一切井然有序。

但在微前端架构下,主应用和每个子应用可能各自携带自己的路由系统。当用户点击导航从 /order/list 跳转到 /product/detail/42,这个 URL 变化需要触发三件事:

  1. single-spa 层面:识别出这是一次跨应用导航——订单子应用需要卸载,商品子应用需要加载并挂载
  2. 商品子应用的 Router:识别出 /product/detail/42 匹配其内部的 Detail 路由,渲染对应组件
  3. 主应用的 Router(如果存在):可能需要更新导航栏的高亮状态

三个路由系统,一次 URL 变化,三种不同的响应——而且必须按正确的顺序执行。如果商品子应用的 Router 在 single-spa 完成挂载之前就尝试渲染,页面会崩溃。如果 single-spa 在子应用的 Router 注册好事件监听之前就触发了路由事件,子应用会错过这次导航。

single-spa 的解法,是从源头掌控一切路由事件的分发。它通过 monkey-patch 浏览器的 History API 和拦截路由事件,建立了一个中央路由调度层。所有的 URL 变化必须先经过 single-spa 的处理,然后才会被分发到各个子应用的路由系统。

本章将从源码层面,完整拆解这个路由拦截机制的每一个细节。

8.1 对 pushState / replaceState 的 monkey-patch

Monkey Patching(猴子补丁)这个词、诞生于 Python 和 Ruby 社区、最早被用来描述”在运行时修改已存在类或模块的行为”这种略带争议的编程技巧之所以叫”猴子补丁”、据说是因为这种技巧最初被称作 guerrilla(游击队)patch、发音与 gorilla(大猩猩)相似、最后变体为 monkey。**这个技巧在动态语言里几乎是不可避免的——当你发现一个库的某个行为不符合需求、而你又不能修改它的源码、这时候你唯一的选择就是在运行时覆盖它的方法single-spa 对 pushState/replaceState 的 monkey-patch、就是这种技巧的典型应用——浏览器的 History API 是内置的、不能修改源码;但它缺少了 single-spa 需要的事件通知能力;所以 single-spa 只能在运行时替换它

这种做法有风险也有回报——风险是”其他代码可能依赖原始行为”、回报是”可以让所有使用 History API 的代码都自动获得 single-spa 的事件通知”。single-spa 非常谨慎地处理了这个取舍——它的 monkey-patch 做的不是”替换原有行为”、而是”在原有行为之前/之后加上自己的逻辑”——保留了所有原始行为、只是多派发了一个事件这种”增强而不取代”的做法、是 monkey-patch 最安全的使用方式

8.1.1 为什么需要拦截 History API

浏览器的 History API 有一个广为人知的设计缺陷:调用 history.pushState()history.replaceState() 不会触发任何事件。

// 浏览器原生行为
history.pushState({ page: 1 }, '', '/new-url');
// URL 变了,但不会触发 popstate 事件
// 不会触发 hashchange 事件
// 没有任何事件通知任何人 URL 已经改变

这意味着如果一个子应用调用了 history.pushState() 来改变 URL,single-spa 完全不知道发生了什么。它无法判断当前 URL 是否仍然匹配当前活跃的子应用,更无法触发必要的应用切换。

popstate 事件只在用户点击浏览器的前进/后退按钮时触发,而绝大多数 SPA 的导航是通过编程式调用 pushState / replaceState 完成的。这是一个致命的信息盲区。

single-spa ��解法直接而暴力:劫持原生方法,在每次调用时手动触发路由检查。

下图展示了 single-spa 路由拦截的整体架构,从各种路由事件源到最终的应用调��:

flowchart TB
    subgraph EventSources["路由事件来源"]
        Push["子应用调用\nhistory.pushState()"]
        Replace["子应用调用\nhistory.replaceState()"]
        BackFwd["用户点击\n浏览器前进/后退"]
        Hash["hash 变化\nhashchange"]
    end

    Push --> MonkeyPatch["monkey-patched pushState\n调用原始方法 + 派发 PopStateEvent"]
    Replace --> MonkeyPatch2["monkey-patched replaceState\n调用原始方法 + 派发 PopStateEvent"]
    BackFwd --> PopState["原生 popstate 事件"]
    Hash --> HashChange["原生 hashchange 事件"]

    MonkeyPatch --> URLReroute["urlReroute()"]
    MonkeyPatch2 --> URLReroute
    PopState --> URLReroute
    HashChange --> URLReroute

    URLReroute --> Reroute["reroute()\n应用调度"]
    Reroute --> CapturedListeners["callCapturedEventListeners()\n延迟触发被拦截的路由监听器"]
    CapturedListeners --> SubRouter["子应用 Router\nReact Router / Vue Router"]

    style MonkeyPatch fill:#fff3e0,stroke:#e65100
    style MonkeyPatch2 fill:#fff3e0,stroke:#e65100
    style Reroute fill:#e3f2fd,stroke:#1565c0
    style CapturedListeners fill:#e8f5e9,stroke:#2e7d32

8.1.2 patchedUpdateState 的源码实现

以下是 single-spa 对 pushStatereplaceState 进行 monkey-patch 的核心代码:

// single-spa/src/navigation/navigation-events.js

// 第一步:保存原始方法的引用
const originalPushState = window.history.pushState;
const originalReplaceState = window.history.replaceState;

/**
 * 创建一个包装函数,在调用原始 History 方法后触发路由重评估
 * @param updateState - 原始的 pushState 或 replaceState 方法
 * @param methodName - 方法名称,用于创建自定义事件
 */
function patchedUpdateState(updateState: typeof history.pushState, methodName: string) {
  return function (this: History, ...args: Parameters<typeof history.pushState>) {
    // 记录 URL 变化前的状态
    const urlBefore = window.location.href;

    // 调用原始的 pushState 或 replaceState
    const result = updateState.apply(this, args);

    // 记录 URL 变化后的状态
    const urlAfter = window.location.href;

    // 只有 URL 真正发生了变化,才触发路由重评估
    if (urlBefore !== urlAfter) {
      // 创建并派发一个自定义的 popstate 事件
      // 注意:这里用的是 PopStateEvent,不是自定义事件类型
      // 这样做是为了让所有监听 popstate 的代码(包括子应用的 Router)
      // 能够正常接收到这个事件
      window.dispatchEvent(
        createPopStateEvent(window.history.state, methodName)
      );
    }

    return result;
  };
}

// 创建模拟的 PopStateEvent
function createPopStateEvent(state: any, methodName: string): PopStateEvent {
  let evt;
  try {
    // 现代浏览器
    evt = new PopStateEvent('popstate', { state });
  } catch (err) {
    // IE 11 兼容
    evt = document.createEvent('PopStateEvent');
    (evt as any).initPopStateEvent('popstate', false, false, state);
  }

  // 在事件对象上标记触发来源
  // 这个标记至关重要——它让 single-spa 的 popstate 监听器能够区分
  // "真正的浏览器前进/后退" 和 "pushState/replaceState 触发的模拟事件"
  (evt as any).singleSpa = true;
  (evt as any).singleSpaTrigger = methodName; // 'pushState' 或 'replaceState'

  return evt;
}

// 第二步:替换全局方法
window.history.pushState = patchedUpdateState(originalPushState, 'pushState');
window.history.replaceState = patchedUpdateState(originalReplaceState, 'replaceState');

这段代码的精妙之处在于几个关键设计决策:

决策一:只在 URL 真正变化时触发事件。 replaceState 经常被用来更新 state 对象但不改变 URL(比如 React Router 的 replace 功能)。如果每次调用都触发路由重评估,会导致不必要的性能开销和潜在的无限循环。

决策二:派发标准的 PopStateEvent 而非自定义事件。 子应用的路由框架(React Router、Vue Router)只监听 popstate 事件。如果 single-spa 派发一个自定义事件类型(比如 single-spa:routing-event),子应用的 Router 无法感知到路由变化。使用标准的 PopStateEvent 确保了与所有路由框架的兼容性。

决策三:在事件对象上打标记。 通过 evt.singleSpa = trueevt.singleSpaTrigger,single-spa 的内部逻辑可以区分事件来源。这在后续的事件处理中至关重要。

8.1.3 monkey-patch 的执行时机

一个容易被忽视的细节是:这段 monkey-patch 代码在 single-spa 的模块加载阶段就立即执行,而不是等到 start() 被调用。

// navigation-events.js 是一个模块
// 以下代码在模块被 import 时就执行,而不是在某个函数内部

// 立即执行:替换 History API
window.history.pushState = patchedUpdateState(originalPushState, 'pushState');
window.history.replaceState = patchedUpdateState(originalReplaceState, 'replaceState');

// 立即执行:注册事件监听
window.addEventListener('popstate', urlReroute);
window.addEventListener('hashchange', urlReroute);

为什么要这么早?因为如果在 start() 调用之前有子应用注册并触发了路由变化,这些变化不能被遗漏。single-spa 需要从第一刻就掌控所有的路由信息。

深度洞察:monkey-patch 的哲学问题

对全局 API 进行 monkey-patch 是一个充满争议的技术决策。它违反了”不要修改你不拥有的对象”的编程原则,也可能与其他同样进行 monkey-patch 的库产生冲突。但在微前端的语境下,这几乎是唯一可行的方案——浏览器没有提供原生的 “navigation” 事件(注:Navigation API 在 2023 年才开始被部分浏览器支持,且当时的兼容性不足以用于生产),而 single-spa 必须在所有路由变化发生时得到通知。这是一个权衡了现实约束的务实选择,而不是一个优雅的设计。理解这种权衡思维,对架构师而言比掌握具体实现更重要。

8.2 popstate / hashchange 的统一处理

**浏览器的路由事件模型是一个”历史包袱”的典型——从最早的 hashchange、到 HTML5 的 popstate、到晚于两者出现的 pushState/replaceState——三者虽然都和路由相关、却有完全不同的触发规则这种混乱让路由库的作者头大——你必须同时监听三种事件、并且能正确处理”同一次路由变化触发多个事件”的重复问题。single-spa 的解法是”统一入口”——不管是哪种事件、最终都引流到同一个处理函数 urlReroute这种”多源归一”的设计模式、在软件工程里极其常见——后端网关把多个来源的请求归一到同一套处理逻辑、日志系统把多个采集点的日志归一到同一个存储、监控系统把多种告警归一到同一个通知通道。**核心思想是——不要让下游逻辑关心上游有多少种来源、让入口层去做归一化、下游只处理一种标准形态

8.2.1 urlReroute:路由事件的统一入口

当 URL 发生变化时——无论是通过 monkey-patched 的 pushState/replaceState 触发的模拟事件,还是用户点击浏览器前进/后退按钮触发的真实 popstate 事件,甚至是 hash 模式下的 hashchange 事件——它们最终都会汇聚到同一个处理函数:urlReroute

// single-spa/src/navigation/navigation-events.js

/**
 * 所有路由事件的统一入口
 * 无论事件来源如何,最终都调用 reroute()
 */
function urlReroute(evt: PopStateEvent | HashChangeEvent): void {
  reroute([], arguments);
}

// 注册监听器
window.addEventListener('hashchange', urlReroute);
window.addEventListener('popstate', urlReroute);

urlReroute 本身极其简单——它只是 reroute 的一个薄封装。但围绕它的事件监听机制却暗藏玄机。

8.2.2 事件拦截与延迟触发

single-spa 不仅要监听路由事件,还要控制这些事件何时被子应用接收到。这是整个路由拦截机制中最精妙的部分。

问题是这样的:当一次路由变化触发了子应用的切换(比如卸载 App A,加载并挂载 App B),single-spa 需要确保 App B 的路由监听器在 App B 完全挂载之后才接收到路由事件。否则,App B 的 Router 可能在 DOM 容器还不存在的时候就试图渲染,导致崩溃。

single-spa 的解法是:拦截子应用注册的 popstate/hashchange 事件监听器,在 reroute 完成后才统一触发。

// single-spa/src/navigation/navigation-events.js

// 存储被拦截的事件监听器
const capturedEventListeners: Record<string, Function[]> = {
  hashchange: [],
  popstate: [],
};

// 保存原始的 addEventListener 和 removeEventListener
const originalAddEventListener = window.addEventListener;
const originalRemoveEventListener = window.removeEventListener;

/**
 * 重写 window.addEventListener
 * 拦截对 popstate 和 hashchange 的监听注册
 */
window.addEventListener = function (
  eventName: string,
  fn: EventListenerOrEventListenerObject,
  ...rest: any[]
) {
  if (typeof fn === 'function') {
    if (
      (eventName === 'hashchange' || eventName === 'popstate') &&
      // 确保不是 single-spa 自己注册的监听器
      !capturedEventListeners[eventName].some((listener) => listener === fn)
    ) {
      // 不调用原始的 addEventListener
      // 而是将监听器保存到 capturedEventListeners 中
      capturedEventListeners[eventName].push(fn);
      return;
    }
  }

  // 其他事件类型正常注册
  return originalAddEventListener.apply(this, [eventName, fn, ...rest]);
};

/**
 * 重写 window.removeEventListener
 * 同步维护 capturedEventListeners
 */
window.removeEventListener = function (
  eventName: string,
  fn: EventListenerOrEventListenerObject,
  ...rest: any[]
) {
  if (typeof fn === 'function') {
    if (eventName === 'hashchange' || eventName === 'popstate') {
      capturedEventListeners[eventName] = capturedEventListeners[eventName].filter(
        (listener) => listener !== fn
      );
      return;
    }
  }

  return originalRemoveEventListener.apply(this, [eventName, fn, ...rest]);
};

这段代码的效果是:当 React Router 调用 window.addEventListener('popstate', handlePop) 时,这个 handlePop 函数并不会真正注册到浏览器上。它被 single-spa “截获”并存放在 capturedEventListeners.popstate 数组中。

那这些被截获的监听器何时执行呢?答案在 reroute 完成之后的回调中:

/**
 * 在 reroute 完成后,手动触发所有被截获的事件监听器
 * 确保子应用的 Router 在应用挂载完成后才接收到路由事件
 */
function callCapturedEventListeners(eventArguments: IArguments | any[]): void {
  if (eventArguments) {
    const eventType = eventArguments[0]?.type;

    if (eventType) {
      const listeners = capturedEventListeners[eventType];
      if (listeners && listeners.length > 0) {
        listeners.forEach((listener) => {
          try {
            listener.apply(window, eventArguments);
          } catch (err) {
            // 单个监听器的错误不应阻止其他监听器的执行
            setTimeout(() => {
              throw err;
            });
          }
        });
      }
    }
  }
}

8.2.3 事件流的完整时序

时序图(Sequence Diagram)是分析异步系统最直观的工具——它把”时间”作为主轴、把”参与方”作为列、清晰地展现”谁在什么时候做了什么、向谁发了什么消息本节的时序图、把一次从”用户点击链接”到”新子应用渲染完成”的完整过程、压缩在一张图里——这种”把复杂过程可视化”的能力、是架构师必备的技能当你能把一个异步流程画成时序图、你基本上就搞清楚了这个流程画不清楚的地方、往往就是你没理解的环节所以以后读任何开源项目的核心流程时、我强烈建议你养成习惯——读完源码之后、自己在纸上或 Excalidraw/Mermaid 上画一张时序图——这个动作能让你的理解从”看懂了”升级到”真懂了

下图展示了从用户点击导航到子应用完成切换的完整事件时序:

sequenceDiagram
    participant User as 用户
    participant Router as React Router
    participant Patched as monkey-patched pushState
    participant SingleSpa as single-spa reroute
    participant OrderApp as 订单子应用
    participant ProductApp as 商品子应用
    participant Captured as capturedEventListeners

    User->>Router: 点击导航链接
    Router->>Patched: history.pushState(null,'','/product/detail/42')
    Patched->>Patched: 调用原始 pushState, URL 更新
    Patched->>Patched: urlBefore !== urlAfter
    Patched->>SingleSpa: dispatchEvent(PopStateEvent)

    Note over SingleSpa: popstate 被 single-spa 监听器捕获
    SingleSpa->>SingleSpa: getAppChanges() 分类
    SingleSpa->>OrderApp: toUnmountPromise() 卸载
    OrderApp-->>SingleSpa: unmount 完成
    SingleSpa->>ProductApp: toLoadPromise() 加载
    ProductApp-->>SingleSpa: 加载完成
    SingleSpa->>ProductApp: toMountPromise() 挂载
    ProductApp-->>SingleSpa: mount 完成

    SingleSpa->>Captured: callCapturedEventListeners()
    Captured->>Router: 触发被拦截的 popstate 监听器
    Note over Router: 商品子应用的 Router 此时已挂载,可安全处理路由

让我们用一个具体的场景来理解完整的事件流。假设用户从 /order/list(订单子应用)点击导航跳转到 /product/detail/42(商品子应用):

用户点击链接


React Router 调用 history.pushState(null, '', '/product/detail/42')


命中 monkey-patched 的 pushState

    ├── 1. 调用原始 pushState → URL 更新为 /product/detail/42

    ├── 2. urlBefore !== urlAfter → 需要触发路由重评估

    └── 3. window.dispatchEvent(new PopStateEvent('popstate'))


        single-spa 的 urlReroute 监听器被触发
        (因为 single-spa 自己的监听器是通过原始 addEventListener 注册的)


        调用 reroute()

              ├── 4. 计算需要卸载的应用:[订单子应用]
              ├── 5. 计算需要加载的应用:[商品子应用]
              ├── 6. 执行卸载:调用订单子应用的 unmount 生命周期
              ├── 7. 执行加载:加载商品子应用的资源
              ├── 8. 执行挂载:调用商品子应用的 mount 生命周期
              │       └── 商品子应用的 React Router 此时初始化
              │           并通过 window.addEventListener('popstate', handlePop)
              │           注册监听器 → 被 single-spa 截获存入 capturedEventListeners

              └── 9. reroute 完成


                callCapturedEventListeners(popstateEvent)


                商品子应用的 React Router 的 handlePop 被调用


                React Router 读取当前 URL /product/detail/42
                匹配到 Detail 路由,渲染商品详情组件

这个时序保证了一个关键不变式:子应用的路由框架永远在应用挂载完成之后才接收到路由事件。

深度洞察:事件拦截的双刃剑

拦截 addEventListener 是一个影响全局的操作。如果某个第三方库(比如一个统计 SDK)也监听了 popstate 事件用于页面浏览追踪,它的监听器也会被 single-spa 截获和延迟触发。在大多数场景下这不会造成问题——统计数据晚几毫秒收到无伤大雅。但如果某个库依赖于 popstate 事件的精确触发时机来做关键逻辑判断,就可能出现难以调试的 bug。这就是为什么理解 single-spa 的路由拦截机制如此重要——当你不知道路由事件被拦截了,你甚至不知道该往哪个方向排查问题。

8.2.4 hash 模式的特殊处理

虽然现代 SPA 大多使用 history 模式,但 hash 模式(URL 形如 /#/order/list)仍然在一些场景下被使用——比如不需要服务端配置的静态部署环境。single-spa 同时监听 hashchange 事件来兼容这种模式。

// hash 变化可能同时触发 popstate 和 hashchange
// single-spa 需要避免重复处理

// 在 urlReroute 中,通过 reroute 内部的去重机制确保
// 即使同一次 URL 变化同时触发了两个事件,也只执行一次应用切换

let lastUrl = window.location.href;

function urlReroute(evt: PopStateEvent | HashChangeEvent): void {
  const currentUrl = window.location.href;

  // 如果 URL 没有变化,跳过处理
  // 这处理了某些浏览器中 popstate 和 hashchange 同时触发的情况
  if (currentUrl !== lastUrl) {
    lastUrl = currentUrl;
    reroute([], arguments);
  }
}

需要注意的是,在某些浏览器中(特别是旧版本的 Chrome 和 Firefox),修改 hash 既会触发 hashchange 事件也会触发 popstate 事件。single-spa 通过 URL 比对来避免重复触发 reroute

8.3 路由变化到应用加载 / 卸载的完整链路

如果说 8.1 和 8.2 节讲的是”如何捕获路由变化”、那么 8.3 节要讲的就是”捕获之后如何响应。**两者的关系就像”感官”和”神经反射”的关系——捕获是感官、响应是反射这一节我们要进入 single-spa 的”神经中枢”——reroute 函数及其背后的分类算法 getAppChanges 和编排函数 performAppChanges看懂这三个函数、你就真正理解了 single-spa 在”一次路由变化”发生时内部究竟在做什么而这种理解、会让你在未来调试任何 single-spa 相关的问题时、都能够”透视”表象、直接定位到内部执行的哪一步出了问题——这是从”能用框架”到”能掌控框架”的关键跃迁。

下图展示了 reroute 的并发控制机制,当多个路由变化快速连续触发时,后续变化被排入等待队列:

stateDiagram-v2
    [*] --> Idle: 初始空闲状态
    Idle --> Processing: reroute() 被调用
    Processing --> Processing: appChangeUnderway=true\n后续 reroute 排入 peopleWaitingOnAppChange 队列

    state Processing {
        [*] --> GetChanges: getAppChanges() 分类
        GetChanges --> Unmount: 并行卸载 appsToUnmount
        GetChanges --> Load: 并行加载 appsToLoad
        Unmount --> Mount: 卸载完成后挂载 appsToMount
        Load --> Mount: 加载完成后挂载
        Mount --> Finish: 所有操作完成
    }

    Processing --> DrainQueue: appChangeUnderway=false
    DrainQueue --> Processing: 队列非空,取出下一个处理
    DrainQueue --> Idle: 队列为空,回到空闲

8.3.1 reroute:路由系统的心脏

reroute 是 single-spa 中最核心的函数,没有之一。每当路由发生变化,reroute 负责计算当前 URL 下哪些应用应该被激活、哪些应该被卸载,然后按正确的顺序执行相应的生命周期。

// single-spa/src/navigation/reroute.js

// reroute 的核心状态
let appChangeUnderway = false;   // 是否正在进行应用切换
let peopleWaitingOnAppChange: Array<{
  resolve: Function;
  reject: Function;
  eventArguments: any;
}> = [];                          // 等待中的路由变化队列

/**
 * reroute 的主函数
 * @param pendingPromises - 来自 registerApplication 的待处理 Promise
 * @param eventArguments - 触发本次 reroute 的原始事件参数
 */
export function reroute(
  pendingPromises: Array<any> = [],
  eventArguments?: IArguments | any[]
): Promise<void> {
  // 关键判断:是否有正在进行中的应用切换?
  if (appChangeUnderway) {
    // 如果是,将本次路由变化加入等待队列
    // 等当前切换完成后再处理
    return new Promise((resolve, reject) => {
      peopleWaitingOnAppChange.push({
        resolve,
        reject,
        eventArguments,
      });
    });
  }

  // 第一步:根据当前 URL 将所有已注册的应用分为四类
  const {
    appsToUnload,    // 需要卸载的应用(从 MOUNTED → NOT_MOUNTED → NOT_LOADED)
    appsToUnmount,   // 需要取消挂载的应用(从 MOUNTED → NOT_MOUNTED)
    appsToLoad,      // 需要加载的应用(从 NOT_LOADED → LOADING_SOURCE_CODE)
    appsToMount,     // 需要挂载的应用(从 NOT_MOUNTED → MOUNTED)
  } = getAppChanges();

  // 第二步:判断 single-spa 是否已经 start()
  if (isStarted()) {
    // 已经 start() → 执行完整的应用切换
    appChangeUnderway = true;
    return performAppChanges();
  } else {
    // 还没有 start() → 只加载应用,不挂载
    return loadApps();
  }

  // ... performAppChanges 和 loadApps 的实现见后文
}

8.3.2 getAppChanges:应用分类算法

getAppChanges 是 reroute 的第一步——根据当前 URL 和每个应用的 activeWhen 函数,将所有已注册的应用分为四类:

// single-spa/src/applications/apps.js

interface AppChanges {
  appsToLoad: Application[];     // 需要加载的
  appsToMount: Application[];    // 需要挂载的
  appsToUnmount: Application[];  // 需要卸载的
  appsToUnload: Application[];   // 需要彻底卸载的
}

export function getAppChanges(): AppChanges {
  const appsToLoad: Application[] = [];
  const appsToMount: Application[] = [];
  const appsToUnmount: Application[] = [];
  const appsToUnload: Application[] = [];

  // 遍历所有已注册的应用
  const apps = getAppNames().map(getAppByName);

  apps.forEach((app) => {
    // shouldBeActive() 调用用户注册时提供的 activeWhen 函数
    // 传入当前的 window.location,返回 boolean
    const appShouldBeActive = shouldBeActive(app);

    switch (getAppStatus(app)) {
      case NOT_LOADED:
      case LOADING_SOURCE_CODE:
        // 应用还没加载,且当前 URL 匹配 → 需要加载
        if (appShouldBeActive) {
          appsToLoad.push(app);
        }
        break;

      case NOT_BOOTSTRAPPED:
      case NOT_MOUNTED:
        // 应用已加载但未挂载,且当前 URL 匹配 → 需要挂载
        if (!appChangeUnderway && appShouldBeActive) {
          appsToMount.push(app);
        }
        break;

      case MOUNTED:
        // 应用已挂载,但当前 URL 不匹配 → 需要卸载
        if (!appShouldBeActive) {
          appsToUnmount.push(app);
        }
        break;

      // ... 其他状态的处理
    }
  });

  return { appsToLoad, appsToMount, appsToUnmount, appsToUnload };
}

/**
 * 判断应用是否应该在当前 URL 下激活
 */
function shouldBeActive(app: Application): boolean {
  try {
    return app.activeWhen(window.location);
  } catch (err) {
    handleAppError(err, app);
    return false;
  }
}

这段逻辑看似简单,但它揭示了 single-spa 应用状态机的核心设计:应用不是简单的”加载”和”未加载”两种状态,而是有一个完整的生命周期状态机。

NOT_LOADED → LOADING_SOURCE_CODE → NOT_BOOTSTRAPPED → NOT_MOUNTED ⇆ MOUNTED


                                                      UNLOADING → NOT_LOADED

8.3.3 performAppChanges:应用切换的编排

编排”(orchestration)这个词很有意味——它来自交响乐团的指挥——一个指挥家不自己演奏任何乐器、但他让二十多种不同的乐器在正确的时间、正确的音量、正确的情绪下合奏出一首完整的曲子performAppChanges 就是 single-spa 的指挥家——它不亲自 mount 任何子应用、不亲自 unmount 任何子应用、但它让这些动作按照正确的顺序发生先卸载旧的、再加载新的、再初始化、再挂载——期间还要处理并发冲突、错误恢复、事件通知这种”不做具体工作、只协调别人工作”的模式、是所有调度类系统的本质——k8s 的 scheduler、操作系统的进程调度器、数据库的查询优化器、都是这种角色当你下次设计一个需要协调多个异步操作的系统时、记住这个”指挥家 vs 乐手”的区分——把具体工作推给”乐手”(具体的生命周期函数)、让”指挥家”(编排函数)只关心时序和协调——你会得到一个更清晰的架构

performAppChanges 是 reroute 的核心执行逻辑——它按正确的顺序执行应用的卸载、加载和挂载:

async function performAppChanges(): Promise<void> {
  // 派发自定义事件,通知外部"路由切换开始"
  window.dispatchEvent(
    new CustomEvent('single-spa:before-routing-event', {
      detail: {
        appsByNewStatus: {
          MOUNTED: appsToMount.map((app) => app.name),
          NOT_MOUNTED: appsToUnmount.map((app) => app.name),
        },
        newUrl: window.location.href,
        oldUrl: lastUrl,
      },
    })
  );

  try {
    // 阶段 1:并行执行卸载和加载
    // 卸载不再匹配的应用 && 加载新匹配的应用可以同时进行
    // 这是一个重要的性能优化

    // 1a. 卸载需要卸载的应用
    const unmountPromises = appsToUnmount.map((app) => {
      return tryToUnmountApp(app);
    });

    // 1b. 同时加载需要加载的应用(如果还没加载)
    // 注意:加载完成后还需要 bootstrap
    const loadAndMountPromises = appsToLoad.map(async (app) => {
      // 加载应用代码
      await tryToLoadApp(app);

      // 加载完成后再次检查:此应用是否仍然应该被激活?
      // 因为在异步加载期间,URL 可能又发生了变化
      if (shouldBeActive(app)) {
        // 执行 bootstrap
        await tryToBootstrapApp(app);
        // 等所有卸载完成后才挂载(确保 DOM 容器已清理)
        await Promise.all(unmountPromises);
        // 执行 mount
        await tryToMountApp(app);
      }
    });

    // 1c. 对已加载但未挂载的应用直接挂载
    const mountPromises = appsToMount
      .filter((app) => !appsToLoad.includes(app))
      .map(async (app) => {
        // 同样需要等卸载完成
        await Promise.all(unmountPromises);
        await tryToBootstrapApp(app);
        await tryToMountApp(app);
      });

    // 等待所有操作完成
    await Promise.all([
      ...unmountPromises,
      ...loadAndMountPromises,
      ...mountPromises,
    ]);

    // 阶段 2:处理 unload 队列中的应用
    const unloadPromises = appsToUnload.map((app) => {
      return tryToUnloadApp(app);
    });
    await Promise.all(unloadPromises);

  } finally {
    // 无论成功还是失败,都需要:
    // 1. 标记应用切换已完成
    appChangeUnderway = false;

    // 2. 触发被截获的路由事件监听器
    callCapturedEventListeners(eventArguments);

    // 3. 派发路由切换完成事件
    window.dispatchEvent(
      new CustomEvent('single-spa:routing-event', {
        detail: {
          appsByNewStatus: getAppStatusesByName(),
        },
      })
    );

    // 4. 检查等待队列中是否有待处理的路由变化
    // 如果在本次切换期间有新的路由变化,递归处理
    if (peopleWaitingOnAppChange.length > 0) {
      const nextPending = peopleWaitingOnAppChange;
      peopleWaitingOnAppChange = [];

      // 递归调用 reroute 处理下一个待处理的路由变化
      reroute(nextPending);
    }
  }
}

这段代码中有几个值得深入分析的设计决策:

并发控制:串行化路由变化。 appChangeUnderway 标志位确保同一时间只有一次应用切换在进行。新的路由变化被排入队列,等当前切换完成后再处理。这避免了并发修改 DOM 带来的竞态条件。

二次检查:加载后再次验证。 在异步加载应用代码期间,用户可能已经又导航到了别的页面。shouldBeActive(app) 的二次检查防止了”加载了但已经不需要挂载”的浪费。

卸载先于挂载。 挂载新应用之前必须等待旧应用卸载完成。这不仅是因为 DOM 容器可能被复用,更是因为旧应用可能持有事件监听器、定时器等资源,必须先清理干净。

8.3.4 生命周期的调用保证

single-spa 对每个生命周期函数的调用提供了严格的保证:

/**
 * 尝试挂载应用
 * 包含超时控制和错误处理
 */
async function tryToMountApp(app: Application): Promise<void> {
  if (shouldBeActive(app)) {
    try {
      // 调用子应用导出的 mount 函数
      // 附带超时控制(默认无超时,可配置)
      await reasonableTime(
        app,
        app.mount({
          name: app.name,
          singleSpa,
          mountParcel: mountParcel.bind(app),
          // 传递用户在 registerApplication 时提供的自定义 props
          ...app.customProps,
        }),
        'mount',
        app.timeouts.mount
      );

      // mount 成功,更新应用状态
      setAppStatus(app, MOUNTED);

    } catch (err) {
      // mount 失败,不是简单地抛出错误
      // 而是将应用标记为 SKIP_BECAUSE_BROKEN
      // 避免后续的 reroute 反复尝试挂载一个已知会失败的应用
      setAppStatus(app, SKIP_BECAUSE_BROKEN);
      handleAppError(err, app, SKIP_BECAUSE_BROKEN);
    }
  }
}

/**
 * 超时控制包装器
 * 确保生命周期函数不会无限期挂起
 */
function reasonableTime<T>(
  app: Application,
  promise: Promise<T>,
  description: string,
  timeout?: {
    millis: number;
    dieOnTimeout: boolean;
    warningMillis?: number;
  }
): Promise<T> {
  if (!timeout) return promise;

  const { millis, dieOnTimeout, warningMillis } = timeout;

  return new Promise((resolve, reject) => {
    let finished = false;

    // 设置警告定时器
    if (warningMillis) {
      setTimeout(() => {
        if (!finished) {
          console.warn(
            `single-spa: ${app.name}'s ${description} lifecycle ` +
            `has not resolved or rejected for ${warningMillis}ms`
          );
        }
      }, warningMillis);
    }

    // 设置超时定时器
    const timeoutId = setTimeout(() => {
      if (!finished) {
        const error = new Error(
          `${description} lifecycle for ${app.name} timed out after ${millis}ms`
        );
        if (dieOnTimeout) {
          reject(error);
        } else {
          console.error(error);
          // 不 reject,让它继续等待
        }
      }
    }, millis);

    promise
      .then((val) => {
        finished = true;
        clearTimeout(timeoutId);
        resolve(val);
      })
      .catch((err) => {
        finished = true;
        clearTimeout(timeoutId);
        reject(err);
      });
  });
}

8.3.5 快速连续导航的处理

快速连续导航是”用户实际行为”和”系统理想假设”之间的冲突——系统理想地假设”一次导航完成后用户才会发起下一次导航”、但真实用户会在焦急的时候连续点击多次如果框架对这种场景没有防护、就会出现”加载到一半被打断、旧子应用没完全卸载、新子应用挂载失败”的混乱single-spa 的解决方案是”队列化 + 二次检查”——同一时刻只执行一个 reroute、新的导航排队等待;每次生命周期函数执行前都再次检查”当前 URL 是否还和最初触发时一致”、如果已经变了就跳过这个操作。**这种”防御性编程”、在任何处理用户异步操作的系统里都是必备的——搜索框的防抖、按钮的提交防重、队列的幂等性设计——本质上都是同一种思想的具体实现真实的用户永远比你想象的更”不理性”、真实的网络永远比你测试的更”不可靠”——承认这两个事实、并在代码里相应地做好防护、就是”生产级代码”和”demo 代码”的根本差别。demo 代码在理想条件下跑通就算胜利、生产代码要在最糟糕的条件下依然保持尊严。

时间轴:
0ms    → 点击"订单"  → pushState('/order')
80ms   → 点击"商品"  → pushState('/product')
160ms  → 点击"用户"  → pushState('/user')

single-spa 的处理流程:

// 第一次点击:/order
// appChangeUnderway = false → 立即执行 reroute()
// appChangeUnderway = true

// 第二次点击(80ms 后):/product
// appChangeUnderway = true → 加入 peopleWaitingOnAppChange 队列
// 此时第一次的 reroute 还在执行(可能正在加载订单子应用)

// 第三次点击(160ms 后):/user
// appChangeUnderway = true → 再次加入队列

// 第一次 reroute 完成后:
// 1. appChangeUnderway = false
// 2. 处理 peopleWaitingOnAppChange 队列
// 3. 递归调用 reroute()
// 4. 此时 URL 已经是 /user
// 5. getAppChanges() 基于当前 URL(/user)计算
// 6. 订单子应用被卸载(刚挂载就卸载),用户子应用被加载和挂载
// 7. 商品子应用从未被挂载(被跳过了)

// 关键优化:中间状态(/product)被自然跳过
// single-spa 不会白白加载商品子应用——因为队列处理时 URL 已经不是 /product 了

这种”最终一致”的处理策略确保了即使面对快速连续的导航,系统也不会进入混乱状态。中间的过渡 URL 会被自然跳过,只有用户最终停留的 URL 才会生效。

深度洞察:队列化 vs 取消化

single-spa 选择了”队列化”策略而非”取消化”策略。另一种可行的设计是:当新的路由变化到来时,取消当前正在执行的 reroute,立即开始新的 reroute。这在理论上更高效——不需要等待一个即将被卸载的应用完成挂载。但取消化策略的问题是:子应用的生命周期函数可能有副作用(比如 mount 中向服务端注册了一个 WebSocket 连接),打断一个执行到一半的生命周期可能导致资源泄漏。队列化策略虽然稍慢,但保证了每个生命周期都能完整执行。这又是一个安全性 vs 性能的经典权衡。

8.4 与 React Router / Vue Router 的共存策略

8.4.1 核心矛盾:谁是路由的真正主人

这个小节标题把本节要讨论的问题戳中了要害——当多个路由系统(single-spa + React Router + Vue Router)共存于同一个浏览器 Tab 时、究竟谁说了算? 这个问题在微前端落地过程中、导致过无数生产事故最常见的表现是子应用开发者写了一个 <Link to="/order/list">、点击后页面没反应——因为 single-spa 的 basename 和 React Router 的 basename 没对齐、导致点击后的 URL 被 single-spa 判断为”不属于任何激活的子应用”、直接触发了应用卸载**。或者相反点击 <Link> 后页面跳转了、但 single-spa 没感知到、导致子应用状态没切换、路由显示和实际状态不一致这些 bug 的根源、都在于”多层路由系统的职责边界没划清楚”——谁负责哪一段 URL、谁负责哪种事件、没有明确的契约解决这类问题的关键不是”学会某个具体配置”、而是建立”分层路由”的心智模型——这也是为什么本节会反复强调职责划分

在微前端架构中,存在两层路由系统:

┌──────────────────────────────────────────────────────┐
│                     浏览器 URL                        │
│  https://app.example.com/product/detail/42?tab=spec  │
└─────────────────────────┬────────────────────────────┘

          ┌───────────────┴───────────────┐
          │     single-spa 路由层          │
          │  负责:匹配 /product → 商品    │
          │        子应用应该激活          │
          └───────────────┬───────────────┘

          ┌───────────────┴───────────────┐
          │  子应用路由层(React Router)   │
          │  负责:匹配 /product/detail/42 │
          │        → 渲染 Detail 组件     │
          │        ?tab=spec              │
          │        → 激活 Spec 标签页      │
          └───────────────────────────────┘

两层路由各司其职:single-spa 只关心 URL 的”前缀”部分来决定激活哪个子应用;子应用的 Router 关心完整的 URL 路径来决定渲染哪个页面组件。

但它们共享同一个 window.location 和同一个 History API——这就是矛盾的根源。

8.4.2 React Router 的共存方案

React Router 是 React 生态里最主流的路由库、它也是最早在微前端场景下被验证过”可以和 single-spa 共存”的成熟方案这种”可以共存”的能力、并非偶然——React Router 从 v4 开始采用”基于组件的路由”范式、让路由不再是”全局单例”、而是”组件树里的一个组件这个看似微小的架构选择、赋予了 React Router 极强的嵌套能力——一个 React Router 可以嵌在另一个 React Router 里、共享同一个 history、互不干扰这正好和微前端的需求匹配——主应用的 Router 和子应用的 Router、逻辑上是嵌套关系、物理上共享 window.history配合 basename 配置划定作用域边界、两层 Router 就能和谐共处这种”组件化路由”的设计、在 Vue Router、Solid Router、Remix 里都能看到相似思路——它已经成为现代前端路由的事实标准

React Router(v6+)支持 basename 配置,这是与 single-spa 共存的关键:

// 子应用注册(主应用中)
import { registerApplication, start } from 'single-spa';

registerApplication({
  name: 'product-app',
  app: () => System.import('@myorg/product-app'),
  // single-spa 只匹配前缀
  activeWhen: (location) => location.pathname.startsWith('/product'),
  customProps: {
    basename: '/product',
  },
});

start();
// 商品子应用的入口 - React + React Router v6
import React from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

let root: ReturnType<typeof createRoot> | null = null;

// single-spa 生命周期:挂载
export async function mount(props: { basename: string; container?: HTMLElement }) {
  const { basename, container } = props;

  const mountPoint = container || document.getElementById('product-app-container')!;

  root = createRoot(mountPoint);
  root.render(
    <BrowserRouter basename={basename}>
      <Routes>
        {/* 这里的路径是相对于 basename 的 */}
        {/* /product/list → 匹配 /list */}
        <Route path="/list" element={<ProductList />} />
        {/* /product/detail/42 → 匹配 /detail/:id */}
        <Route path="/detail/:id" element={<ProductDetail />} />
        {/* /product → 匹配 / */}
        <Route path="/" element={<Navigate to="/list" replace />} />
      </Routes>
    </BrowserRouter>
  );
}

// single-spa 生命周期:卸载
export async function unmount(props: { container?: HTMLElement }) {
  if (root) {
    root.unmount();
    root = null;
  }
}

// single-spa 生命周期:启动
export async function bootstrap() {
  // 可以在这里做一次性初始化
}

basename 的作用是告诉 React Router:“你只需要关心 /product 之后的部分。” 当 URL 是 /product/detail/42 时,React Router 看到的路径是 /detail/42,正好匹配 /detail/:id 路由。

8.4.3 Vue Router 的共存方案

Vue Router 的 base 选项、和 React Router 的 basename、本质上是同一种思想的两种实现——它们都在做”把路径空间分片”的事情这种”不同命名的相同思想”、在前端生态里俯拾皆是——React 的 state 和 Vue 的 data、React 的 key 和 Vue 的 ref、React 的 useEffect 和 Vue 的 watchEffect、React 的 Context 和 Vue 的 provide/inject——如果你学会了其中一套、理解另一套只需要做一次”概念映射这种”框架差异只是表象、底层思想高度共通”的洞察、让一个熟练的 React 工程师、很快就能上手 Vue、反之亦然所以不要被某个具体框架的 API 绑住——学会”用概念思考、而不是用 API 思考”、你就能在任何前端生态里自由穿梭

Vue Router 的配置思路类似,通过 base 选项实现路由前缀隔离:

// 商品子应用的入口 - Vue 3 + Vue Router 4
import { createApp, App as VueApp } from 'vue';
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
import RootComponent from './App.vue';

let app: VueApp | null = null;
let router: ReturnType<typeof createRouter> | null = null;

const routes: RouteRecordRaw[] = [
  { path: '/list', component: () => import('./views/ProductList.vue') },
  { path: '/detail/:id', component: () => import('./views/ProductDetail.vue') },
  { path: '/', redirect: '/list' },
];

export async function mount(props: { basename: string; container?: HTMLElement }) {
  const { basename, container } = props;

  // 创建 Router 实例,使用 basename 作为 base
  router = createRouter({
    // createWebHistory 接受 base 参数
    history: createWebHistory(basename),
    routes,
  });

  app = createApp(RootComponent);
  app.use(router);

  const mountPoint = container || document.getElementById('product-app-container')!;
  app.mount(mountPoint);
}

export async function unmount() {
  if (app) {
    app.unmount();
    app = null;
    router = null;
  }
}

export async function bootstrap() {
  // 一次性初始化
}

8.4.4 跨应用导航的实现

跨应用导航”是微前端里最容易出 bug 的地方——因为它涉及”从一个路由作用域跳到另一个路由作用域”、而每一个子应用的 Router 都只知道自己的作用域当用户在订单子应用里点击”前往商品详情”的链接时——订单应用的 React Router 会试图在自己的路由表里找 /product/detail/42、找不到就会跳到 404;即使它聪明地 fallback 到 window.history.pushState、也可能因为 single-spa 的事件监听顺序问题、导致路由变化被重复处理这种 bug 的解决之道、是建立”跨应用导航只走 window.history API”这个约束——不要让子应用的 Router 去处理跨应用跳转、而是让它们都统一走浏览器原生 API、由 single-spa 来裁决”这次跳转应该激活哪个应用”**。**这种”跨边界的事情交给更高层的调度器”的原则、在操作系统、分布式系统、组织管理里都适用——永远不要让一个组件超出它的职责边界去做跨边界的决策

子应用之间的导航需要特别注意——子应用的 Router 只能管理自己路由前缀下的导航。跨应用导航需要通过 history.pushState 触发,让 single-spa 接管:

// ❌ 错误方式:在商品子应用中用 React Router 导航到订单页
// React Router 只知道 /product/* 路由,/order/list 对它来说是未知路由
import { useNavigate } from 'react-router-dom';

function ProductDetail() {
  const navigate = useNavigate();
  return (
    <button onClick={() => navigate('/order/list')}>
      {/* 这不会触发 single-spa 的应用切换! */}
      查看相关订单
    </button>
  );
}

// ✅ 正确方式:使用 single-spa 提供的导航 API
import { navigateToUrl } from 'single-spa';

function ProductDetail() {
  return (
    <button onClick={() => navigateToUrl('/order/list')}>
      查看相关订单
    </button>
  );
}

// navigateToUrl 的实现非常简单
export function navigateToUrl(url: string): void {
  // 解析 URL
  const parsed = parseUri(url);
  const currentUrl = window.location.href;

  if (url.indexOf('#') === 0) {
    // hash-only 的 URL
    window.location.hash = url;
  } else {
    // 使用(已被 monkey-patch 的)pushState
    // 这会触发整个路由拦截链路
    window.history.pushState(null, '', url);
  }
}

还有一种更优雅的封装方式——创建一个跨框架的导航 Hook / Composable:

// shared/navigation.ts
// 跨框架的导航工具

import { navigateToUrl } from 'single-spa';

/**
 * 判断目标 URL 是否属于当前子应用
 */
function isInternalNavigation(targetUrl: string, basename: string): boolean {
  const path = new URL(targetUrl, window.location.origin).pathname;
  return path.startsWith(basename);
}

/**
 * 智能导航:自动判断使用子应用 Router 还是 single-spa
 */
export function createSmartNavigate(basename: string) {
  return function smartNavigate(
    targetUrl: string,
    internalNavigate: (path: string) => void
  ): void {
    if (isInternalNavigation(targetUrl, basename)) {
      // 应用内导航:使用子应用自己的 Router
      const internalPath = targetUrl.replace(basename, '') || '/';
      internalNavigate(internalPath);
    } else {
      // 跨应用导航:使用 single-spa
      navigateToUrl(targetUrl);
    }
  };
}

// React 子应用中使用
// hooks/useSmartNavigate.ts
import { useNavigate } from 'react-router-dom';
import { createSmartNavigate } from '@myorg/shared-navigation';

export function useSmartNavigate(basename: string) {
  const navigate = useNavigate();
  const smartNavigate = createSmartNavigate(basename);

  return (targetUrl: string) => {
    smartNavigate(targetUrl, (path) => navigate(path));
  };
}

8.4.5 路由状态同步的陷阱

状态同步”类的 bug、是分布式系统领域的经典难题——两个独立的状态源、在相互更新的过程中出现不一致微前端的”浏览器 URL + 子应用内部路由状态”、本质上就是一个小型的分布式系统——两个状态源(URL 和 Router 内部状态)之间的一致性、需要通过双向同步来保证但双向同步最容易出现”时序错乱——比如异步操作的顺序被打乱、事件传播的路径出现环路、快速操作导致中间状态被跳过single-spa 的路由同步陷阱、都是这类经典分布式问题在前端的具体化。**理解这些陷阱的根本原因、是要意识到——当你的系统里有多个状态源时、必须明确定义”权威源(source of truth)“——比如”浏览器 URL 是权威、子应用路由状态是副本”——然后所有同步逻辑都围绕这个权威源展开没有明确权威源的状态同步、迟早会进入”谁先改、谁说了算”的混乱

在实际项目中,最容易踩的坑是路由状态不同步。以下是几个典型场景和解决方案:

陷阱一:子应用挂载时 URL 已经变了

// 问题场景:
// 1. single-spa 开始加载子应用 A(异步操作,耗时 500ms)
// 2. 加载过程中用户又点了导航,URL 变了
// 3. 子应用 A 加载完成,mount 被调用
// 4. 但此时 URL 已经不匹配了

// single-spa 的保护机制(前面提到的二次检查)
// 在 tryToMountApp 中会再次调用 shouldBeActive(app)
// 如果返回 false,不会执行 mount

陷阱二:子应用内部路由守卫与 single-spa 生命周期的冲突

// Vue Router 的路由守卫可能阻止导航
const router = createRouter({
  history: createWebHistory('/product'),
  routes,
});

router.beforeEach((to, from) => {
  if (!isAuthenticated()) {
    // 问题:这个守卫只能阻止 Vue Router 内部的导航
    // 无法阻止 single-spa 的应用切换
    // 如果用户通过浏览器前进/后退到达这个路由
    // Vue Router 的守卫可以阻止页面渲染
    // 但 single-spa 仍然认为这个应用已经挂载
    return '/login';
  }
});

// 解决方案:在 single-spa 的 activity function 中也加入认证检查
registerApplication({
  name: 'product-app',
  app: () => System.import('@myorg/product-app'),
  activeWhen: (location) => {
    // 在 activeWhen 中检查认证状态
    if (!isAuthenticated() && location.pathname.startsWith('/product')) {
      // 重定向到登录页
      // 注意:不能在这里调用 navigateToUrl,会导致无限循环
      // 应该返回 false,让主应用的路由处理重定向
      return false;
    }
    return location.pathname.startsWith('/product');
  },
});

陷阱三:子应用卸载后的异步回调

// React 子应用中的常见问题
function ProductList() {
  const [products, setProducts] = useState<Product[]>([]);

  useEffect(() => {
    // 发起 API 请求
    fetchProducts().then((data) => {
      // 危险!如果在请求期间子应用被卸载了
      // 这个 setProducts 会触发 React 警告
      // "Can't perform a React state update on an unmounted component"
      setProducts(data);
    });
  }, []);

  // ...
}

// 解决方案:使用 AbortController 或 cleanup
function ProductList() {
  const [products, setProducts] = useState<Product[]>([]);

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

    fetchProducts({ signal: controller.signal })
      .then((data) => setProducts(data))
      .catch((err) => {
        if (err.name !== 'AbortError') {
          console.error('Failed to fetch products:', err);
        }
      });

    // 子应用卸载时(unmount → React root.unmount() → cleanup 执行)
    return () => controller.abort();
  }, []);

  // ...
}

8.4.6 主应用路由的最佳实践

主应用”(shell)在整个微前端体系里的定位、类似于操作系统里的 init 进程或浏览器里的 chrome——它是第一个启动的、也是最后一个退出的、负责管理所有其他参与方的生命周期但主应用本身也有 UI 需求——登录页、全局导航、404 兜底、通用 Toast——这些页面逻辑、应该由谁负责? 如果让 single-spa 来管、那主应用就得是一个”被 single-spa 调度的子应用”——但那样 single-spa 就需要有引导逻辑、违背了它”不做 UI”的设计哲学如果让主应用自己管、就需要主应用有自己的 Router——但这又可能和 single-spa 的路由系统冲突。**实践证明的答案是——主应用也用 Router(React Router 或 Vue Router)、但主应用的 Router 只处理”non-子应用”的路径(登录页、全局错误页)、把所有”子应用应该接管的路径”都用 /* 通配符兜底给 MicroAppContainer 组件——这个组件什么都不做、只是给 single-spa 提供一个 DOM 节点、让它可以在里面挂载子应用

主应用(通常被称为”容器应用”或”shell”)自身也可以有路由需求——比如登录页、404 页面、全局布局切换。但主应用的路由必须与 single-spa 的路由拦截和平共处:

// 主应用(Shell)的路由配置 - React 示例
import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom';

function ShellApp() {
  return (
    <BrowserRouter>
      <Routes>
        {/* 主应用自己管理的路由 */}
        <Route path="/login" element={<LoginPage />} />
        <Route path="/403" element={<ForbiddenPage />} />
        <Route path="/404" element={<NotFoundPage />} />

        {/* 带布局的路由 */}
        <Route element={<MainLayout />}>
          {/* 子应用挂载点 */}
          {/* 使用通配符让 React Router 不要拦截子应用的路径 */}
          <Route path="/*" element={<MicroAppContainer />} />
        </Route>
      </Routes>
    </BrowserRouter>
  );
}

/**
 * 子应用的挂载容器
 * 它只负责提供 DOM 节点,不参与子应用的路由决策
 */
function MicroAppContainer() {
  return (
    <div id="micro-app-container">
      {/* 子应用会被 single-spa 挂载到这个容器中 */}
      {/* 这个组件本身不做任何事,只是占位 */}
    </div>
  );
}

/**
 * 主布局:导航栏 + 子应用区域
 */
function MainLayout() {
  return (
    <div className="shell-layout">
      <nav className="shell-nav">
        <NavLink to="/order/list">订单管理</NavLink>
        <NavLink to="/product/list">商品管理</NavLink>
        <NavLink to="/user/profile">用户中心</NavLink>
      </nav>
      <main className="shell-content">
        <Outlet />
      </main>
    </div>
  );
}

这里有一个微妙但关键的设计原则:主应用的路由应该尽可能”薄”。主应用只管理那些不属于任何子应用的页面(登录、404 等),以及全局布局。子应用挂载区域用一个通配符路由 /* 匹配,把路由决策权交给 single-spa。

主应用薄化”这个原则的深层含义、是”容器不抢戏——主应用作为所有子应用的宿主、它承担的是”舞台”角色、不应该去抢”演员”的戏份如果主应用在自己的路由层处理了太多业务逻辑(比如商品详情、订单列表、用户中心)、那它就变成了一个”和子应用职责重叠”的巨无霸、失去了微前端的价值理想的主应用应该像”机场”——它提供跑道、登机口、行李系统、安检流程(全局能力)、但它不经营任何一家航空公司(业务应用)这种”基础设施与业务分离”的原则、在微服务的 API Gateway 设计、在 Kubernetes 的 Pod 和 Service 分层、在 Android 的系统应用与第三方应用分层里、都有同样的影子

8.4.7 完整的路由分层架构

让我们把所有的知识串联起来,形成一个完整的路由分层架构图:

┌──────────────────────────────────────────────────────────┐
│                        浏览器层                           │
│  history.pushState / replaceState / popstate / hashchange │
└────────────────────────────┬─────────────────────────────┘

┌────────────────────────────┴─────────────────────────────┐
│                   single-spa 拦截层                       │
│                                                          │
│  ┌──────────────────┐  ┌──────────────────────────────┐  │
│  │ monkey-patched   │  │ 拦截 addEventListener        │  │
│  │ pushState /      │  │ 截获子应用注册的              │  │
│  │ replaceState     │  │ popstate / hashchange 监听器  │  │
│  └────────┬─────────┘  └──────────────┬───────────────┘  │
│           │                           │                  │
│           └───────────┬───────────────┘                  │
│                       ▼                                  │
│              ┌─────────────────┐                         │
│              │    urlReroute   │                         │
│              └────────┬────────┘                         │
│                       ▼                                  │
│              ┌─────────────────┐                         │
│              │     reroute     │                         │
│              │  ┌───────────┐  │                         │
│              │  │getAppChgs │  │                         │
│              │  │performApp │  │                         │
│              │  │Changes    │  │                         │
│              │  └───────────┘  │                         │
│              └────────┬────────┘                         │
│                       │                                  │
│                       ▼                                  │
│          callCapturedEventListeners                      │
│          (延迟触发子应用的路由监听器)                      │
└────────────────────────┬─────────────────────────────────┘

          ┌──────────────┼──────────────┐
          ▼              ▼              ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│  React Router │ │  Vue Router  │ │ Angular      │
│  basename:    │ │  base:       │ │ Router       │
│  /order       │ │  /product    │ │ base-href:   │
│               │ │              │ │ /admin       │
│  只处理       │ │  只处理       │ │  只处理       │
│  /order/*     │ │  /product/*  │ │  /admin/*    │
└──────────────┘ └──────────────┘ └──────────────┘

这个分层架构的优雅之处在于:每一层只关心自己的职责,且层与层之间的交互通过标准的浏览器 API(popstate 事件、window.location)进行。子应用的 Router 甚至不知道 single-spa 的存在——它以为自己直接和浏览器打交道,但实际上所有的路由事件都经过了 single-spa 的过滤和编排。

下一章进入 Module Federation,那是一次完全不同的范式——不再是运行时拦截,而是编译时共享;不再是”独立应用组合”,而是”模块跨工程引用”。

深度洞察:透明代理模式

single-spa 的路由拦截实质上是一个透明代理(Transparent Proxy)。子应用的路由框架通过标准的浏览器 API 与 single-spa 交互,但它们并不知道中间存在一个代理层。这种设计的最大优势是:子应用不需要为微前端做任何特殊适配——相同的代码既可以独立运行,也可以作为微前端子应用运行。你只需要在入口文件中导出 single-spa 生命周期函数,内部的路由逻辑完全不需要修改。这种”对子应用透明”的设计理念,是 single-spa 能够成为微前端事实标准的根本原因之一。


思考题

  1. Navigation API 的影响:Chrome 102+ 支持了新的 Navigation API(navigation.addEventListener('navigate', ...)),它能原生监听所有导航事件(包括 pushState),理论上不再需要 monkey-patch。如果 single-spa 要迁移到 Navigation API,路由拦截机制需要做哪些改变?这种迁移的主要障碍是什么?

  2. 事件拦截的边界:假设一个第三方统计 SDK 在 single-spa 加载之前就通过 window.addEventListener('popstate', tracker) 注册了路由追踪监听器。这个监听器会被 single-spa 截获吗?为什么?如果不会,这会导致什么问题?

  3. 并发路由变化的优化:当前 single-spa 使用”队列化”策略处理快速连续导航——前一次 reroute 完成后才处理下一次。请设计一种”可取消”的 reroute 策略:如果新的路由变化到来时,当前正在执行的应用生命周期可以被安全取消。你需要考虑哪些边界条件?如何保证生命周期的完整性?

  4. 多层嵌套的路由冲突:假设主应用使用 React Router,子应用 A 也使用 React Router,而子应用 A 又通过 single-spa Parcel 嵌入了一个使用 Vue Router 的微组件。在这种三层嵌套的场景下,路由事件会如何传播?可能出现哪些冲突?如何设计一套规范来避免这些问题?

  5. 调试技巧:在生产环境中遇到”点击导航后子应用没有切换”的问题,你会如何利用本章所学的知识进行排查?请列出你会检查的五个关键点,并解释每个检查点的原理。