Skip to content

第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

8.1.1 为什么需要拦截 History API

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

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

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

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

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

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

8.1.2 patchedUpdateState 的源码实现

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

typescript
// 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() 被调用。

typescript
// 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 的统一处理

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

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

typescript
// 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 完成后才统一触发。

typescript
// 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 完成之后的回调中:

typescript
/**
 * 在 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 事件流��完整时序

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

让我们用一个具体的场景来理解完整的事件流。假设用户从 /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 路由,渲染商品详情组件

基于 VitePress 构建