微前端源码精讲

第8章 single-spa 的路由拦截

作者 杨艺韬 · 12,213 字 · 发布于 · 更新于

第8章 single-spa 的路由拦截

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

本章要点

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

本章 single-spa 源码以 v6.0.3 为准(src/navigation/navigation-events.js、src/navigation/reroute.js、src/applications/apps.js、src/applications/timeouts.js),代码片段删去了 __PROFILE__ 埋点、错误码包装和测试环境分支。qiankun 2.x 依赖的是 single-spa 5.x,两者的一个重要差别是 History API 打补丁的时机(见 8.1.3)。

打开浏览器的开发者工具,在控制台输入 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 事件只在历史遍历(用户点击前进/后退、脚本调用 history.back() / go())和 hash 变化时触发,而绝大多数 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 对 pushState 和 replaceState 进行 monkey-patch 的核心代码:

// single-spa/src/navigation/navigation-events.js(精简)

let urlRerouteOnly;                     // 由 start({ urlRerouteOnly }) 传入,默认 true
export let originalReplaceState = null; // 导出给 reroute.js:取消导航时用它静默回退 URL


function patchedUpdateState(updateState, methodName) {
  return function () {
    // 记录 URL 变化前的状态
    const urlBefore = window.location.href;

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

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

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

    return result;
  };
}

// 创建模拟的 PopStateEvent
function createPopStateEvent(state, originalMethodName) {
  let evt;
  try {
    evt = new PopStateEvent("popstate", { state });
  } catch (err) {
    // IE 11 兼容
    evt = document.createEvent("PopStateEvent");
    evt.initPopStateEvent("popstate", false, false, state);
  }

  // 在事件对象上标记触发来源
  // 它让监听 popstate 的代码能够区分
  // "真正的浏览器前进/后退" 和 "pushState/replaceState 触发的模拟事件"
  evt.singleSpa = true;
  evt.singleSpaTrigger = originalMethodName; // "pushState" 或 "replaceState"
  return evt;
}

// 替换全局方法(位于 patchHistoryApi 内部,见 8.1.3)
originalReplaceState = window.history.replaceState;
window.history.pushState = patchedUpdateState(window.history.pushState, "pushState");
window.history.replaceState = patchedUpdateState(originalReplaceState, "replaceState");

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

决策一:默认只在 URL 真正变化时触发事件。 replaceState 经常被用来更新 state 对象但不改变 URL。如果每次调用都触发路由重评估,会带来不必要的开销。这个行为由 start() 的 urlRerouteOnly 选项控制(v5.2.0 引入,v6 起默认为 true);设成 false 就退回"每次调用都派发事件"。

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

决策三:在事件对象上打标记。 通过 evt.singleSpa = true 和 evt.singleSpaTrigger,任何监听 popstate 的代码都能区分"真实的浏览器前进/后退"和"某个应用调用了 pushState / replaceState"。single-spa 自己的 urlReroute 并不区分这两种来源——两种都走同一条 reroute 路径;这个标记主要是给子应用的路由库和调试工具用的(single-spa-angular 等就依赖它)。

8.1.3 monkey-patch 的执行时机

一个容易被忽视的细节是 monkey-patch 的执行时机——它在 5.x 和 6.x 之间发生过变化。5.x 里这段代码写在 navigation-events.js 的模块顶层,import 'single-spa' 的那一刻 History API 就被替换了;6.0.0 起,它被收进了 patchHistoryApi() 函数,由 start() 调用:

// src/start.js:7
export function start(opts) {
  started = true;
  if (isInBrowser) {
    patchHistoryApi(opts); // 到这一步才替换 History API、注册路由监听
    reroute();
  }
}

// src/navigation/navigation-events.js
let historyApiIsPatched = false;

export function patchHistoryApi(opts) {
  if (historyApiIsPatched) {
    throw Error(/* #43 patchHistoryApi() was called after the history api was already patched */);
  }
  urlRerouteOnly = opts && opts.hasOwnProperty("urlRerouteOnly") ? opts.urlRerouteOnly : true;
  historyApiIsPatched = true;
  originalReplaceState = window.history.replaceState;

  // 注册 single-spa 自己的路由监听(用的是尚未被劫持的原生 addEventListener)
  window.addEventListener("hashchange", urlReroute);
  window.addEventListener("popstate", urlReroute);

  // 劫持 addEventListener / removeEventListener(见 8.2.2)
  // ...

  window.history.pushState = patchedUpdateState(window.history.pushState, "pushState");
  window.history.replaceState = patchedUpdateState(originalReplaceState, "replaceState");
}

patchHistoryApi 的完整实现见 src/navigation/navigation-events.js:134-195;它也作为公开 API 导出——如果你的根应用需要在 start() 之前就让 single-spa 接管路由(例如先注册应用、延迟很久才 start),可以提前手动调用它;重复调用会抛错。这意味着在 6.x 里,start() 之前发生的 pushState 不会触发 reroute——不过这并不会丢事件:start() 自己会立刻 reroute() 一次,按当时的 URL 重新算一遍该挂载谁。

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

对全局 API 进行 monkey-patch 是一个充满争议的技术决策。它违反了"不要修改你不拥有的对象"的编程原则,也可能与其他同样进行 monkey-patch 的库产生冲突。但在微前端的语境下,这几乎是唯一可行的方案——浏览器没有提供原生的 "navigation" 事件(注:Navigation API 2022 年才随 Chrome 102 落地,其他浏览器跟进更晚,直到 2026 年 1 月才在各主流浏览器的最新版里都可用,旧版浏览器仍不支持),而 single-spa 必须在所有路由变化发生时得到通知。这是一个权衡了现实约束的务实选择,而不是一个优雅的设计。理解这种权衡思维,对架构师而言比掌握具体实现更重要。

8.2 popstate / hashchange 的统一处理

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

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

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


function urlReroute() {
  reroute([], arguments); // 把原始事件参数带给 reroute,稍后回放给被截获的监听器
}

// 注册监听器(在 patchHistoryApi 内部)
window.addEventListener("hashchange", urlReroute);
window.addEventListener("popstate", urlReroute);

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

8.2.2 事件拦截与延迟触发

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

问题是这样的:当一次路由变化触发了子应用的切换(比如卸载 App A,加载并挂载 App B),如果 popstate 事件按浏览器的原生时序立刻分发,App A 的 Router 会在自己被卸载之前先收到一个"URL 已经变成 /product"的事件——它会拿一个不属于自己的 URL 去匹配路由表,轻则渲染 404,重则在 single-spa 正准备拆掉它的时候还在往 DOM 里写东西。

single-spa 的解法是:拦截应用注册的 popstate/hashchange 事件监听器,等 reroute 把该卸载的应用都卸载干净之后,再统一回放这些事件。

// single-spa/src/navigation/navigation-events.js(精简)

// 模块顶层:存储被拦截的事件监听器
const capturedEventListeners = {
  hashchange: [],
  popstate: [],
};

export const routingEventsListeningTo = ["hashchange", "popstate"];

// ——以下位于 patchHistoryApi() 内部,start() 时才执行(见 8.1.3)——

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

/**
 * 重写 window.addEventListener
 * 拦截对 popstate 和 hashchange 的监听注册
 */
window.addEventListener = function (eventName, fn) {
  if (typeof fn === "function") {
    if (
      routingEventsListeningTo.indexOf(eventName) >= 0 &&
      // 同一个函数不重复截获
      !find(capturedEventListeners[eventName], (listener) => listener === fn)
    ) {
      // 不调用原始的 addEventListener
      // 而是将监听器保存到 capturedEventListeners 中
      capturedEventListeners[eventName].push(fn);
      return;
    }
  }

  // 其他事件类型正常注册
  return originalAddEventListener.apply(this, arguments);
};

/**
 * 重写 window.removeEventListener
 * 同步维护 capturedEventListeners
 */
window.removeEventListener = function (eventName, listenerFn) {
  if (typeof listenerFn === "function") {
    if (routingEventsListeningTo.indexOf(eventName) >= 0) {
      capturedEventListeners[eventName] = capturedEventListeners[
        eventName
      ].filter((fn) => fn !== listenerFn);
    }
  }

  // 6.0.2 起总是继续调用原生 removeEventListener:
  // 万一这个监听器是在打补丁之前用原生方法注册的,也能被正确移除
  return originalRemoveEventListener.apply(this, arguments);
};

注意这里只截获函数形式的监听器;传对象({ handleEvent() {} })注册的 popstate 监听器不会被截获,会直接落到原生 addEventListener 上。这两个补丁位于 src/navigation/navigation-events.js:161-185;6.0.2 那处"总是继续调用原生 removeEventListener"的改动来自 single-spa PR #1227。

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

那这些被截获的监听器何时执行呢?答案在 reroute 里:performAppChanges 等 unmountAllPromise(所有卸载与 unload)完成之后、开始挂载新应用之前,调用 callAllEventListeners(),把排队期间积压的事件参数(先来的先回放)连同本次的事件参数一起交给 callCapturedEventListeners:

// src/navigation/navigation-events.js:64(中文注释为本专栏所加)
export function callCapturedEventListeners(eventArguments) {
  if (eventArguments) {
    const eventType = eventArguments[0].type;
    if (routingEventsListeningTo.indexOf(eventType) >= 0) {
      capturedEventListeners[eventType].forEach((listener) => {
        try {
          // The error thrown by application event listener should not break single-spa down.
          // 用原始的事件参数回放,监听器看不出自己是被延迟调用的
          listener.apply(this, eventArguments);
        } catch (e) {
          // 每个监听器各自包一层:一个抛错不影响其余监听器,也不打断框架本身
          setTimeout(() => {
            throw e;
          });
        }
      });
    }
  }
}

源码里的注释把意图说得很清楚:"既然需要卸载的应用都已卸载,它们的 DOM 导航事件监听(hashchange / popstate)也应该已经清理掉了,现在把剩下的被截获监听器放出来处理这个 DOM 事件是安全的。"也就是说,延迟的目的是让正在离场的应用收不到不属于它的路由事件,而不是等新应用挂载完再通知它——新应用的 Router 在 mount 时会直接读 window.location 初始化,本来就不依赖这个事件。

8.2.3 事件流的完整时序

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

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 的 urlReroute 捕获
    SingleSpa->>SingleSpa: getAppChanges() 分类
    par 卸载与加载并行
        SingleSpa->>OrderApp: toUnmountPromise() 卸载
        OrderApp-->>SingleSpa: unmount 完成(其 Router 已 removeEventListener)
    and
        SingleSpa->>ProductApp: toLoadPromise() → toBootstrapPromise()
        ProductApp-->>SingleSpa: 加载、bootstrap 完成
    end
    SingleSpa->>Captured: callCapturedEventListeners()(卸载全部完成后)
    Captured->>Router: 回放被拦截的 popstate 监听器(订单 Router 已不在名单里)
    SingleSpa->>ProductApp: toMountPromise() 挂载
    Note over ProductApp: 商品子应用的 Router 在 mount 时读取 window.location 初始化
    ProductApp-->>SingleSpa: mount 完成

让我们用一个具体的场景来理解完整的事件流。假设用户从 /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 生命周期
              │       │      └── 订单子应用的 Router 在此 removeEventListener('popstate')
              │       │          → 从 capturedEventListeners 中移除
              │       └── 加载 + bootstrap:拉取商品子应用的资源并初始化
              │
              ├── 7. 所有卸载完成 → callCapturedEventListeners(popstateEvent)
              │       └── 回放给仍然登记在册的监听器(主应用 Router、统计 SDK……)
              │           订单子应用的 handlePop 已经不在名单里,不会拿到不属于它的 URL
              │
              ├── 8. 执行挂载:调用商品子应用的 mount 生命周期
              │       └── 商品子应用的 React Router 此时初始化:
              │           直接读取 window.location(/product/detail/42),匹配 Detail 路由渲染
              │           并通过 window.addEventListener('popstate', handlePop)
              │           注册监听器 → 被 single-spa 截获,等下一次路由变化时回放
              │
              └── 9. reroute 完成:派发 single-spa:app-change / routing-event

这个时序保证了一个关键不变式:正在被卸载的应用,永远不会收到那个把它"赶下台"的路由事件;而新应用在 mount 时看到的一定是已经稳定的 URL。

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

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

8.2.4 hash 模式的特殊处理

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

需要注意的是,按 HTML 规范,改变 hash 会先后触发 popstate 和 hashchange 两个事件,而 single-spa 对两者都注册了 urlReroute——所以一次 hash 变化会调两次 reroute。single-spa 没有做 URL 比对去重,靠的是 reroute 自身的两道机制:

// src/navigation/reroute.js:39(精简)
export function reroute(pendingPromises = [], eventArguments, silentNavigation = false) {
  if (appChangeUnderway) {
    // 第一道:上一次 reroute 还没结束,本次排队,事件参数一并保存
    return new Promise((resolve, reject) => {
      peopleWaitingOnAppChange.push({ resolve, reject, eventArguments });
    });
  }
  // 第二道:getAppChanges() 只看应用当前状态与 URL 是否匹配——
  // 第二次进来时该挂的已挂、该卸的已卸,四个数组全空,
  // 结果不动任何应用,只派发 before-no-app-change / no-app-change 与 routing 系列事件
  const { appsToUnload, appsToUnmount, appsToLoad, appsToMount } = getAppChanges();
  // ...
}

被排队的那次并不会白跑:它的 eventArguments(那个 hashchange 事件)会在下一轮 callAllEventListeners 里按顺序回放给被截获的监听器,所以子应用里同时监听 popstate 和 hashchange 的路由库两个事件都能收到。

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(精简;源码是 JS,类型标注为本专栏所加)

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


export function reroute(
  pendingPromises: Array<any> = [],
  eventArguments?: IArguments | any[],
  silentNavigation = false
): Promise<string[]> {
  // 关键判断:是否有正在进行中的应用切换?
  if (appChangeUnderway) {
    // 如果是,将本次路由变化加入等待队列
    // 等当前切换完成后再处理
    return new Promise((resolve, reject) => {
      peopleWaitingOnAppChange.push({
        resolve,
        reject,
        eventArguments,
      });
    });
  }

  // 第一步:根据当前 URL 将所有已注册的应用分为四类
  const {
    appsToUnload,    // 已 unmount、且有人调用过 unloadApplication 的应用(NOT_MOUNTED → UNLOADING → NOT_LOADED)
    appsToUnmount,   // 需要卸载的应用(MOUNTED → UNMOUNTING → NOT_MOUNTED)
    appsToLoad,      // 需要加载的应用(NOT_LOADED / LOAD_ERROR → LOADING_SOURCE_CODE → NOT_BOOTSTRAPPED)
    appsToMount,     // 需要挂载的应用(NOT_BOOTSTRAPPED / NOT_MOUNTED → … → MOUNTED)
  } = getAppChanges();
  // 同时记录本次 reroute 的 oldUrl / newUrl,写进各个事件的 detail 里

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

  // ... performAppChanges 的实现见 8.3.3;loadApps 只做 load,再回放被截获的监听器
}

8.3.2 getAppChanges:应用分类算法

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

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

const apps = []; // registerApplication 推入的内部应用对象

export function getAppChanges() {
  const appsToUnload = [],
    appsToUnmount = [],
    appsToLoad = [],
    appsToMount = [];

  // LOAD_ERROR 的应用要隔 200ms 才允许重试下载
  const currentTime = new Date().getTime();

  apps.forEach((app) => {
    // shouldBeActive() 调用用户注册时提供的 activeWhen 函数
    // 传入当前的 window.location,返回 boolean;已 BROKEN 的应用一律视为不激活
    const appShouldBeActive =
      app.status !== SKIP_BECAUSE_BROKEN && shouldBeActive(app);

    switch (app.status) {
      case LOAD_ERROR:
        if (appShouldBeActive && currentTime - app.loadErrorTime >= 200) {
          appsToLoad.push(app);
        }
        break;
      case NOT_LOADED:
      case LOADING_SOURCE_CODE:
        // 应用还没加载,且当前 URL 匹配 → 需要加载
        if (appShouldBeActive) {
          appsToLoad.push(app);
        }
        break;
      case NOT_BOOTSTRAPPED:
      case NOT_MOUNTED:
        // 已加载未挂载:URL 不匹配且有人在等它 unload → 彻底卸载;URL 匹配 → 挂载
        if (!appShouldBeActive && getAppUnloadInfo(toName(app))) {
          appsToUnload.push(app);
        } else if (appShouldBeActive) {
          appsToMount.push(app);
        }
        break;
      case MOUNTED:
        // 应用已挂载,但当前 URL 不匹配 → 需要卸载
        if (!appShouldBeActive) {
          appsToUnmount.push(app);
        }
        break;
      // 其余状态(BOOTSTRAPPING / MOUNTING / UNMOUNTING / UNLOADING / SKIP_BECAUSE_BROKEN)忽略
    }
  });

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

// single-spa/src/applications/app.helpers.js
export function shouldBeActive(app) {
  try {
    return app.activeWhen(window.location);
  } catch (err) {
    // activeWhen 自己抛错也算应用坏了
    handleAppError(err, app, SKIP_BECAUSE_BROKEN);
    return false;
  }
}

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

NOT_LOADED → LOADING_SOURCE_CODE → NOT_BOOTSTRAPPED → NOT_MOUNTED ⇆ MOUNTED
                                                            │
                                                            ▼
                                                      UNLOADING → NOT_LOADED

8.3.3 performAppChanges:应用切换的编排

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

// single-spa/src/navigation/reroute.js(精简;performAppChanges、finishUpAndReturn 是 reroute 内部闭包,
// tryToBootstrapAndMount 在模块顶层)

function performAppChanges() {
  return Promise.resolve().then(() => {
    // 派发自定义事件,通知外部"路由切换开始"
    fireSingleSpaEvent(
      appsThatChanged.length === 0 ? "before-no-app-change" : "before-app-change",
      getCustomEventDetail(true)
    );
    fireSingleSpaEvent(
      "before-routing-event",
      getCustomEventDetail(true, { cancelNavigation })
    );

    // 监听器可以调用 detail.cancelNavigation()(可传 Promise);任一为真则取消本次导航
    return Promise.all(cancelPromises).then((cancelValues) => {
      const navigationIsCanceled = cancelValues.some((v) => v);
      if (navigationIsCanceled) {
        // 用未打补丁的 replaceState 把 URL 改回去,然后静默地再 reroute 一次
        originalReplaceState.call(window.history, history.state, "", oldUrl.substring(location.origin.length));
        currentUrl = location.href;
        appChangeUnderway = false;
        return reroute(pendingPromises, eventArguments, true);
      }

      // 阶段 1:卸载与 unload 一起启动
      const unloadPromises = appsToUnload.map(toUnloadPromise);
      const unmountUnloadPromises = appsToUnmount
        .map(toUnmountPromise)
        .map((unmountPromise) => unmountPromise.then(toUnloadPromise));
      const unmountAllPromise = Promise.all(unmountUnloadPromises.concat(unloadPromises));

      unmountAllPromise.then(() => {
        fireSingleSpaEvent("before-mount-routing-event", getCustomEventDetail(true));
      });

      
      const loadThenMountPromises = appsToLoad.map((app) =>
        toLoadPromise(app).then((app) => tryToBootstrapAndMount(app, unmountAllPromise))
      );

      
      const mountPromises = appsToMount
        .filter((appToMount) => appsToLoad.indexOf(appToMount) < 0)
        .map((appToMount) => tryToBootstrapAndMount(appToMount, unmountAllPromise));

      return unmountAllPromise
        .catch((err) => {
          callAllEventListeners();
          throw err;
        })
        .then(() => {
          
          callAllEventListeners();

          return Promise.all(loadThenMountPromises.concat(mountPromises))
            .catch((err) => {
              pendingPromises.forEach((promise) => promise.reject(err));
              throw err;
            })
            .then(finishUpAndReturn);
        });
    });
  });
}

function finishUpAndReturn() {
  const returnValue = getMountedApps();
  // 排队的调用者拿到同一个结果
  pendingPromises.forEach((promise) => promise.resolve(returnValue));

  try {
    fireSingleSpaEvent(appsThatChanged.length === 0 ? "no-app-change" : "app-change", getCustomEventDetail());
    fireSingleSpaEvent("routing-event", getCustomEventDetail());
  } catch (err) {
    // 事件监听器抛错是它们自己的问题,single-spa 要继续往下走
    setTimeout(() => { throw err; });
  }

  
  appChangeUnderway = false;

  if (peopleWaitingOnAppChange.length > 0) {
    
    const nextPendingPromises = peopleWaitingOnAppChange;
    peopleWaitingOnAppChange = [];
    reroute(nextPendingPromises);
  }

  return returnValue;
}


function tryToBootstrapAndMount(app, unmountAllPromise) {
  if (shouldBeActive(app)) {
    return toBootstrapPromise(app).then((app) =>
      unmountAllPromise.then(() => (shouldBeActive(app) ? toMountPromise(app) : app))
    );
  } else {
    return unmountAllPromise.then(() => app);
  }
}

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

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

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

卸载先于挂载,但加载不等卸载。 挂载新应用之前必须等待旧应用卸载完成。这不仅是因为 DOM 容器可能被复用,更是因为旧应用可能持有事件监听器、定时器等资源,必须先清理干净。而下载代码和 bootstrap 不碰 DOM,所以和卸载并行——这是 single-spa 在"安全"前提下能榨出来的那点并行度。

导航可以被取消。 before-routing-event 的 detail.cancelNavigation 是 v5.7.0 加入的钩子:监听器调用它(可以传一个 Promise 做异步判断),single-spa 会用未打补丁的 originalReplaceState 把 URL 改回 oldUrl,再以 silentNavigation = true 静默地 reroute 一次——这一轮不派发任何 single-spa:* 事件,也不回放被截获的监听器。这是在 single-spa 层面做"路由守卫"的正规途径。

8.3.4 生命周期的调用保证

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

// single-spa/src/lifecycles/mount.js(精简)
export function toMountPromise(appOrParcel, hardFail) {
  return Promise.resolve().then(() => {
    if (appOrParcel.status !== NOT_MOUNTED) {
      return appOrParcel; // 状态不对直接返回——天然幂等
    }

    appOrParcel.status = MOUNTING;

    // 调用子应用导出的 mount 函数(reasonableTime 内部会用 getProps 组装
    // { ...customProps, name, mountParcel, singleSpa } 作为参数),并附带超时控制
    return reasonableTime(appOrParcel, "mount")
      .then(() => {
        appOrParcel.status = MOUNTED;
        return appOrParcel;
      })
      .catch((err) => {
        // mount 失败:先临时标成 MOUNTED,让 toUnmountPromise 真的去清理半成品 DOM,
        // 然后把应用标记为 SKIP_BECAUSE_BROKEN——后续 reroute 不会再碰它
        appOrParcel.status = MOUNTED;
        return toUnmountPromise(appOrParcel, true).then(setSkipBecauseBroken, setSkipBecauseBroken);

        function setSkipBecauseBroken() {
          if (!hardFail) {
            handleAppError(err, appOrParcel, SKIP_BECAUSE_BROKEN);
            return appOrParcel;
          } else {
            throw transformErr(err, appOrParcel, SKIP_BECAUSE_BROKEN);
          }
        }
      });
  });
}

// single-spa/src/applications/timeouts.js(精简)
// 全局默认:bootstrap 4000ms,mount / unmount / unload / update 3000ms;
// dieOnTimeout 均为 false;warningMillis 均为 1000ms
export function reasonableTime(appOrParcel, lifecycle) {
  const timeoutConfig = appOrParcel.timeouts[lifecycle]; // 加载时由 ensureValidAppTimeouts 合并出来
  const warningPeriod = timeoutConfig.warningMillis;
  const type = objectType(appOrParcel); // "application" 或 "parcel"

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

    appOrParcel[lifecycle](getProps(appOrParcel))
      .then((val) => { finished = true; resolve(val); })
      .catch((val) => { finished = true; reject(val); });

    setTimeout(() => maybeTimingOut(1), warningPeriod);
    setTimeout(() => maybeTimingOut(true), timeoutConfig.millis);

    const errMsg = `Lifecycle function ${lifecycle} for ${type} ${toName(appOrParcel)} lifecycle did not resolve or reject for ${timeoutConfig.millis} ms.`;

    function maybeTimingOut(shouldError) {
      if (!finished) {
        if (shouldError === true) {
          errored = true;
          if (timeoutConfig.dieOnTimeout) {
            reject(Error(errMsg));
          } else {
            console.error(errMsg);
            // 不 resolve 也不 reject,继续等它
          }
        } else if (!errored) {
          // 每隔 warningPeriod 警告一次,直到超时
          const numWarnings = shouldError;
          const numMillis = numWarnings * warningPeriod;
          console.warn(errMsg);
          if (numMillis + warningPeriod < timeoutConfig.millis) {
            setTimeout(() => maybeTimingOut(numWarnings + 1), warningPeriod);
          }
        }
      }
    }
  });
}

注意默认的超时是"软"的:dieOnTimeout 为 false 时到期只打一条 console.error,Promise 仍然挂着等生命周期函数自己结束。要让超时真的失败,需要 setMountMaxTime(ms, true) 之类的全局配置,或在应用导出对象上带 timeouts: { mount: { millis, dieOnTimeout } }。

8.3.5 快速连续导航的处理

真实用户会在上一次切换还没完成时连续点击导航。假设 160ms 内连点三次:

时间轴:
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. 订单子应用:若第一轮里它已经挂载完成,这一轮被卸载;
//    若第一轮还卡在加载/bootstrap,tryToBootstrapAndMount 在 mount 前的
//    shouldBeActive 二次检查会直接跳过它——根本不会挂载
// 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 没感知到,导致子应用状态没切换,路由显示和实际状态不一致——在 6.x 里,这只会发生在 start()(也就是 patchHistoryApi())之前的导航,或者绕开了被打补丁的 pushState 的导航上。这些 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 能和 single-spa 共存,并非偶然——它从 v4 开始采用"基于组件的路由"范式,让路由不再是"全局单例",而是"组件树里的一个组件"。这个看似微小的架构选择,让 Router 可以按需实例化:各自独立的 React 根里可以各放一个 Router,共享同一个 window.history,互不干扰(同一棵组件树里 v6 不允许再嵌套 Router)。这正好和微前端的需求匹配——主应用的 Router 和子应用的 Router,逻辑上是嵌套关系,物理上共享 window.history。配合 basename 配置划定作用域边界,两层 Router 就能和谐共处。

React Router 支持 basename 配置(v4 起就有,v6 亦然),这是与 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, Navigate } from 'react-router-dom';
// ProductList / ProductDetail 为子应用自己的页面组件,import 从略

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>
        {}
        {/* /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 选项实现路由前缀隔离:

// 商品子应用的入口 - 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。这种 bug 的解决之道,是建立"跨应用导航不经过子应用 Router"这个约束——不要让子应用的 Router 去处理跨应用跳转,而是统一交给 navigateToUrl(底层就是被打过补丁的 history.pushState),由 single-spa 来裁决"这次跳转应该激活哪个应用"。

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

// ❌ 错误方式:在商品子应用中用 React Router 导航到订单页
// 商品子应用的 Router 带着 basename="/product",navigate('/order/list')
// 实际 pushState 的是 /product/order/list——single-spa 仍判定为商品应用,
// 只是商品应用内部匹配不到这条路由而已
import { useNavigate } from 'react-router-dom';

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

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

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

// navigateToUrl 的实现(src/navigation/navigation-events.js,精简)
// 它也挂在 window.singleSpaNavigate 上,方便写在 <a onclick> 里;
// 除了字符串,还接受 <a> 元素作为 this、或 currentTarget 是 <a> 的点击事件
export function navigateToUrl(obj) {
  let url;
  if (typeof obj === "string") {
    url = obj;
  } else if (this && this.href) {
    url = this.href;
  } else if (obj && obj.currentTarget && obj.currentTarget.href && obj.preventDefault) {
    url = obj.currentTarget.href;
    obj.preventDefault();
  } else {
    throw Error(/* #14 must be called with a string url, an <a> tag, or an event */);
  }

  const current = parseUri(window.location.href); // 用 <a> 元素解析 URL
  const destination = parseUri(url);

  if (url.indexOf("#") === 0) {
    window.location.hash = destination.hash;           // hash-only 的 URL
  } else if (current.host !== destination.host && destination.host) {
    window.location.href = url;                        // 跨域:整页跳转
  } else if (destination.pathname === current.pathname &&
             destination.search === current.search) {
    window.location.hash = destination.hash;           // 只有 hash 不同
  } else {
    // 使用(已被 monkey-patch 的)pushState,这会触发整个路由拦截链路
    window.history.pushState(null, null, url);
  }
}

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

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

import { navigateToUrl } from 'single-spa';


function isInternalNavigation(targetUrl: string, basename: string): boolean {
  const { origin, pathname } = new URL(targetUrl, window.location.origin);
  // 按路径段比较:/productivity 不属于 /product
  return origin === window.location.origin &&
    (pathname === basename || pathname.startsWith(basename + '/'));
}


export function createSmartNavigate(basename: string) {
  return function smartNavigate(
    targetUrl: string,
    internalNavigate: (path: string) => void
  ): void {
    if (isInternalNavigation(targetUrl, basename)) {
      // 应用内导航:使用子应用自己的 Router
      // 从解析后的 pathname 里去掉 basename(targetUrl 也可能是绝对 URL)
      const { pathname, search, hash } = new URL(targetUrl, window.location.origin);
      const internalPath = (pathname.slice(basename.length) || '/') + search + hash;
      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 路由状态同步的陷阱

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

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

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

// single-spa 的保护机制(前面提到的二次检查)
// 在 tryToBootstrapAndMount 中,等所有卸载完成后会再次调用 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 中也加入认证检查
// (另一条路是监听 single-spa:before-routing-event,在里面调用
//   evt.detail.cancelNavigation(),让 single-spa 把 URL 回退,见 8.3.3)
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 17 及以前会警告 "Can't perform a React state update on an unmounted component";
      // React 18 移除了这条警告,但请求和回调照样跑完
      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 只处理不属于子应用的路径(登录页、全局错误页),把所有"子应用应该接管的路径"都用 /* 通配符兜底给 MicroAppContainer 组件——这个组件什么都不做,只是给 single-spa 提供一个 DOM 节点,让它可以在里面挂载子应用。

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

// 主应用(Shell)的路由配置 - React 示例
import { BrowserRouter, Routes, Route, Outlet, NavLink } from 'react-router-dom';
// LoginPage / ForbiddenPage / NotFoundPage 为主应用自己的页面组件,import 从略

function ShellApp() {
  return (
    <BrowserRouter>
      <Routes>
        {}
        <Route path="/login" element={<LoginPage />} />
        <Route path="/403" element={<ForbiddenPage />} />
        <Route path="/404" element={<NotFoundPage />} />

        {/* 带布局的路由 */}
        <Route element={<MainLayout />}>
          {/* 子应用挂载点 */}
          {}
          <Route path="/*" element={<MicroAppContainer />} />
        </Route>
      </Routes>
    </BrowserRouter>
  );
}


function MicroAppContainer() {
  return (
    <div id="micro-app-container">
      {}
      {}
    </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。

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 在 start()(也就是 patchHistoryApi())执行之前就通过 window.addEventListener('popstate', tracker) 注册了路由追踪监听器。这个监听器会被 single-spa 截获吗?为什么?如果不会,这会导致什么问题?

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

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

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