微前端源码精讲
第7章 single-spa 核心机制
第7章 single-spa 核心机制
框架的灵魂不在它暴露了多少 API——在于它隐藏了多少复杂度,又在正确的地方把控制权还给你。
本章要点
- 理解 single-spa "路由即应用边界"的设计哲学及其对微前端架构的深远影响
- 深入 registerApplication 的参数设计,掌握内部状态机如何管理应用的完整生命周期
- 掌握 12 种应用状态(NOT_LOADED → UNLOADING)的完整流转规则与边界条件
- 剖析 reroute 函数的核心调度逻辑:getAppChanges 如何决定加载、挂载与卸载
- 理解 toLoadPromise / toBootstrapPromise / toMountPromise / toUnmountPromise 四大 Promise 链的执行机制
本章 single-spa 源码以 v6.0.3 为准(src/applications、src/lifecycles、src/navigation),代码片段为可读性删去了 __PROFILE__ 埋点与错误码包装;qiankun 2.x 依赖的是 single-spa 5.x,个别时机(如 History API 何时被打补丁)在 5.x 与 6.x 不同,文中会注明。
如果说乾坤是中国微前端生态的代名词,那么 single-spa 就是全球微前端的基石。
这个由 Joel Denning 在 2015 年创建的框架,做了一件看似简单却意义深远的事情:它在浏览器的路由系统和多个独立应用之间,架起了一座桥梁。 当 URL 发生变化时,single-spa 自动判断哪些应用该加载、哪些该挂载、哪些该卸载——整个过程对用户来说是无感知的单页应用体验。
但"简单"是表象。当你打开 single-spa 的源码,你会发现:一个 src 目录 25 个文件、合计 2757 行代码的框架,内部竟然维护着 12 种应用状态、一个精密的状态机、一套复杂的并发调度逻辑。每一行代码都在处理你可能从未想到过的边界情况——应用加载失败怎么办?用户在应用还没挂载完成时又切换了路由怎么办?两个应用的激活条件重叠时该如何处理?
本章将从源码层面彻底剖析 single-spa 的三大核心机制:注册(registerApplication)、状态管理(12 种状态的流转)、和调度(reroute)。读完这一章,你不仅能理解 single-spa 的每一个设计决策,更能看到乾坤这样的上层框架为什么要在 single-spa 之上做那些增强、无界又为什么干脆不用它——因为你会清楚地看到 single-spa "故意不做"的部分。
7.1 设计哲学:路由即应用边界
single-spa 与乾坤的差异不在代码层面,而在设计取向:读乾坤源码是在学"如何为微前端加上各种能力",读 single-spa 源码则是在学"如何克制、把能力以扩展形式而非内置形式暴露"。这种克制让它成为 qiankun 等上层框架的底座(无界不依赖它,自己实现了调度)——它选择自己只做调度,把沙箱、通信、资源加载留给使用者。
7.1.1 一个核心假设
single-spa 的整个架构建立在一个核心假设之上:URL 路径是划分应用边界的最自然单位。
这个假设如此朴素,以至于很容易被忽略。但仔细想想——在一个 SPA 中,路由本来就是组织页面的方式。single-spa 只是把这个概念提升了一个层次:路由不仅组织页面,还组织应用。
这个看似朴素的假设、背后有深刻的用户心智建模——对用户来说、他们感知"当前在哪个应用"最直接的信号就是 URL。用户在浏览器地址栏里看到的路径、就是他对"我现在在什么应用里"的心智锚点。任何违背这个心智锚点的设计、都会让用户感到困惑——比如"URL 看起来像在商品页、但界面显示的是订单"这种情况、用户会立刻觉得"这个网站有 bug"。所以 single-spa 选择"以 URL 为应用边界"、不只是技术上的优雅、更是对"人如何理解多应用系统"的正确建模。这和我们在《Claude Code 源码深度解析》专栏第 17 章看到的终端 UI 设计、在《Vue 3 设计与实现》专栏第 16 章看到的 Vue Router 内核设计、本质上是同一种思路——好的软件不只是"功能正确"、更要"符合人的认知直觉"。
// 传统 SPA:路由 → 页面
const routes = [
{ path: '/order', component: OrderPage },
{ path: '/product', component: ProductPage },
];
// single-spa:路由 → 应用
import { registerApplication, start } from 'single-spa';
registerApplication({
name: 'order-app',
app: () => System.import('https://cdn.example.com/order/main.js'),
activeWhen: '/order',
});
registerApplication({
name: 'product-app',
app: () => System.import('https://cdn.example.com/product/main.js'),
activeWhen: '/product',
});
start();
从外部看,这只是把"组件"换成了"应用"。但这一步的跨越带来了根本性的不同:每个"应用"可以是一个独立构建、独立部署、独立运行的前端项目。
7.1.2 "不做什么"比"做什么"更重要
在这一节的标题里、藏着一个成熟工程师和新手的根本差异——新手总是问"我能加什么功能"、成熟工程师总是问"我不该加什么功能"。新手相信"更多功能 = 更好产品"——所以他们的产品总在膨胀、文档越写越厚、bug 越改越多;成熟工程师相信"更聚焦 = 更好产品"——他们明确划定产品边界、让那些不属于边界内的需求留给其他工具去解决。这个差异、在开源领域被反复验证——SQLite 作者 Richard Hipp 长期让 SQLite 保持轻量、不引入存储过程这类"大型数据库"特性;尤雨溪在 Vue 2 成名后、依然保持 Vue Router 和 Vuex 为独立包、不合并进 Vue 核心。这些选择看起来是在"放弃机会"、实际上是在"保护产品的清晰性"——清晰的产品能活得久、膨胀的产品会被自己压垮。
single-spa 最大的设计智慧不在于它做了什么,而在于它故意不做什么:
// single-spa 不做的事情
interface WhatSingleSpaDoesNot {
jsSandbox: never; // 不提供 JS 沙箱
cssSandbox: never; // 不提供 CSS 隔离
htmlEntry: never; // 不支持 HTML Entry 加载
communication: never; // 不提供应用间通信机制
}
// single-spa 只做的事情
interface WhatSingleSpaDoes {
registration: '注册应用与激活条件';
lifecycle: '管理 bootstrap / mount / unmount 生命周期';
routing: '监听路由变化,调度应用的挂载与卸载';
status: '维护每个应用的状态';
}
这是一个极其克制的设计选择。single-spa 的定位是微前端的调度层——它只负责"什么时候加载什么应用",至于应用如何隔离、如何通信、如何共享依赖,全部留给上层方案或开发者自行解决。正是这种克制,使得 single-spa 成为了微前端的"Linux 内核"——乾坤在它之上加了沙箱和 HTML Entry;无界则没有复用它、而是用 iframe + Shadow DOM 自己实现了一套调度(见第 13 章)。如果 single-spa 自己做了太多,反而会限制上层方案的设计空间。
7.1.3 架构全景
┌─────────────────────────────────────────────────────┐
│ 浏览器路由事件 │
│ (hashchange / popstate / pushState) │
└────────────────────┬────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────┐
│ reroute() 调度中枢 │
│ ┌───────────┐ ┌────────────┐ ┌─────────────┐ │
│ │getAppChg │ │ Promise 链 │ │ 并发控制 │ │
│ │ 分类应用 │ │ load→boot→ │ │ appChangeUn │ │
│ │ 状态变更 │ │ mount→unmt │ │ derway 标志 │ │
│ └───────────┘ └────────────┘ └─────────────┘ │
└────────────────────┬────────────────────────────────┘
┌───────────┼──────────┐
▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌────────┐
│ App A │ │ App B │ │ App C │
│ MOUNTED │ │ NOT_ │ │ LOAD_ │
│ │ │ LOADED │ │ ERROR │
└─────────┘ └──────────┘ └────────┘
整个架构可以用一句话概括:路由变化触发 reroute,reroute 根据每个应用的激活条件和当前状态,决定执行加载、启动、挂载或卸载操作。
下图用 Mermaid 展示了 single-spa 的核心调度架构:
flowchart TB
Event["浏览器路由事件\nhashchange / popstate / pushState"] --> URLReroute["urlReroute()"]
URLReroute --> Reroute["reroute() 调度中枢"]
Reroute --> GetChanges["getAppChanges()\n根据 activeWhen 分类应用"]
GetChanges --> ToLoad["appsToLoad\n需要加载的应用"]
GetChanges --> ToMount["appsToMount\n需要挂载的应用"]
GetChanges --> ToUnmount["appsToUnmount\n需要卸载的应用"]
GetChanges --> ToUnload["appsToUnload\n需要完全卸载的应用"]
ToLoad --> LoadPromise["toLoadPromise\n下载应用代码"]
LoadPromise --> BootPromise["toBootstrapPromise\n初始化应用"]
BootPromise --> MountPromise["toMountPromise\n挂载到 DOM"]
ToMount --> MountPromise
ToUnmount --> UnmountPromise["toUnmountPromise\n从 DOM 卸载"]
ToUnload --> UnloadPromise["toUnloadPromise\n释放内存"]
UnmountPromise --> Replay["callAllEventListeners()\n卸载全部完成后回放被截获的路由事件"]
UnloadPromise --> Replay
Replay --> MountPromise
MountPromise --> Done["finishUpAndReturn()\n派发 app-change / routing-event"]
style Reroute fill:#e3f2fd,stroke:#1565c0
style GetChanges fill:#fff3e0,stroke:#e65100
style Done fill:#e8f5e9,stroke:#2e7d32
🔥 深度洞察:single-spa 的"管道架构"
single-spa 的架构本质上是一个管道(Pipeline)模式——路由事件作为输入,经过 getAppChanges 分类、Promise 链执行、状态更新三个阶段,最终输出一组 DOM 变更。这种管道架构的优势在于:每个阶段职责单一(分类只读状态、Promise 链只改状态),易于测试和推理。它的劣势也同样明显:一次 reroute 要等所有卸载与挂载都结束才算完成,只要有一个应用的 mount 迟迟不返回,后续的路由变化就只能排队。single-spa 为此内置了
reasonableTime超时机制(默认只告警不中断,见 7.4.4);乾坤则在它之上又加了预加载和singular单实例模式。
7.2 注册机制:registerApplication 的参数设计与内部状态机
API 设计是一门需要极致推敲的艺术——一个 API 的参数列表、不只是"函数需要什么信息"、更是"框架作者对使用者心智模型的一次承诺"。好的 API 让你用起来自然流畅、每个参数都有清晰的存在意义;不好的 API 让你用起来处处别扭、每次都要翻文档回忆这个参数是干嘛的。single-spa 的 registerApplication 经过了多次大版本迭代——从最初的位置参数(registerApplication(name, loadFn, activeFn, props))、演化到 v5.3.0 起支持的对象参数(registerApplication({ name, app, activeWhen, customProps }),位置参数仍然保留)——这个演化不是偶然、是团队对"API 可扩展性"深度思考的结果。位置参数在参数少于 3 个时直观、但当未来需要加新参数时会让 API 签名变得混乱;对象参数牺牲了一点简洁性、但获得了"任意时刻都可以加新字段而不破坏旧调用"的演化能力。好的 API 设计师、总是在当前便利和未来演化之间寻找平衡——这个平衡、决定了这个 API 能否活过 5 年、10 年、15 年。
7.2.1 API 表面:四个参数的设计意图
registerApplication 是 single-spa 暴露给开发者的核心 API:
// 简化自 typings/single-spa.d.ts 的 RegisterApplicationConfig
interface AppConfig {
name: string;
app: LifeCycles | ((props: AppProps) => Promise<LifeCycles>);
activeWhen: ActivityFn | string | (ActivityFn | string)[];
customProps?: object | ((name: string, location: Location) => object);
}
interface LifeCycles {
bootstrap: LifeCycleFn | LifeCycleFn[]; // v5.6.0 起可省略
mount: LifeCycleFn | LifeCycleFn[];
unmount: LifeCycleFn | LifeCycleFn[];
unload?: LifeCycleFn | LifeCycleFn[]; // 类型声明未列出,但 load.js 会读取,unloadApplication 时调用
}
type LifeCycleFn = (props: CustomProps) => Promise<void>;
type ActivityFn = (location: Location) => boolean;
activeWhen 是最灵活的参数,支持三种形式:
// 形式1:字符串前缀匹配
registerApplication({
name: 'order',
app: () => import('./order.js'),
activeWhen: '/order', // 匹配 /order, /order/list, /order/detail/123
});
// 形式2:函数(完全自定义)
registerApplication({
name: 'admin',
app: () => import('./admin.js'),
activeWhen: (location) =>
location.pathname.startsWith('/admin')
&& localStorage.getItem('role') === 'admin',
});
// 形式3:数组(多条件 OR)
registerApplication({
name: 'shared-layout',
app: () => import('./layout.js'),
activeWhen: ['/order', '/product', '/user'],
});
7.2.2 源码剖析:registerApplication 的内部实现
让我们用"分层解读"的方式看这段代码——不是一行一行读、而是先把它拆成几个逻辑层、看每个层做了什么、然后再看每层的具体实现。这种"先拆层、后读细节"的阅读方式、让你对代码的宏观结构先有印象、再去填充微观的实现。你可以看到 registerApplication 的内部逻辑大概分四层:(1) 参数归一化——把多种调用形式统一为内部对象;(2) 校验——防止重复注册、格式错误;(3) 入库——把归一化后的对象推入全局 apps 数组;(4) 触发 reroute——让调度中枢重新评估当前路由状态。理解了这四层、你就掌握了这个函数的"骨架";再具体看每层的代码、只是在填"血肉"。这种"骨架到血肉"的阅读法、就像医学院学生先学解剖学再学临床一样——先理解结构、再关注细节、永远比反过来高效。这也是本专栏一贯坚持的阅读方法论、希望能伴随你读完剩余的所有章节、并且带到你未来阅读任何开源项目的过程中去。
// single-spa 源码 - src/applications/apps.js(简化版)
const apps = []; // 全局应用注册表
export function registerApplication(
appNameOrConfig, appOrLoadApp, activeWhen, customProps
) {
// 第一步:参数归一化
const registration = sanitizeArguments(
appNameOrConfig, appOrLoadApp, activeWhen, customProps
);
// 第二步:校验——不允许重复注册
if (getAppNames().indexOf(registration.name) !== -1)
throw Error(`There is already an app registered with name ${registration.name}`);
// 第三步:创建内部应用对象并放入注册表
apps.push(
assign(
{
loadErrorTime: null,
status: NOT_LOADED, // 关键:初始状态为 NOT_LOADED
parcels: {},
devtools: { overlays: { options: {}, selectors: [] } },
},
registration
)
);
// 第四步:立即触发一次 reroute
if (isInBrowser) {
ensureJQuerySupport();
reroute();
}
}
参数归一化中最精巧的是 activeWhen 的处理——字符串、函数、数组被统一转换为一个判定函数:
// single-spa 源码 - src/applications/apps.js:439-466(pathToActiveWhen 略去了 IE10 的 location.origin 兼容分支)
function sanitizeActiveWhen(activeWhen) {
let activeWhenArray = Array.isArray(activeWhen) ? activeWhen : [activeWhen];
activeWhenArray = activeWhenArray.map((item) =>
typeof item === 'function' ? item : pathToActiveWhen(item)
);
// 返回 OR 逻辑的组合函数
return (location) => activeWhenArray.some((fn) => fn(location));
}
function pathToActiveWhen(path, exactMatch) {
const regex = toDynamicPathValidatorRegex(path, exactMatch);
return (location) => {
const route = location.href
.replace(location.origin, '')
.replace(location.search, '')
.split('?')[0];
return regex.test(route);
};
}
注意 pathToActiveWhen 做了前缀匹配而非精确匹配——'/order' 会匹配 /order、/order/list、/order/detail/123(按路径段匹配,/orders 不会命中)。这是有意为之的设计:在微前端场景中,一个子应用通常管理一个路径前缀下的所有页面。
最后一行 reroute() 至关重要——每次注册新应用时,single-spa 都会立即重新评估当前 URL 下哪些应用该被激活。如果新注册的应用恰好匹配当前路由,它会立即开始加载流程,无需等待下一次路由变化。
🔥 深度洞察:apps 数组而非 Map
single-spa 用数组而非 Map 来存储注册的应用。数组保留了注册顺序:
getAppChanges按注册顺序遍历,getMountedApps()、getAppNames()返回的顺序也与之一致。但要注意,这个顺序不是挂载顺序的保证——同时激活的应用是用Promise.all并行 bootstrap / mount 的,谁先挂载完取决于各自生命周期函数的耗时。如果你的共享布局应用(如导航栏)必须先于业务应用就位,靠"先注册"是不够的,得用 single-spa-layout 之类的方案显式表达容器关系。
7.3 应用状态管理:12 种状态的流转
第一次看到 "12 种状态" 这个数字、很多读者会本能地觉得"太复杂了"——一个子应用怎么可能需要这么多状态?但当你真正把每一种状态拆开来看、你会发现每一种都代表了一个"真实可能发生的情况"——未加载、加载中、已加载但未初始化、初始化中、已初始化但未挂载、挂载中、已挂载、更新中、卸载中、彻底卸载中、加载失败、因出错被跳过——这些不是"凭空想出来的状态"、是真实世界里会出现的情境。如果你强行压缩状态数量(比如只保留 "加载中" 和 "已加载" 两种)、那些被忽略的情境就会变成 "状态不一致的 bug"——比如你不区分 "正在卸载中" 和 "已卸载"、那你就无法判断"能不能再次挂载这个应用"这种问题。状态机设计的黄金法则是——宁可多几个状态让系统更精确、也不要为了"看起来简洁"而牺牲精确性。single-spa 的 12 种状态、是这个原则的典型应用。
7.3.1 为什么需要 12 种状态
下图展示了 single-spa 完整的 12 种应用状态流转,每个异步操作都有进行中的过渡状态和失败分支:
stateDiagram-v2
[*] --> NOT_LOADED
NOT_LOADED --> LOADING_SOURCE_CODE: 路由匹配,需要加载
LOADING_SOURCE_CODE --> NOT_BOOTSTRAPPED: 加载成功
LOADING_SOURCE_CODE --> LOAD_ERROR: 加载失败
LOADING_SOURCE_CODE --> SKIP_BECAUSE_BROKEN: 导出不合法/loader 未返回 Promise
LOAD_ERROR --> LOADING_SOURCE_CODE: 200ms 后重试加载
NOT_BOOTSTRAPPED --> BOOTSTRAPPING: 初始化
BOOTSTRAPPING --> NOT_MOUNTED: 初始化成功
BOOTSTRAPPING --> SKIP_BECAUSE_BROKEN: 初始化失败
NOT_MOUNTED --> MOUNTING: 路由匹配,挂载
MOUNTING --> MOUNTED: 挂载成功
MOUNTING --> SKIP_BECAUSE_BROKEN: 挂载失败
MOUNTED --> UNMOUNTING: 路由不匹配,卸载
MOUNTED --> UPDATING: Parcel update
UPDATING --> MOUNTED: 更新完成
UNMOUNTING --> NOT_MOUNTED: 卸载成功
UNMOUNTING --> SKIP_BECAUSE_BROKEN: 卸载失败
NOT_MOUNTED --> UNLOADING: 完全卸载(释放内存)
UNLOADING --> NOT_LOADED: 卸载完成,回到初始
在一般的 UI 组件生命周期中,我们习惯了简单的状态模型:创建 → 挂载 → 更新 → 卸载。但在微前端场景中,应用代码需要从远端加载(可能失败),加载后需要初始化(可能失败),初始化后需要挂载到 DOM(可能失败),路由变化时需要卸载(可能失败),某些情况下需要彻底卸载释放内存。每个异步操作都有"正在进行中"的过渡状态,再加上可重试的加载失败(LOAD_ERROR)和应用自身出错后被跳过(SKIP_BECAUSE_BROKEN)两种失败态,一共是 12 种状态。
7.3.2 12 种状态的完整定义
接下来看源码会发现一个朴素但重要的细节——这 12 种状态是用字符串常量定义的、不是用枚举、不是用 Symbol。这是 JavaScript 社区里一个常见的取舍——字符串常量的好处是"调试友好"(日志里直接显示 'MOUNTING' 而不是数字 3)、"跨模块兼容"(不依赖枚举的共享定义)、"序列化友好"(JSON.stringify 就能直接序列化)。代价是"类型安全略弱"——字符串可以被任意赋值、不像 TypeScript 的字符串字面量类型那样有编译时检查。这个取舍在 single-spa 作者看来是值得的——因为调试性和跨模块兼容性对于一个基础库来说、比类型安全更重要。这种"在多个理想之间选择适合当前场景的那一个"的决策、在任何一个技术选型里都会遇到——没有绝对正确的选择、只有"针对你的场景最优"的选择。
// single-spa 源码 - src/applications/app.helpers.js
export const NOT_LOADED = "NOT_LOADED"; // 初始状态
export const LOADING_SOURCE_CODE = "LOADING_SOURCE_CODE"; // 正在加载代码
export const NOT_BOOTSTRAPPED = "NOT_BOOTSTRAPPED"; // 已加载,待初始化
export const BOOTSTRAPPING = "BOOTSTRAPPING"; // 正在初始化
export const NOT_MOUNTED = "NOT_MOUNTED"; // 已初始化,待挂载
export const MOUNTING = "MOUNTING"; // 正在挂载
export const MOUNTED = "MOUNTED"; // 已挂载(用户可见)
export const UPDATING = "UPDATING"; // 正在 update,仅 Parcel 使用
export const UNMOUNTING = "UNMOUNTING"; // 正在卸载
export const UNLOADING = "UNLOADING"; // 正在完全卸载
export const LOAD_ERROR = "LOAD_ERROR"; // 加载失败
export const SKIP_BECAUSE_BROKEN = "SKIP_BECAUSE_BROKEN"; // 应用已损坏
这 12 个常量集中定义在 src/applications/app.helpers.js:4-15。UPDATING 是 Parcel API 特有的状态——Parcel 是 single-spa 提供的一种手动控制挂载/卸载的子应用模式,支持 update 操作。普通的 registerApplication 注册的应用不会进入这个状态。
7.3.3 状态流转图
NOT_LOADED ──加载──► LOADING_SOURCE_CODE ──成功──► NOT_BOOTSTRAPPED ──► BOOTSTRAPPING ──成功──► NOT_MOUNTED
▲ │ │ ▲
│ │ 加载失败 需要挂载 │ │ 成功
│ ▼ ▼ │
│ LOAD_ERROR ──200ms 后重试──► LOADING_SOURCE_CODE MOUNTING ───┘
│ │ 成功
│ unloadApplication / ▼
│ unregisterApplication MOUNTED ◄──► UPDATING(仅 Parcel)
│ │ 路由不匹配
└──────────── UNLOADING ◄──────────── NOT_MOUNTED ◄──成功── UNMOUNTING ◄──────────────────┘
任何一步生命周期失败(加载结果不合法、bootstrap / mount / unmount / unload 出错)──► SKIP_BECAUSE_BROKEN(终态,不再参与调度)
状态流转遵循几条核心规则:
规则一:单向主流程。 正常情况下,应用沿着 NOT_LOADED → LOADING → NOT_BOOTSTRAPPED → BOOTSTRAPPING → NOT_MOUNTED → MOUNTING → MOUNTED 的路径单向前进。
规则二:UNMOUNTING 回到 NOT_MOUNTED。 路由变化导致应用卸载时,状态从 MOUNTED → UNMOUNTING → NOT_MOUNTED。注意不是回到 NOT_LOADED——bootstrap 只执行一次,应用的初始化状态被保留。
规则三:UNLOADING 回到 NOT_LOADED。 只有显式调用 unloadApplication(或内部会调用它的 unregisterApplication)时,应用才会走完整的卸载流程:toUnloadPromise 会删掉 app 上的 bootstrap / mount / unmount / unload 引用。这意味着下次激活时需要重新加载代码和执行 bootstrap。
规则四:LOAD_ERROR 允许重试。 加载失败不是终点——getAppChanges 会在距 loadErrorTime 至少 200ms 之后的下一次 reroute 里重新把它放进 appsToLoad。注意 SKIP_BECAUSE_BROKEN 没有回头路:进入这个状态的应用会被后续所有 reroute 跳过。
7.3.4 状态判定辅助函数
// single-spa 源码 - src/applications/app.helpers.js
export function isActive(app) {
return app.status === MOUNTED;
}
export function shouldBeActive(app) {
try {
return app.activeWhen(window.location);
} catch (err) {
handleAppError(err, app, SKIP_BECAUSE_BROKEN);
return false;
}
}
特别注意 shouldBeActive 和 isActive 的区别:isActive(app) 回答"应用现在是否已挂载",shouldBeActive(app) 回答"根据当前 URL,应用应该处于活跃状态吗"。当两者不一致时,就需要执行挂载或卸载操作——这个"差异"正是 reroute 的决策依据。
7.3.5 状态转换的幂等保护
single-spa 通过代码结构来隐式保证状态转换的合法性。每个 toXxxPromise 函数在入口处检查当前状态:
// 不匹配则跳过——天然幂等
export function toBootstrapPromise(appOrParcel, hardFail) {
return Promise.resolve().then(() => {
if (appOrParcel.status !== NOT_BOOTSTRAPPED) {
return appOrParcel; // 状态不对,直接返回
}
appOrParcel.status = BOOTSTRAPPING;
// ... 执行 bootstrap
});
}
这种"检查当前状态,不匹配则跳过"的模式,是 single-spa 处理并发的核心策略。当同一个应用被多条路径重复触发时(例如 start() 之前每次 registerApplication 都会调用 reroute,而这条只加载的路径不受 7.4.3 的 appChangeUnderway 排队控制),只有第一次会进入目标状态,后续的尝试自动跳过(toLoadPromise 则是直接复用进行中的 loadPromise)。
🔥 深度洞察:隐式状态机 vs 显式状态机
single-spa 选择隐式状态机(通过 if/else 约束转换),而非显式状态机(如 XState 的状态转换表)。12 种状态的显式转换表会非常庞大,而且微前端场景中的"非法转换"通常不应该抛出错误,而是安静地跳过。但这也带来了隐患:当出现 bug 导致应用卡在某个中间状态时,除了
reasonableTime在控制台打出的超时告警(见 7.4.4),没有显式的状态错误,排查变得困难。这就是为什么在生产环境中,你偶尔会看到应用莫名其妙地"消失"——它可能卡在了 BOOTSTRAPPING 或 MOUNTING 状态,永远无法前进。
7.4 reroute 函数深度剖析:微前端的调度中枢
reroute 这个名字、表面上看是"重新路由"、实际上承担的角色远不止于此——它是整个 single-spa 的"心脏"。在计算机系统里、"心脏"通常指的是那种"被反复触发、每次触发都让整个系统向前推进一步"的核心循环——浏览器的 event loop、Node.js 的事件循环、Game 的 render loop、数据库的 transaction loop——都是同一种"心跳式推进"的设计。reroute 就是 single-spa 的这种心跳——每次路由变化、每次 start、每次 registerApplication、都会触发它;每次触发、它都会检查"当前应该有哪些应用运行、哪些应用卸载"、然后驱动系统走向那个目标状态。这种"声明式目标 + 每次心跳驱动一步"的设计模式、和 Kubernetes 的 controller reconcile 循环、React 的 render 循环、Terraform 的 plan/apply 循环、本质上完全一致——它们都在说同一件事:"你告诉我你想要什么、我通过不断比较当前状态和目标状态、让系统慢慢收敛到你想要的状态"。这种"控制回路(Control Loop)"是现代分布式系统最通用的一种架构模式——理解了 reroute、你就理解了这个模式在微前端里的应用。
7.4.1 reroute 的触发时机
reroute 是 single-spa 的心脏。它主要在三种情况下被调用(此外 triggerAppChange() 和 unloadApplication 也会调用它):
// 触发时机 1:registerApplication 时(src/applications/apps.js)
export function registerApplication(...) {
// ... 注册逻辑
if (isInBrowser) {
ensureJQuerySupport();
reroute();
}
}
// 触发时机 2:start() 被调用时(src/start.js)
export function start(opts) {
started = true;
if (isInBrowser) {
patchHistoryApi(opts); // v6 起:在这里才给 History API 打补丁、注册路由监听
reroute();
}
}
// 触发时机 3:路由变化时(src/navigation/navigation-events.js,patchHistoryApi 内部)
function urlReroute() {
reroute([], arguments);
}
window.addEventListener("hashchange", urlReroute);
window.addEventListener("popstate", urlReroute);
// 关键:劫持 pushState 和 replaceState
function patchedUpdateState(updateState, methodName) {
return function () {
const urlBefore = window.location.href;
const result = updateState.apply(this, arguments);
const urlAfter = window.location.href;
if (!urlRerouteOnly || urlBefore !== urlAfter) {
// 人工派发一个 popstate 事件(带 singleSpa=true 标记),
// 由上面的 urlReroute 监听器统一处理
window.dispatchEvent(createPopStateEvent(window.history.state, methodName));
}
return result;
};
}
window.history.pushState = patchedUpdateState(window.history.pushState, "pushState");
window.history.replaceState = patchedUpdateState(originalReplaceState, "replaceState");
这里有一个精妙的设计:single-spa 不仅监听浏览器的原生路由事件(hashchange、popstate),还劫持了 history.pushState 和 history.replaceState。因为在 SPA 中,子应用的路由导航通过 pushState 实现,而 pushState 不会触发 popstate 事件。如果不劫持,single-spa 将无法感知子应用内部的路由变化。劫持后的方法并不直接调 reroute,而是派发一个人工的 PopStateEvent,让所有监听 popstate 的代码(包括其他子应用的路由库)都能感知这次导航——细节见第 8 章。另外注意,5.x 是在模块加载时就打补丁,6.x 改为在 start()(或手动调用 patchHistoryApi())时才打。
7.4.2 getAppChanges:分类的核心逻辑
"分类"是程序员面对复杂问题时最强大的思维武器之一——当你要处理一堆异构数据时、最有效的第一步往往不是"立即处理"、而是"先把它们分成几类、然后对每一类分别处理"。这种思路在算法(快排的分治思想、基数排序)、工程(任务队列的优先级分类)、甚至产品设计(用户分群)里都能看到。single-spa 的 getAppChanges 正是这种思路的典型应用——不是"一个一个应用地处理"、而是"先把所有应用分成四类(appsToLoad / appsToMount / appsToUnmount / appsToUnload)、然后针对每一类用不同的 Promise 链处理"。这种设计让处理逻辑变得异常清晰——每一类的代码只关心自己这类应用的状态迁移、不需要考虑其他类型。如果不做这个分类、而是对每个应用都写一大堆 if-else、代码会变得不可维护——这是一个关于"分而治之"的工程美学的具体例子。
下图展示了 getAppChanges 根据应用当前状态和路由匹配结果,将应用分配到四个操作队列的决策逻辑:
flowchart TB
Start["遍历所有注册的应用"] --> CheckBroken{"status == \nSKIP_BECAUSE_BROKEN?"}
CheckBroken -->|"是"| Skip["跳过该应用"]
CheckBroken -->|"否"| CheckActive{"shouldBeActive(app)\n当前 URL 匹配 activeWhen?"}
CheckActive --> StatusSwitch["根据 app.status 判断"]
StatusSwitch --> |"NOT_LOADED /\nLOADING_SOURCE_CODE /\nLOAD_ERROR"| ActiveCheck1{"shouldBeActive?"}
ActiveCheck1 -->|"是"| ToLoad["appsToLoad"]
StatusSwitch --> |"NOT_BOOTSTRAPPED /\nNOT_MOUNTED"| ActiveCheck2{"shouldBeActive?"}
ActiveCheck2 -->|"是"| ToMount["appsToMount"]
ActiveCheck2 -->|"否, 且需卸载"| ToUnload["appsToUnload"]
StatusSwitch --> |"MOUNTED"| ActiveCheck3{"shouldBeActive?"}
ActiveCheck3 -->|"否"| ToUnmount["appsToUnmount"]
StatusSwitch --> |"过渡状态\nBOOTSTRAPPING\nMOUNTING\nUNMOUNTING"| Wait["等待当前操作完成\n不做任何处理"]
style ToLoad fill:#e3f2fd,stroke:#1565c0
style ToMount fill:#e8f5e9,stroke:#2e7d32
style ToUnmount fill:#ffebee,stroke:#c62828
style ToUnload fill:#fff3e0,stroke:#e65100
getAppChanges 是调度的"大脑"——它扫描所有注册的应用,根据当前状态和激活条件分入四个类别(src/applications/apps.js:29-72):
// single-spa 源码 - src/applications/apps.js
export function getAppChanges() {
const appsToUnload = [], appsToUnmount = [],
appsToLoad = [], appsToMount = [];
const currentTime = new Date().getTime();
apps.forEach((app) => {
const appShouldBeActive =
app.status !== SKIP_BECAUSE_BROKEN && shouldBeActive(app);
switch (app.status) {
case LOAD_ERROR:
// 加载失败且超过重试间隔(200ms),重新加载
if (appShouldBeActive && currentTime - app.loadErrorTime >= 200) {
appsToLoad.push(app);
}
break;
case NOT_LOADED:
case LOADING_SOURCE_CODE:
if (appShouldBeActive) {
appsToLoad.push(app);
}
break;
case NOT_BOOTSTRAPPED:
case NOT_MOUNTED:
if (!appShouldBeActive && getAppUnloadInfo(toName(app))) {
appsToUnload.push(app);
} else if (appShouldBeActive) {
appsToMount.push(app);
}
break;
case MOUNTED:
if (!appShouldBeActive) {
appsToUnmount.push(app);
}
break;
// BOOTSTRAPPING, MOUNTING, UNMOUNTING, UNLOADING 等过渡状态
// 不做任何操作——等待当前操作完成
}
});
return { appsToUnload, appsToUnmount, appsToLoad, appsToMount };
}
用一个具体场景来理解:
// 用户从 /order 导航到 /product
// order-app: MOUNTED + shouldBeActive=false → appsToUnmount
// product-app: NOT_LOADED + shouldBeActive=true → appsToLoad
// nav-app: MOUNTED + shouldBeActive=true → 不变(保持挂载)
7.4.3 reroute 核心逻辑与并发控制
// single-spa 源码 - src/navigation/reroute.js(简化版)
let appChangeUnderway = false;
let peopleWaitingOnAppChange = [];
export function reroute(pendingPromises = [], eventArguments) {
// 并发控制:已有 reroute 在执行中则排队
if (appChangeUnderway) {
return new Promise((resolve, reject) => {
peopleWaitingOnAppChange.push({ resolve, reject, eventArguments });
});
}
const { appsToUnload, appsToUnmount, appsToLoad, appsToMount } =
getAppChanges();
if (isStarted()) {
appChangeUnderway = true;
return performAppChanges();
} else {
// start() 尚未调用:只加载,不挂载
return loadApps();
}
function loadApps() {
return Promise.resolve().then(() => {
const loadPromises = appsToLoad.map(toLoadPromise);
return Promise.all(loadPromises).then(callAllEventListeners);
});
}
function performAppChanges() {
return Promise.resolve().then(() => {
// 触发 before-app-change / before-no-app-change 与 before-routing-event
// (fireSingleSpaEvent / getCustomEventDetail / cancelNavigation 都是 reroute 内部闭包;
// before-routing-event 的 detail.cancelNavigation 自 v5.7.0 起可用来取消本次导航)
fireSingleSpaEvent("before-app-change", getCustomEventDetail(true));
fireSingleSpaEvent("before-routing-event",
getCustomEventDetail(true, { cancelNavigation }));
// 卸载不需要的应用
const unmountUnloadPromises = appsToUnmount
.map(toUnmountPromise)
.map((p) => p.then(toUnloadPromise));
const allUnmountPromises = Promise.all(
unmountUnloadPromises.concat(appsToUnload.map(toUnloadPromise))
);
// 加载并挂载新应用
const loadThenMountPromises = appsToLoad.map((app) =>
toLoadPromise(app).then((app) =>
tryToBootstrapAndMount(app, allUnmountPromises)
)
);
// 挂载已加载但未挂载的应用
const mountPromises = appsToMount
.filter((app) => appsToLoad.indexOf(app) < 0)
.map((app) => tryToBootstrapAndMount(app, allUnmountPromises));
return allUnmountPromises
.then(() => {
// 旧应用已卸载:此时才放行被截获的 popstate/hashchange 监听器
callAllEventListeners();
return Promise.all(loadThenMountPromises.concat(mountPromises));
})
.then(finishUpAndReturn);
});
}
function finishUpAndReturn() {
const returnValue = getMountedApps();
pendingPromises.forEach((promise) => promise.resolve(returnValue));
// 触发完成事件:app-change / no-app-change,然后 routing-event
fireSingleSpaEvent("app-change", getCustomEventDetail());
fireSingleSpaEvent("routing-event", getCustomEventDetail());
// 重置并发标志,处理排队请求
appChangeUnderway = false;
if (peopleWaitingOnAppChange.length > 0) {
const next = peopleWaitingOnAppChange;
peopleWaitingOnAppChange = [];
reroute(next); // 排队的请求会重新调用 getAppChanges
}
return returnValue;
}
}
并发控制解决了这样的问题:
T1: 用户从 /order 导航到 /product → reroute 开始执行
T2: order-app 还在 unmount,用户又导航到 /user
→ 第二个 reroute 被排队(不会立即执行)
T3: 第一个 reroute 完成 → 处理排队的 reroute
→ 重新调用 getAppChanges(基于最新状态决策)
排队的请求在执行时会重新调用 getAppChanges()——因为排队期间应用的状态可能已经发生了变化。基于过时的状态做决策会导致错误。
7.4.4 四大 Promise 链
"Promise 链"的设计是 JavaScript 异步编程的原生表达方式——在没有 async/await 的时代、Promise.then 链是串联异步操作最常用的方式。single-spa 在 reroute 函数里把"加载、初始化、挂载、卸载"这四个异步操作组织成四条 Promise 链——每一条链都是"一组同类应用、各自并行完成同一个操作,再用 Promise.all 汇合"。这种组织方式的优雅之处在于:(1) 每条链只关心一种操作、逻辑清晰;(2) 每条链可以独立处理失败;(3) 链之间可以自然地表达时序依赖(比如 mount 链依赖 unmount 链完成);(4) 整体的错误恢复不需要复杂的 try-catch 嵌套。如果你正在设计一个需要编排多个异步操作的系统、"把同类操作归为一条 Promise 链"是一个非常值得学习的模式——它比"嵌套回调"优雅、比"纯 async/await 线性代码"更能表达并行性。
toLoadPromise:加载应用代码
// single-spa 源码 - src/lifecycles/load.js(简化版)
export function toLoadPromise(app) {
return Promise.resolve().then(() => {
if (app.loadPromise) return app.loadPromise; // 防重复加载
if (app.status !== NOT_LOADED && app.status !== LOAD_ERROR) return app;
app.status = LOADING_SOURCE_CODE;
let isUserErr;
return (app.loadPromise = Promise.resolve()
.then(() => {
const loadPromise = app.loadApp(getProps(app));
if (!smellsLikeAPromise(loadPromise)) {
isUserErr = true; // loader 没返回 Promise:这是使用者的错,直接判 BROKEN
throw Error(/* #33 loading function did not return a promise */);
}
return loadPromise.then((appOpts) => {
app.loadErrorTime = null;
// 校验生命周期函数:mount / unmount 必需,bootstrap 可省略(v5.6.0 起)
let validationErrMessage, validationErrCode; // 前者仅 __DEV__ 构建下赋值,此处略
if (typeof appOpts !== "object") validationErrCode = 34;
if (Object.prototype.hasOwnProperty.call(appOpts, "bootstrap") &&
!validLifecycleFn(appOpts.bootstrap)) validationErrCode = 35;
if (!validLifecycleFn(appOpts.mount)) validationErrCode = 36;
if (!validLifecycleFn(appOpts.unmount)) validationErrCode = 37;
if (validationErrCode) {
// 校验失败不进 LOAD_ERROR,而是 SKIP_BECAUSE_BROKEN——重试也没用
handleAppError(validationErrMessage, app, SKIP_BECAUSE_BROKEN);
return app;
}
// 将生命周期函数挂载到内部对象
app.status = NOT_BOOTSTRAPPED;
app.bootstrap = flattenFnArray(appOpts, "bootstrap");
app.mount = flattenFnArray(appOpts, "mount");
app.unmount = flattenFnArray(appOpts, "unmount");
app.unload = flattenFnArray(appOpts, "unload");
app.timeouts = ensureValidAppTimeouts(appOpts.timeouts);
delete app.loadPromise;
return app;
});
})
.catch((err) => {
delete app.loadPromise;
let newStatus;
if (isUserErr) {
newStatus = SKIP_BECAUSE_BROKEN;
} else {
newStatus = LOAD_ERROR; // 网络等原因加载失败:可重试
app.loadErrorTime = new Date().getTime();
}
handleAppError(err, app, newStatus); // transformErr 内部会写入 app.status
return app;
}));
});
}
注意三种失败的归宿不同:loader 没返回 Promise 或导出的生命周期不合法,进 SKIP_BECAUSE_BROKEN(重试没有意义);loader 的 Promise 被拒绝(典型的是网络错误),才进可重试的 LOAD_ERROR。
flattenFnArray 是一个关键的辅助函数——single-spa 允许生命周期函数是数组形式,它用 reduce 把多个函数串成 Promise 链:
// src/lifecycles/lifecycle.helpers.js:15
// 将数组形式的生命周期函数串行化
export function flattenFnArray(appOrParcel, lifecycle) {
let fns = appOrParcel[lifecycle] || [];
fns = Array.isArray(fns) ? fns : [fns];
if (fns.length === 0) fns = [() => Promise.resolve()];
return function (props) {
return fns.reduce(
(chain, fn) =>
chain.then(() => {
const thisPromise = fn(props);
// 每个生命周期函数都必须返回 Promise,否则整条链 reject(错误码 #15)
return smellsLikeAPromise(thisPromise)
? thisPromise
: Promise.reject(/* #15 did not return a promise */);
}),
Promise.resolve()
);
};
}
这让开发者可以把初始化逻辑拆分为多个步骤:
export const bootstrap = [
async () => { await loadConfig(); },
async () => { await initI18n(); },
async () => { await initStore(); },
];
toBootstrapPromise:初始化应用
// single-spa 源码 - src/lifecycles/bootstrap.js(简化版)
export function toBootstrapPromise(appOrParcel, hardFail) {
return Promise.resolve().then(() => {
if (appOrParcel.status !== NOT_BOOTSTRAPPED) return appOrParcel;
appOrParcel.status = BOOTSTRAPPING;
if (!appOrParcel.bootstrap) {
// 防御分支:没有 bootstrap 时直接进入 NOT_MOUNTED
// (省略 bootstrap 的应用在 load 时已被 flattenFnArray 补成空操作,并不走这里)
return Promise.resolve().then(successfulBootstrap);
}
return reasonableTime(appOrParcel, "bootstrap")
.then(successfulBootstrap)
.catch((err) => {
if (hardFail) throw transformErr(err, appOrParcel, SKIP_BECAUSE_BROKEN);
handleAppError(err, appOrParcel, SKIP_BECAUSE_BROKEN);
return appOrParcel;
});
});
function successfulBootstrap() {
appOrParcel.status = NOT_MOUNTED;
return appOrParcel;
}
}
reasonableTime(src/applications/timeouts.js)是 single-spa 的超时控制——默认 bootstrap 超时 4000ms、mount / unmount / unload / update 超时 3000ms,每隔 warningMillis(默认 1000ms)打一次 console.warn。超时不一定导致失败:dieOnTimeout 默认为 false,到期只 console.error 一条错误并继续等待;设为 true 时才 reject。全局默认可用 setBootstrapMaxTime / setMountMaxTime / setUnmountMaxTime / setUnloadMaxTime 调整,单个应用也可以在导出对象里带 timeouts 字段覆盖(ensureValidAppTimeouts 负责合并)。
toMountPromise:挂载到 DOM
// single-spa 源码 - src/lifecycles/mount.js(简化版)
export function toMountPromise(appOrParcel, hardFail) {
return Promise.resolve().then(() => {
if (appOrParcel.status !== NOT_MOUNTED) return appOrParcel;
if (!beforeFirstMountFired) {
// 全局只触发一次
window.dispatchEvent(new CustomEvent("single-spa:before-first-mount"));
beforeFirstMountFired = true;
}
appOrParcel.status = MOUNTING;
return reasonableTime(appOrParcel, "mount")
.then(() => {
appOrParcel.status = MOUNTED;
if (!firstMountFired) {
window.dispatchEvent(new CustomEvent("single-spa:first-mount"));
firstMountFired = true;
}
return appOrParcel;
})
.catch((err) => {
// mount 失败:先"假装成功"让 unmount 能清理半成品 DOM
appOrParcel.status = MOUNTED;
return toUnmountPromise(appOrParcel, true).then(
setSkipBecauseBroken,
setSkipBecauseBroken // unmount 本身失败也照样判 BROKEN
);
function setSkipBecauseBroken() {
if (hardFail) throw transformErr(err, appOrParcel, SKIP_BECAUSE_BROKEN);
handleAppError(err, appOrParcel, SKIP_BECAUSE_BROKEN);
return appOrParcel;
}
});
});
}
mount 失败时的处理非常精妙——先把状态设为 MOUNTED,再调用 toUnmountPromise 清理。因为 mount 函数可能已经渲染了部分 DOM,不执行 unmount 清理会在页面上留下"半成品"。
注意 toMountPromise 自己不做 shouldBeActive 检查——"等待其他应用 unmount 的过程中用户又导航走了"这种竞态,是由外层的 tryToBootstrapAndMount(7.4.5)在 mount 前二次确认来防护的;Parcel 的 mount 走的也是这个函数,它没有 activeWhen 可查。
toUnmountPromise:从 DOM 卸载
// single-spa 源码 - src/lifecycles/unmount.js(简化版)
export function toUnmountPromise(appOrParcel, hardFail) {
return Promise.resolve().then(() => {
if (appOrParcel.status !== MOUNTED) return appOrParcel;
appOrParcel.status = UNMOUNTING;
// 先卸载所有子 Parcel
const unmountChildrenParcels = Object.keys(appOrParcel.parcels)
.map((id) => appOrParcel.parcels[id].unmountThisParcel());
return Promise.all(unmountChildrenParcels)
.then(unmountAppOrParcel, (parcelError) => {
// 子 Parcel 卸载失败:自己照样 unmount,但事后把自己也判为 BROKEN
return unmountAppOrParcel().then(() => {
const parentError = Error(parcelError.message);
if (hardFail) throw transformErr(parentError, appOrParcel, SKIP_BECAUSE_BROKEN);
handleAppError(parentError, appOrParcel, SKIP_BECAUSE_BROKEN);
});
})
.then(() => appOrParcel);
function unmountAppOrParcel() {
return reasonableTime(appOrParcel, "unmount").then(
() => { appOrParcel.status = NOT_MOUNTED; },
(err) => {
if (hardFail) throw transformErr(err, appOrParcel, SKIP_BECAUSE_BROKEN);
handleAppError(err, appOrParcel, SKIP_BECAUSE_BROKEN);
}
);
}
});
}
关键设计:卸载时先卸载所有子 Parcel;子 Parcel 卸载失败并不会阻止父应用自己的 unmount 执行,但父应用最终会被标记为 SKIP_BECAUSE_BROKEN。hardFail 参数决定失败时是抛出(Parcel 与 mount 失败后的清理走这条路)还是交给 handleAppError 吞掉(reroute 走这条路)。
7.4.5 tryToBootstrapAndMount:串联与时序保证
// src/navigation/reroute.js:443
function tryToBootstrapAndMount(app, unmountAllPromise) {
if (shouldBeActive(app)) {
return toBootstrapPromise(app).then((app) =>
unmountAllPromise.then(() =>
shouldBeActive(app) ? toMountPromise(app) : app
)
);
} else {
return unmountAllPromise.then(() => app);
}
}
这里隐藏着一个关键决策:mount 必须等待所有 unmount 完成。 因为 single-spa 没有内建沙箱——如果旧应用还没卸载完毕,新应用就开始挂载,全局变量和 DOM 操作可能冲突。
同时注意两次 shouldBeActive 检查——函数入口一次,unmount 完成后一次。因为等待 unmount 的过程中,用户可能又导航了。这种"双重检查"模式在并发编程中非常常见。
完整执行时序:
时间线(/order → /product):
────────────────────────────────────────────────→
│ order-app: unmount ──────┐ │
│ product-app: load ───────┤ (并行执行) │
│ product-app: bootstrap ──┤ │
│ (等待 unmount) ────┤ │
│ ├─ product: mount │
└───────────────────────────┴───────────────────┘
▲ unmount 完成点
7.4.6 自定义事件与可扩展性
reroute 在执行过程中触发一系列自定义事件,为上层框架提供扩展点:
// single-spa 触发的事件(按一次 reroute 中的先后顺序)
'single-spa:before-app-change' / 'single-spa:before-no-app-change' // 分类完成,有/无应用变更
'single-spa:before-routing-event' // 路由变化前,detail.cancelNavigation() 可取消本次导航
'single-spa:before-mount-routing-event' // 所有卸载完成、挂载开始前
'single-spa:before-first-mount' / 'single-spa:first-mount' // 全局第一个应用挂载前/后,只触发一次
'single-spa:app-change' / 'single-spa:no-app-change' // 本次 reroute 有/无应用变更
'single-spa:routing-event' // 路由处理完成
乾坤用到了其中两个:setDefaultMountApp 监听 single-spa:no-app-change,发现没有任何应用挂载时导航到默认应用;预加载策略 prefetchAfterFirstMounted 监听 single-spa:first-mount,在第一个应用挂载完成后再去预取其余应用的资源(分别在 qiankun v2.10.16 的 src/effects.ts 与 src/prefetch.ts):
// qiankun src/prefetch.ts(精简)
function prefetchAfterFirstMounted(apps: AppMetadata[], opts?: ImportEntryOpts): void {
window.addEventListener('single-spa:first-mount', function listener() {
const notLoadedApps = apps.filter((app) => getAppStatus(app.name) === NOT_LOADED);
notLoadedApps.forEach(({ entry }) => prefetch(entry, opts));
window.removeEventListener('single-spa:first-mount', listener);
});
}
至于沙箱的激活 / 失活,乾坤并不是靠这些事件,而是把 mountSandbox / unmountSandbox 直接塞进了它替子应用注册给 single-spa 的 mount / unmount 生命周期数组里(src/loader.ts)——这正是上一节 flattenFnArray 支持数组形式生命周期的用武之地。
🔥 深度洞察:single-spa 的"可组合性"设计
single-spa 的自定义事件系统揭示了它的另一个设计哲学:可组合性优于完备性。 它不试图成为"全家桶"式微前端框架,而是通过暴露丰富的事件和钩子,让上层框架在不修改源码的前提下扩展功能。这种"核心精简 + 事件扩展"的模式降低了贡献门槛、增加了适应性、也减少了核心代码的复杂度。如果你正在设计一个框架,single-spa 的这种设计模式值得深入学习。
7.4.7 错误处理策略
错误处理是任何生产级系统的"灵魂考验"——一个系统能否在意外情况下优雅降级、能否在局部故障时保证整体可用、能否给用户留下"有东西坏了但大部分还能用"的印象——这些都考验架构师对"故障即常态"的深度认知。single-spa 的"分层错误处理"很好地诠释了这种认知——底层(每个应用的生命周期函数)负责抛出具体错误、上层(reroute 函数)负责捕获并分发、最顶层(全局错误处理器)负责上报监控。这种分层的好处是:底层不需要考虑"这个错误会不会影响其他应用"、只需要忠实地报告自己的问题;上层不需要关心具体错误内容、只需要根据错误类型决定"重试还是跳过";顶层不需要处理业务逻辑、只需要把错误推送到监控系统。这种"关注点分离"的错误处理架构、在任何一个需要处理多方故障的系统里都适用——浏览器的错误边界、Kubernetes 的 Pod 重启策略、数据库的事务回滚机制、都是类似思想的不同实现。
single-spa 的错误处理是分层的:
// src/applications/app-errors.js:为讲解方便把 addErrorHandler(:17)挪到了
// handleAppError(:5)前面,抛错那句也略去了 formatErrorMessage / __DEV__ 包装
// 第一层:全局错误处理器
let errorHandlers = []; // :3——是 let 不是 const,removeErrorHandler 会整体重新赋值
export function addErrorHandler(handler) {
if (typeof handler !== 'function')
throw Error('a single-spa error handler must be a function'); // 错误码 #28
errorHandlers.push(handler);
}
// 第二层:handleAppError——所有应用级错误的统一入口
export function handleAppError(err, app, newStatus) {
const transformedErr = transformErr(err, app, newStatus);
if (errorHandlers.length) {
errorHandlers.forEach((handler) => handler(transformedErr));
} else {
// 没有注册处理器时,抛到全局
setTimeout(() => { throw transformedErr; });
}
}
// 第三层:transformErr——标准化错误对象(:58,bootstrap / mount / unmount 的 hardFail 分支也直接调用它)
export function transformErr(ogErr, appOrParcel, newStatus) {
const errPrefix = `${objectType(appOrParcel)} '${toName(
appOrParcel
)}' died in status ${appOrParcel.status}: `;
let result;
if (ogErr instanceof Error) {
try { ogErr.message = errPrefix + ogErr.message; } catch (err) { /* message 只读时无能为力 */ }
result = ogErr;
} else {
// 用非 Error 值 reject 会丢失堆栈,这里 console.warn 提醒后包装成 Error
result = Error(errPrefix + JSON.stringify(ogErr));
}
result.appOrParcelName = toName(appOrParcel);
appOrParcel.status = newStatus; // 先拼好错误信息(引用旧状态),再更新应用状态
return result;
}
这个三层设计有几个值得注意的特点:
-
全局错误处理器允许上层框架统一捕获所有微前端相关的错误,实现集中式日志和监控。
-
错误不会中断调度。当一个应用的生命周期函数出错时,single-spa 会把该应用标记为 SKIP_BECAUSE_BROKEN 或 LOAD_ERROR,但不会影响其他应用的调度。这是微前端架构的重要特性——一个子应用崩溃不应该导致整个系统瘫痪。
-
错误信息包含上下文。
transformErr在错误消息前加上应用名称和当前状态,当你看到 "application 'order-app' died in status MOUNTING" 时,立刻就能定位是哪个应用在什么阶段出了问题。
// 在主应用中注册全局错误处理器
import { addErrorHandler } from 'single-spa';
addErrorHandler((err) => {
console.error('[micro-frontend error]', err);
console.error('Application:', err.appOrParcelName);
// 上报到监控系统
monitor.reportError({
type: 'micro-frontend',
app: err.appOrParcelName,
message: err.message,
stack: err.stack,
});
// 可选:展示降级 UI
showFallbackUI(err.appOrParcelName);
});
7.4.8 start() 与 reroute 的关系
start() 把系统从"注册阶段"切换到"执行阶段":之前 registerApplication 只是在收集定义;之后 single-spa 才真正监听路由、触发加载与挂载。这种"先注册、后启动"的两阶段初始化是很多框架的共同模式(Webpack plugins vs 构建、Express middleware vs listen、React 组件定义 vs 首次渲染),它允许在启动前自由调整配置,也把副作用集中到一个显式时间点便于性能观测。
// src/start.js:5
let started = false;
export function start(opts) {
started = true;
if (isInBrowser) {
patchHistoryApi(opts); // 劫持 History API、注册 popstate/hashchange 监听
reroute();
}
}
export function isStarted() {
return started;
}
在 reroute 内部,isStarted() 决定了两种完全不同的行为——start 之前只加载不挂载,start 之后执行完整流程。registerApplication 还会在首次调用时起一个 5000ms 的定时器:到时还没 start(),就打一条 console.warn 提醒你(src/applications/apps.js:107-121)。这让开发者可以在调用 start() 之前先注册所有应用(触发预加载),等主应用的全局布局渲染完成后再 start:
// 注册与启动分离
registerApplication({ name: 'app-a', ... }); // 触发预加载
registerApplication({ name: 'app-b', ... }); // 触发预加载
document.addEventListener('DOMContentLoaded', () => {
renderMainLayout().then(() => {
start(); // DOM 容器就绪后才开始挂载子应用
});
});
本章小结
- single-spa 的设计哲学是"路由即应用边界"——它只做调度,不做隔离、通信或资源管理,这种克制使其成为微前端的"最大公约数"框架
- registerApplication 通过参数归一化将多种调用形式统一为内部格式,注册完成后立即触发 reroute 重新评估当前路由
- 12 种应用状态构成了一个隐式状态机,覆盖了从加载到卸载的完整生命周期,包括加载失败、bootstrap 失败等异常分支
- reroute 是调度中枢,通过 getAppChanges 将应用分为四类(appsToLoad / appsToMount / appsToUnmount / appsToUnload),然后通过 Promise 链执行对应操作
- 四大 Promise 链各自承担加载、初始化、DOM 挂载和 DOM 卸载的职责,通过状态检查实现幂等性
- 并发控制通过 appChangeUnderway 标志位确保同一时刻只有一个 reroute 在执行
- mount 必须等待所有 unmount 完成——在没有沙箱的情况下,这是 single-spa 避免新旧应用同时挂载、相互干扰的时序手段
"先卸载再挂载"而不是"先挂载再卸载"——看似用户体验更差,实则是 single-spa 在没有沙箱的前提下避免新旧应用互相干扰的办法:若新旧两个应用同时挂载在页面上、共享同一个 window,很容易互相污染。它能隔开的也只是"一出一进"的这两个应用:新应用的 load 与 bootstrap 仍与旧应用的 unmount 并行,同时激活的多个应用也照样共处一个 window。用时序隔离替代空间隔离,与《Claude Code 源码深度解析》专栏第 14 章讨论的多 Agent 调度(消息驱动 + 显式生命周期)形成有趣对照——两者都是"多个有生命周期的实体在同一个进程里共存"这一问题在不同约束下的不同解法。
思考题
-
源码理解:single-spa 的
getAppChanges对处于 BOOTSTRAPPING、MOUNTING、UNMOUNTING 等"过渡状态"的应用不做任何操作。请分析这种设计选择的原因——如果对这些过渡状态的应用也做处理,会带来什么问题? -
设计权衡:single-spa 选择"先卸载所有旧应用,再挂载新应用"的策略。请对比"先挂载新应用,再卸载旧应用"这种策略的优劣。在什么场景下后者可能更好?
-
并发分析:假设用户在 200ms 内连续导航了 3 个不同的路由(/a → /b → /c),请画出 reroute 的执行时序图,说明最终哪些应用会被挂载。
-
架构对比:single-spa 使用隐式状态机,而不是显式状态机(如 XState)。请分析两种方案在微前端场景下的优缺点。
-
实践延伸:如果两个应用的
activeWhen条件重叠(即同时激活),会发生什么?single-spa 如何处理这种情况?这种处理方式有什么限制?