Appearance
第8章 single-spa 的路由拦截
"所有微前端框架的路由系统,本质上都是在回答同一个问题:如何在浏览器只有一个地址栏的约束下,让多个独立应用各自认为自己拥有完整的路由控制权。"
本章要点
- 理解 single-spa 对
history.pushState/replaceState的 monkey-patch 机制及其设计动机- 掌握
popstate与hashchange事件的统一拦截与延迟触发策略- 追踪从 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 变化需要触发三件事:
- single-spa 层面:识别出这是一次跨应用导航——订单子应用需要卸载,商品子应用需要加载并挂载
- 商品子应用的 Router:识别出
/product/detail/42匹配其内部的Detail路由,渲染对应组件 - 主应用的 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 对 pushState 和 replaceState 进行 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 = true 和 evt.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);