微前端源码精讲
第3章 乾坤架构总览
第3章 乾坤架构总览
好的框架不是让你什么都能做——而是让你在做对的事情时毫不费力,做错的事情时寸步难行。
本章要点
- 理解乾坤的三大设计哲学:HTML Entry、沙箱隔离、标准化生命周期
- 掌握乾坤的核心依赖关系:qiankun → single-spa → import-html-entry 的三层架构
- 通过源码走读完整理解子应用从注册到卸载的全生命周期
- 深入分析 registerMicroApps、loadApp、start 三大核心函数的实现
- 客观评估乾坤在 2026 年微前端生态中的真实地位与适用场景
本章引用的 qiankun 源码以 v2.10.16 为准(2.x 是稳定线,3.0 仍处于 rc 阶段),import-html-entry 以 v1.17.0 为准,single-spa 以 v6.0.3 为准。
2019 年 5 月,蚂蚁金服(今蚂蚁集团)前端团队在 GitHub 上开源了一个名为 qiankun(乾坤)的项目(仓库建于 5 月 15 日,同年 6 月 13 日发布 1.0)。彼时 single-spa 已经是微前端领域的事实标准,但它有一个让无数开发者头疼的问题:太底层了。single-spa 只负责子应用的注册和生命周期调度,至于子应用怎么加载、JS 怎么隔离、CSS 怎么隔离——全部由你自己解决。
这就像给你一个操作系统内核,告诉你"进程调度做好了,至于内存管理、文件系统、网络协议栈——自己写吧。"
乾坤的回答是:我来封装这一切。
它在 single-spa 的生命周期调度之上,增加了 HTML Entry(通过 import-html-entry 实现子应用加载)、JS 沙箱(ProxySandbox / LegacySandbox / SnapshotSandbox 三种实现)、CSS 隔离(Shadow DOM/Scoped CSS)三大核心能力,把微前端从"理论上可行"变成了"开箱即用"。
截至 2026 年 9 月,乾坤在 GitHub 上累计超过 16k star,npm 周下载量在 4 万到 8 万次之间波动(2026 年 6–9 月)。它不是最新的,也不是最"酷"的——但它是被大量生产环境验证过的微前端方案。在我们深入任何源码细节之前,先建立对它的全局架构认知,就像在徒步穿越一片森林之前,先在山顶看一眼全貌。
3.1 乾坤的设计哲学
乾坤的设计哲学可以浓缩为三个关键词:HTML Entry、沙箱、生命周期。这三者分别解决了微前端的三个核心问题:怎么加载子应用、怎么隔离子应用、怎么管理子应用。
3.1.1 HTML Entry:像使用 iframe 一样简单
HTML Entry 是乾坤对 single-spa 做的最重要的创新、也是它能在中文前端社区快速普及的关键。乾坤的主要作者 kuitos 在《Why Not Iframe》一文里解释过不选 iframe 的原因(这篇文章就是乾坤文档 docs/guide/README.zh.md 的「为什么不是 iframe」一节指向的那个链接)——隔离能力几乎完美,但 URL 不同步、UI 不同步(弹窗无法相对整个页面居中)、全局上下文完全隔离、每次进入都要重建整个上下文而慢。HTML Entry 想保留的是 iframe 那种"给一个 URL 就能接入"的简单,去掉的是它的过度隔离——接入方式像 iframe 一样简单(只要一个 URL)、但内部是通过 fetch + DOM 解析 + JS 执行的方式、让子应用"融入"到主应用的上下文里、而不是被"隔离"在 iframe 里。这种"模仿易用性、改造实现方式"的设计思路、在很多成功的技术产品里都能看到——比如 Bun 沿用了 Node.js 的 API、但用 Zig 重写了运行时。"借壳上市"的创新、往往比从零发明要更容易被市场接受。
在 single-spa 中,注册一个子应用需要你手动提供一个 JS Entry——一个 JavaScript 文件的 URL,single-spa 本身不负责加载,而是调用你提供的加载函数(通常借助 SystemJS)去加载它。这意味着你需要:
- 确保子应用的构建产物是加载器认得的模块格式(如 SystemJS 的 System.register 或 UMD)
- 手动管理子应用的 CSS 加载
- 处理子应用内部的静态资源路径问题
- 解决子应用多个 JS chunk 的加载顺序
// single-spa 的 JS Entry 模式——繁琐且易错
import { registerApplication } from 'single-spa';
registerApplication({
name: 'app-order',
// 你需要自己保证这个 JS 文件能正确导出生命周期
app: () => System.import('http://localhost:7100/app.js'),
activeWhen: '/order',
});
// 子应用必须打成加载器认得的格式(System.register / UMD)
// CSS?自己加载。
// 图片路径?自己处理。
// 多个 chunk?自己编排。
乾坤的 HTML Entry 彻底改变了这个局面。它的思路极其朴素——既然子应用本身就是一个完整的 Web 应用,有自己的 HTML 入口页面,为什么不直接获取这个 HTML,从中解析出 JS 和 CSS 资源?
// 乾坤的 HTML Entry 模式——简洁直观
import { registerMicroApps } from 'qiankun';
registerMicroApps([
{
name: 'app-order',
// 直接给子应用的 URL,就像在浏览器地址栏输入一样
entry: '//localhost:7100',
container: '#micro-app-container',
activeRule: '/order',
},
]);
这个设计的精妙之处在于:子应用完全不需要为接入微前端做任何构建配置上的妥协。它的 HTML 文件里引用了什么 JS、什么 CSS、什么字体文件——乾坤全部自动解析、自动加载。子应用可以继续作为独立应用运行,也可以作为微前端子应用被加载。
HTML Entry 的底层实现依赖 import-html-entry 这个库,它的核心逻辑我们将在 3.2 节详细分析。这里先建立一个直觉:
// import-html-entry 的核心能力(简化)
interface HtmlEntryResult {
// 子应用的 HTML 模板(script 标签被替换成注释占位,不是删掉)
template: string;
// 一个函数:执行所有提取出的 JS 脚本,返回子应用导出的生命周期
execScripts: () => Promise<{
bootstrap: () => Promise<void>;
mount: (props: any) => Promise<void>;
unmount: (props: any) => Promise<void>;
}>;
// 用于获取外部样式表的内容([{ src, value }])
getExternalStyleSheets: () => Promise<Array<{ src: string; value: string }>>;
// 用于获取外部脚本的内容
getExternalScripts: () => Promise<Array<{ src: string; value: string }>>;
// 静态资源的 publicPath(默认由 entry URL 推导)
assetPublicPath: string;
}
💡 深度洞察:HTML Entry 的设计思想本质上是"逆向 iframe"。iframe 直接加载整个页面但无法与主应用深度通信;HTML Entry 解析页面但在主应用的上下文中执行代码——它取了 iframe 的便利性(给一个 URL 就够了),又避免了 iframe 的隔离过度问题(跨域时难以共享登录态,路由不同步,DOM 无法直接互访)。这个设计决策奠定了乾坤"简单接入"的核心竞争力。
3.1.2 沙箱:隔离是微前端的生命线
如果说 HTML Entry 解决了"怎么加载",沙箱则解决了一个更根本的问题:多个子应用同时运行时,如何防止它们互相污染?
如果说 HTML Entry 是乾坤"易用性的王牌"、那么沙箱就是它"安全性的灵魂"。没有沙箱、微前端就是一堆互相倾轧的子应用、每个都可能因为另一个而崩溃;有了沙箱、每个子应用对全局变量的写入、以及定时器、事件监听、动态样式这些副作用、都被收拢在自己的范围内。沙箱这个概念本身、在计算机安全领域已经有半个世纪的历史——从操作系统的用户空间隔离、到浏览器的 Origin 隔离、到 Docker 的容器隔离、到 WebAssembly 的 capability 模型、都是"把不信任的代码限制在一个受控环境里运行"的具体实现。乾坤的 JS 沙箱、是这个古老思想在 JavaScript 运行时的一次新应用——它用 Proxy 这个 ES6 特性、在与主应用共用的同一个 JS 上下文里、给每个子应用隔出一份独立的全局变量空间。
JavaScript 的全局变量是所有微前端方案的噩梦。一个子应用在 window 上挂了一个 __APP_CONFIG__,另一个子应用也挂了同名属性——后者悄无声息地覆盖了前者。更隐蔽的是定时器:子应用 A 设了一个 setInterval,卸载时忘了清理,这个定时器就像幽灵一样在后台持续运行,污染后续加载的子应用。
乾坤为此设计了三种沙箱机制:
// qiankun/src/interfaces.ts:三种沙箱的类型标识
export enum SandBoxType {
Proxy = 'Proxy', // ProxySandbox:多例 Proxy 沙箱(默认)
Snapshot = 'Snapshot', // SnapshotSandbox:快照沙箱(不支持 Proxy 时的降级方案)
LegacyProxy = 'LegacyProxy', // LegacySandbox:单例 Proxy 沙箱(sandbox.loose 模式)
}
// Proxy 沙箱的核心思想(示意,非源码;完整实现见第 4 章)
class ProxySandbox {
private updatedValueSet = new Set<PropertyKey>();
private fakeWindow: Record<PropertyKey, any>;
private sandboxRunning = true;
proxy: WindowProxy;
constructor() {
const rawWindow = window;
// 创建一个假的 window 对象
this.fakeWindow = Object.create(null);
this.proxy = new Proxy(this.fakeWindow, {
get: (target, prop) => {
// 优先从 fakeWindow 获取(子应用设置的变量)
if (Object.prototype.hasOwnProperty.call(target, prop)) {
return target[prop];
}
// 否则从真实 window 获取(原生 API)
const value = rawWindow[prop as any];
// 如果是函数,绑定到真实 window(如 setTimeout)
return typeof value === 'function' ? value.bind(rawWindow) : value;
},
set: (target, prop, value) => {
if (this.sandboxRunning) {
target[prop] = value;
this.updatedValueSet.add(prop);
}
return true;
},
});
}
active() {
this.sandboxRunning = true;
}
inactive() {
this.sandboxRunning = false;
}
}
这段代码展示了 Proxy 沙箱的核心思想:每个子应用看到的 window 其实是一个代理对象。 子应用往 window 上写属性,实际写入的是 fakeWindow;读属性时先查 fakeWindow,找不到再查真实 window。这样多个子应用可以同时运行,各自拥有独立的"全局变量空间",互不干扰。
快照沙箱(SnapshotSandbox)则是面向不支持 Proxy 的旧浏览器的降级方案:
// 快照沙箱的简化实现
class SnapshotSandbox {
private windowSnapshot: Map<string, any> = new Map();
private modifyPropsMap: Map<string, any> = new Map();
active() {
// 激活时,拍下 window 的快照
for (const prop in window) {
this.windowSnapshot.set(prop, (window as any)[prop]);
}
// 恢复上次子应用运行时的修改
this.modifyPropsMap.forEach((value, prop) => {
(window as any)[prop] = value;
});
}
inactive() {
// 失活时,记录子应用的修改,然后恢复 window
for (const prop in window) {
if ((window as any)[prop] !== this.windowSnapshot.get(prop)) {
// 记录修改
this.modifyPropsMap.set(prop, (window as any)[prop]);
// 恢复原值
(window as any)[prop] = this.windowSnapshot.get(prop);
}
}
}
}
💡 深度洞察:快照沙箱有一个致命限制——它是单例的。因为它直接操作真实的 window 对象,同一时刻只能有一个子应用处于激活状态。而 Proxy 沙箱通过虚拟 window 实现了多例隔离,可以同时运行多个子应用。这也是为什么乾坤 FAQ(「qiankun 能兼容 ie 吗?」)说,在不支持 Proxy 的浏览器里只能使用单实例模式。理解这个区别,能帮你避开生产环境中最常见的沙箱配置陷阱。
3.1.3 生命周期:子应用的生老病死
"生命周期"这个词、在计算机科学里是一个非常朴素但深刻的概念——它承认了一件事:任何运行时实体都不是永恒存在的、它有"被创建"、"被激活"、"被使用"、"被休眠"、"被销毁"这些不同阶段;每个阶段都需要做不同的事情。这个概念来自真实世界的生物学类比、也被无数软件系统采纳——操作系统里的进程生命周期、React 组件的挂载/更新/卸载、Spring Bean 的 init/destroy、Kubernetes Pod 的 Pending/Running/Succeeded、数据库连接的 open/query/close——全都是同一个心智模型的不同实现。乾坤(以及它底层的 single-spa)把这个模型应用到了微前端子应用上——每个子应用都有自己的"生"(bootstrap)、"醒"(mount)、"眠"(unmount);single-spa 还定义了"死"(unload)、乾坤 2.x 没有把它透传给子应用。理解这套生命周期不只是理解乾坤——它是理解"任何具有可变生命态的软件实体"的通用钥匙。
微前端中的子应用不是"加载一次就完事"的静态资源——它有完整的生命周期。乾坤(通过 single-spa)定义了三个核心生命周期钩子:
// 子应用必须导出的三个生命周期函数
import ReactDOM from 'react-dom/client';
import App from './App';
// 乾坤传入的 props 里带着 container(开启 Shadow DOM 时是 shadowRoot)
type MicroAppProps = { container: HTMLElement | ShadowRoot };
export async function bootstrap(): Promise<void> {
// 初始化:只在子应用第一次加载时调用一次
// 适合做一次性的初始化工作,如加载 polyfill
console.log('[order-app] bootstrapped');
}
let root: ReactDOM.Root | null = null;
export async function mount(props: MicroAppProps): Promise<void> {
// 挂载:每次子应用被激活时调用
// 在这里创建根组件、渲染 DOM
const { container } = props;
root = ReactDOM.createRoot(container.querySelector('#root')!);
root.render(<App />);
}
export async function unmount(): Promise<void> {
// 卸载:每次子应用被切走时调用
// 在这里销毁根组件、清理副作用(必须卸载 mount 时创建的那个 root)
root?.unmount();
root = null;
}
这三个钩子看起来简单,但它们的调用时机和语义是整个微前端协调的基础。乾坤在 single-spa 的基础上增强了这些生命周期:
// 乾坤增强的生命周期钩子(框架侧,非子应用侧)——qiankun/src/interfaces.ts(简化:省略泛型约束,源码用 type 定义)
type LifeCycleFn<T> = (app: LoadableApp<T>, global: typeof window) => Promise<any>;
interface FrameworkLifeCycles<T> {
beforeLoad?: LifeCycleFn<T> | Array<LifeCycleFn<T>>; // 加载前(执行子应用脚本之前)
beforeMount?: LifeCycleFn<T> | Array<LifeCycleFn<T>>; // 挂载前
afterMount?: LifeCycleFn<T> | Array<LifeCycleFn<T>>; // 挂载后
beforeUnmount?: LifeCycleFn<T> | Array<LifeCycleFn<T>>; // 卸载前
afterUnmount?: LifeCycleFn<T> | Array<LifeCycleFn<T>>; // 卸载后
}
// 使用示例
registerMicroApps(apps, {
beforeLoad: async (app) => {
console.log(`[主应用] ${app.name} 即将加载...`);
// 可以在这里做权限校验、加载提示等
},
afterMount: async (app) => {
console.log(`[主应用] ${app.name} 已挂载`);
// 可以在这里做埋点、性能监控等
},
});
下图展示了乾坤三大设计哲学如何分别解决微前端核心问题:
flowchart TD
subgraph 乾坤三大支柱
HE["HTML Entry<br/>解决: 怎么加载子应用"]
SB["沙箱隔离<br/>解决: 怎么隔离子应用"]
LC["生命周期<br/>解决: 怎么管理子应用"]
end
HE --> HE1["fetch HTML"]
HE --> HE2["解析JS/CSS资源"]
HE --> HE3["子应用零改造接入"]
SB --> SB1["Proxy多实例沙箱"]
SB --> SB2["Snapshot快照沙箱"]
SB --> SB3["副作用劫持与清理"]
LC --> LC1["bootstrap: 一次性初始化"]
LC --> LC2["mount: 每次激活渲染"]
LC --> LC3["unmount: 每次切走清理"]
style HE fill:#e3f2fd,stroke:#1565c0
style SB fill:#fce4ec,stroke:#c62828
style LC fill:#e8f5e9,stroke:#2e7d32
这三个设计哲学——HTML Entry、沙箱、生命周期——构成了乾坤的三根支柱。接下来我们俯瞰乾坤的依赖架构,理解这三根支柱是如何在代码层面组织起来的。
3.2 核心依赖关系:qiankun → single-spa → import-html-entry
乾坤的分层、是开源软件"站在巨人肩膀上"的优秀范本。一个新框架在设计时、总要面对一个选择——是"自己重写一切"、还是"基于现有方案做增强"?前者更有"掌控感"、但会让开发周期变长;后者更快、但要承担上游的技术债。乾坤团队的选择是后者——他们基于已经在国外社区成熟的 single-spa 做了"针对中文企业级场景的增强"。这个决策的本质、是一次"价值链上的定位"——他们不去解决"路由拦截"这种底层问题(single-spa 已经解决了)、而是专注于解决"HTML Entry、Proxy 沙箱、多应用协调"这些中国企业微前端场景里的痛点。这种"把问题分层、自己只做最有价值的那一层"的思维、在所有成功的开源项目里都能看到——Next.js 基于 React 做了路由和 SSR、Vite 基于 esbuild 和 Rollup 做了开发体验、pnpm 基于 npm 的语义做了更高效的存储方案。好的架构决策、不是"重新发明一切"、而是"在正确的层面做创新"。
3.2.1 三层架构
乾坤的代码架构可以用一张依赖图概括:
┌─────────────────────────────────────────────────────┐
│ qiankun (乾坤) │
│ │
│ ┌─────────────────┐ ┌────────────┐ ┌───────────┐ │
│ │ JS/CSS 沙箱 │ │ HTML Entry │ │ 全局状态 │ │
│ │ (Proxy/Snapshot)│ │ 适配层 │ │ 通信管理 │ │
│ └────────┬────────┘ └─────┬──────┘ └───────────┘ │
│ │ │ │
├───────────┼─────────────────┼─────────────────────────┤
│ │ single-spa│ │
│ │ ┌─────────────┴────────────┐ │
│ │ │ 应用注册 / 路由匹配 │ │
│ │ │ 生命周期调度 │ │
│ │ │ 状态机管理 │ │
│ │ └──────────────────────────┘ │
├───────────┼───────────────────────────────────────────┤
│ │ import-html-entry │
│ │ ┌──────────────────────────┐ │
│ │ │ HTML 获取与解析 │ │
│ │ │ Script/Style 资源提取 │ │
│ │ │ JS 执行(with 沙箱) │ │
│ │ └──────────────────────────┘ │
└───────────┴───────────────────────────────────────────┘
每一层的职责非常清晰:
- import-html-entry(底层):负责获取子应用的 HTML,从中提取 JS 和 CSS 资源,并提供在沙箱环境中执行 JS 的能力
- single-spa(中层):负责子应用的注册、路由监听、生命周期状态机管理——它决定何时加载、挂载、卸载子应用
- qiankun(上层):在前两者之上,添加了沙箱隔离、预加载、全局状态管理、错误处理等生产级能力
3.2.2 single-spa:生命周期的调度中枢
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'; // 正在 unload
export const LOAD_ERROR = 'LOAD_ERROR'; // 加载函数抛错(可重试)
export const SKIP_BECAUSE_BROKEN = 'SKIP_BECAUSE_BROKEN'; // 应用自身出错,此后跳过它
// 状态流转:
// NOT_LOADED → LOADING_SOURCE_CODE → NOT_BOOTSTRAPPED
// → BOOTSTRAPPING → NOT_MOUNTED
// → MOUNTING → MOUNTED
// → UNMOUNTING → NOT_MOUNTED(可重新挂载)
// → UNLOADING → NOT_LOADED(完全卸载,需重新加载)
下图是 single-spa 子应用状态机的完整流转:
stateDiagram-v2
[*] --> NOT_LOADED
NOT_LOADED --> LOADING_SOURCE_CODE: 路由匹配,开始加载
LOADING_SOURCE_CODE --> NOT_BOOTSTRAPPED: 代码加载完成
LOADING_SOURCE_CODE --> LOAD_ERROR: 加载函数的 Promise 被拒绝(网络失败/脚本执行抛错)
LOAD_ERROR --> LOADING_SOURCE_CODE: 200ms 后可重试
LOADING_SOURCE_CODE --> SKIP_BECAUSE_BROKEN: 加载函数未返回 Promise/导出缺少生命周期
NOT_BOOTSTRAPPED --> BOOTSTRAPPING: 首次初始化
BOOTSTRAPPING --> NOT_MOUNTED: bootstrap完成
NOT_MOUNTED --> MOUNTING: 路由匹配,开始挂载
MOUNTING --> MOUNTED: mount完成,子应用可见
MOUNTED --> UNMOUNTING: 路由不再匹配
UNMOUNTING --> NOT_MOUNTED: unmount完成,可再次挂载
NOT_MOUNTED --> UNLOADING: 完全卸载
UNLOADING --> NOT_LOADED: unload完成,需重新加载
BOOTSTRAPPING --> SKIP_BECAUSE_BROKEN: 生命周期抛错
MOUNTING --> SKIP_BECAUSE_BROKEN: 生命周期抛错
single-spa 的路由监听机制是子应用自动切换的基础:
// single-spa 路由监听的核心实现(src/navigation/navigation-events.js,简化;reroute 与 tryToBootstrapAndMount 在 src/navigation/reroute.js)
function urlReroute() {
reroute([], arguments);
}
function patchedUpdateState(updateState, methodName) {
return function () {
const urlBefore = window.location.href;
const result = updateState.apply(this, arguments);
const urlAfter = window.location.href;
// urlRerouteOnly 为 true(默认)时只有 URL 真的变了才触发;
// 触发方式不是直接调 reroute,而是派发一个人造的 popstate 事件,
// 这样其他子应用里的路由库也能感知到这次导航
if (!urlRerouteOnly || urlBefore !== urlAfter) {
window.dispatchEvent(createPopStateEvent(window.history.state, methodName));
}
return result;
};
}
// start() 时执行:监听 hashchange/popstate,并 patch pushState/replaceState
window.addEventListener('hashchange', urlReroute);
window.addEventListener('popstate', urlReroute);
window.history.pushState = patchedUpdateState(window.history.pushState, 'pushState');
window.history.replaceState = patchedUpdateState(window.history.replaceState, 'replaceState');
// 另外还会 patch window.addEventListener/removeEventListener:
// 子应用注册的 hashchange/popstate 监听被"截留",等 reroute 完成后再统一回放
// reroute:微前端调度的心脏
function reroute() {
const {
appsToLoad, // 需要加载的应用
appsToMount, // 需要挂载的应用
appsToUnmount, // 需要卸载的应用
} = getAppChanges(); // 根据当前 URL 和 activeWhen 计算
// 卸载不再需要的应用
const unmountAllPromise = Promise.all(appsToUnmount.map(toUnmountPromise));
// 加载 + bootstrap 与卸载并行进行,但 mount 必须等所有卸载完成
const loadThenMountPromises = appsToLoad.map((app) =>
toLoadPromise(app).then((app) => tryToBootstrapAndMount(app, unmountAllPromise)),
);
const mountPromises = appsToMount.map((app) => tryToBootstrapAndMount(app, unmountAllPromise));
return unmountAllPromise.then(() => Promise.all([...loadThenMountPromises, ...mountPromises]));
}
function tryToBootstrapAndMount(app, unmountAllPromise) {
return toBootstrapPromise(app).then((app) => unmountAllPromise.then(() => toMountPromise(app)));
}
💡 深度洞察:single-spa 的
reroute函数是整个微前端调度的"心跳"。每次路由变化都会触发一次 reroute,它负责计算哪些应用需要卸载、哪些需要加载、哪些需要挂载。这个设计有一个精妙之处:加载和 bootstrap 与旧应用的卸载并行,但 mount 一定等所有卸载完成之后(unmountAllPromise.then(() => toMountPromise(app)))。这避免了新旧子应用同时挂载时的资源竞争问题。但这也意味着应用切换不可能是"无缝"的——新应用挂载之前,旧应用一定已经从 DOM 中消失。另外注意"加载与卸载并行"这一点:乾坤的loadApp在singular模式下会额外await prevAppUnmountedDeferred.promise,就是为了在执行新子应用脚本之前等上一个子应用真正卸载完。
3.2.3 import-html-entry:HTML 的拆解与执行
import-html-entry 是乾坤"HTML Entry"能力的底层实现。它做了三件事:
// import-html-entry 的核心流程
import { importEntry } from 'import-html-entry';
// 第一步:获取 HTML 并解析
const {
template, // 处理后的 HTML 模板(script 标签换成注释占位,外链 CSS 已内联成 <style>)
execScripts, // 执行所有提取出的 JS
getExternalStyleSheets, // 获取外部 CSS 内容
getExternalScripts, // 获取外部 JS 内容
assetPublicPath, // 静态资源 publicPath
} = await importEntry('//localhost:7100');
// 第二步:将模板插入容器
document.querySelector('#container')!.innerHTML = template;
// 第三步:在沙箱中执行 JS,获取子应用的生命周期导出
const appExports = await execScripts(
sandboxProxy, // 沙箱的 proxy 对象作为全局上下文
true // strictGlobal:是否用 with(proxy) 把裸全局变量的查找也导向 proxy
);
importEntry 内部的 HTML 解析过程是这样的:
// import-html-entry/src/process-tpl.js(v1.17.0,节选并简化;genLinkReplaceSymbol 原型还带一个区分 prefetch/preload 的可选参数,
// genScriptReplaceSymbol 还带区分 async、cors 的两个可选参数,这里省掉了)
const ALL_SCRIPT_REGEX = /(<script[\s\S]*?>)[\s\S]*?<\/script>/gi;
const SCRIPT_SRC_REGEX = /.*\ssrc=('|")?([^>'"\s]+)/;
const SCRIPT_ENTRY_REGEX = /.*\sentry\s*.*/;
const SCRIPT_ASYNC_REGEX = /.*\sasync\s*.*/;
const LINK_TAG_REGEX = /<(link)\s+.*?>/isg;
const STYLE_TYPE_REGEX = /\s+rel=('|")?stylesheet\1.*/;
const STYLE_HREF_REGEX = /.*\shref=('|")?([^>'"\s]+)/;
const HTML_COMMENT_REGEX = /<!--([\s\S]*?)-->/g;
export const genLinkReplaceSymbol = (linkHref) => `<!-- link ${linkHref} replaced by import-html-entry -->`;
export const genScriptReplaceSymbol = (scriptSrc) => `<!-- script ${scriptSrc} replaced by import-html-entry -->`;
export const inlineScriptReplaceSymbol = `<!-- inline scripts replaced by import-html-entry -->`;
export default function processTpl(tpl, baseURI, postProcessTemplate) {
let scripts = [];
const styles = [];
let entry = null;
const template = tpl
// 先去掉 HTML 注释,避免注释里的 <script> 被误匹配
.replace(HTML_COMMENT_REGEX, '')
// <link rel="stylesheet" href="..."> → 收集 href(相对路径按 baseURI 补全),换成注释占位
.replace(LINK_TAG_REGEX, (match) => {
if (match.match(STYLE_TYPE_REGEX)) {
const styleHref = match.match(STYLE_HREF_REGEX);
if (styleHref) {
let href = styleHref[2];
if (!hasProtocol(href)) href = getEntirePath(href, baseURI); // new URL(href, baseURI)
styles.push(href);
return genLinkReplaceSymbol(href);
}
}
return match;
})
// <script> → 外链脚本收集 src(带 entry 属性的记为入口;async 脚本记为对象),内联脚本收集整段标签
.replace(ALL_SCRIPT_REGEX, (match, scriptTag) => {
if (scriptTag.match(SCRIPT_SRC_REGEX)) {
let src = scriptTag.match(SCRIPT_SRC_REGEX)[2];
if (!hasProtocol(src)) src = getEntirePath(src, baseURI);
if (scriptTag.match(SCRIPT_ENTRY_REGEX)) entry = entry || src;
const async = !!scriptTag.match(SCRIPT_ASYNC_REGEX);
scripts.push(async ? { async, src } : src);
return genScriptReplaceSymbol(src);
}
scripts.push(match); // 内联脚本:保留原始标签文本,执行时再用 getInlineCode 取出内容
return inlineScriptReplaceSymbol;
});
return {
template,
scripts,
styles,
// 没有显式 entry 属性时,最后一个 script 就是入口
entry: entry || scripts[scripts.length - 1],
};
}
几个和"想当然"不一样的细节:script 标签不是被删掉而是被替换成一条 HTML 注释(方便排查);<link rel="stylesheet"> 同样换成注释占位,随后 importHTML 会调用 getEmbedHTML 把外链 CSS 下载下来、以 <style>/* href */...</style> 的形式内联回模板——所以乾坤拿到的 template 里已经没有外链样式表了;此外,type="module"/nomodule 脚本会按浏览器能力二选一,带 ignore 属性的资源会被跳过,这些是 processTpl 里额外的分支。
特别值得关注的是 execScripts 的实现——它是乾坤沙箱能力的基石:
// import-html-entry/src/index.js(v1.17.0,节选)
function getExecutableScript(scriptSrc, scriptText, opts = {}) {
const { proxy, strictGlobal, scopedGlobalVariables = [] } = opts;
const sourceUrl = isInlineCode(scriptSrc) ? '' : `//# sourceURL=${scriptSrc}\n`;
// speedy 模式:把一批不会被覆写的全局变量先解构成 const,之后访问它们不再经过 Proxy
const scopedGlobalVariableDefinition = scopedGlobalVariables.length
? `const {${scopedGlobalVariables.join(',')}}=this;`
: '';
// 必须绑定到真正的全局 window 上,嵌套场景下 window.proxy 才不会串
const globalWindow = (0, eval)('window');
globalWindow.proxy = proxy;
return strictGlobal
? (scopedGlobalVariableDefinition
? `;(function(){with(this){${scopedGlobalVariableDefinition}${scriptText}\n${sourceUrl}}}).bind(window.proxy)();`
: `;(function(window, self, globalThis){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`)
: `;(function(window, self, globalThis){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`;
}
// utils.js:把包装后的代码用间接 eval 编译成函数并缓存,再以 window 为 this 调用
export function evalCode(scriptSrc, code) {
if (!evalCache[scriptSrc]) {
evalCache[scriptSrc] = (0, eval)(`(function(){${code}})`);
}
evalCache[scriptSrc].call(window);
}
export function execScripts(entry, scripts, proxy = window, opts = {}) {
const { fetch = defaultFetch, strictGlobal = false, scopedGlobalVariables = [] } = opts;
return getExternalScripts(scripts, fetch, entry).then((scriptsText) => {
const geval = (scriptSrc, inlineScript) => {
const code = getExecutableScript(scriptSrc, inlineScript, { proxy, strictGlobal, scopedGlobalVariables });
evalCode(scriptSrc, code);
};
function exec(scriptSrc, inlineScript, resolve) {
if (scriptSrc === entry) {
// 入口脚本:执行前记下全局对象上已有的属性,执行后找出"新出现的那个",
// 它就是 UMD 打包产物挂到全局上的导出(做法源自 SystemJS 的 global extra)
noteGlobalProps(strictGlobal ? proxy : window);
geval(scriptSrc, inlineScript);
const exports = proxy[getGlobalProp(strictGlobal ? proxy : window)] || {};
resolve(exports);
} else {
// 普通脚本:出错不阻塞其他脚本;async 脚本等下载完再执行
geval(scriptSrc, inlineScript);
}
}
// 按 HTML 里出现的顺序依次执行
function schedule(i, resolvePromise) {
if (i < scriptsText.length) {
const { src, value } = scriptsText[i];
exec(src, value, resolvePromise);
schedule(i + 1, resolvePromise);
}
}
return new Promise((resolve) => schedule(0, resolve));
});
}
这里的 (0, eval) 是一个经典的 JavaScript 技巧——它是间接 eval 调用,确保代码在全局作用域中执行,而不是在当前函数的闭包作用域中执行。注意即使 strictGlobal 为 false(乾坤的 sandbox.loose 模式),代码也不是"直接 eval",而是同样被包进一个以 proxy 为 this、并把 window/self/globalThis 形参替换成 proxy 的函数里——差别只在有没有 with(window):有了 with,子应用代码中所有对全局变量的无限定访问(如 document、setTimeout)都会先在 proxy 对象上查找(触发它的 has/get trap);没有 with,只有显式写 window.xxx 的访问才会经过 proxy。另一个容易被忽略的细节是入口脚本的导出是怎么拿到的:不是约定某个固定的全局变量名,而是在执行入口脚本前后对比全局对象上的属性,把"新出现的那个"当作导出——这也是为什么乾坤要求子应用打成 UMD(output.library),并把入口脚本放在最后(或加 entry 属性)。
3.3 注册 → 加载 → 挂载 → 卸载:完整生命周期源码走读
现在我们已经理解了乾坤的三层架构和各自的职责。接下来,我们将沿着一个子应用从注册到卸载的完整生命路径,逐步走读乾坤的核心源码。
"沿着数据流读源码"、是我个人觉得最高效的源码阅读方法论。它比"按目录结构读"要有方向感、比"从 main 入口读"要有焦点、比"随机挑文件读"要有上下文。原因在于、任何一个复杂系统的价值、最终都体现在"用户的某个操作如何被正确处理"上——而沿着数据流走、就是在"重演这个价值创造的过程"。在本节、我们要重演的是 "一个子应用从被注册到最终被渲染、再到被切换走" 这个完整的生命——这是乾坤为开发者创造的核心价值。读懂这一条线、你就读懂了乾坤的主干;剩下的是各种边界 case 和性能优化、它们只是这条主线的支线。这种"抓主干、放细节"的阅读方式、我们在《Tokio 源码深度解析》专栏第 6 章追踪一个 spawn 的旅程、在《Vue 3 设计与实现》专栏第 6 章追踪一次响应式更新的传播、在《Claude Code 源码深度解析》专栏第 4 章追踪一次 query 的流转——都是同一种方法的具体应用。
3.3.1 第一步:registerMicroApps——应用注册
一切的起点是 registerMicroApps。这是开发者接触乾坤的第一个 API:
// 开发者这样调用
import { registerMicroApps, start } from 'qiankun';
registerMicroApps([
{
name: 'app-order',
entry: '//localhost:7100',
container: '#micro-app-container',
activeRule: '/order',
props: { authToken: 'xxx' },
},
{
name: 'app-product',
entry: '//localhost:7200',
container: '#micro-app-container',
activeRule: '/product',
},
]);
start();
我们来看 registerMicroApps 的源码实现:
// qiankun/src/apis.ts(v2.10.16,简化)
let microApps: Array<RegistrableApp<Record<string, unknown>>> = [];
const frameworkStartedDefer = new Deferred<void>();
export function registerMicroApps<T extends ObjectType>(
apps: Array<RegistrableApp<T>>,
lifeCycles?: FrameworkLifeCycles<T>,
) {
// 过滤掉已经注册过的应用(通过 name 去重)
const unregisteredApps = apps.filter((app) => !microApps.some((registeredApp) => registeredApp.name === app.name));
microApps = [...microApps, ...unregisteredApps];
// 将每个应用注册到 single-spa
unregisteredApps.forEach((app) => {
const { name, activeRule, loader = noop, props, ...appConfig } = app;
registerApplication({
name,
// 关键:这里不是直接给 JS URL,而是给一个返回 Promise<LifeCycles> 的函数
app: async () => {
// loader 是加载状态回调,可用于显示 loading
loader(true);
await frameworkStartedDefer.promise; // 等待 start() 被调用
// loadApp 是乾坤的核心——负责加载子应用并返回生命周期(工厂函数)
// beforeMount/afterMount 等框架钩子是在 loadApp 内部编进 mount/unmount 数组的
const { mount, ...otherMicroAppConfigs } = (
await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles)
)();
return {
mount: [async () => loader(true), ...toArray(mount), async () => loader(false)],
...otherMicroAppConfigs,
};
},
// activeWhen 决定何时激活这个子应用
activeWhen: activeRule,
customProps: props,
});
});
}
这段代码有几个关键细节值得注意:
第一,延迟加载。 app 参数不是立即执行的,而是一个返回 Promise 的函数。single-spa 只在路由匹配时才会调用这个函数。这意味着注册 100 个子应用本身不会产生任何网络请求——子应用要么在用户真正访问对应路由时才开始加载,要么由 start() 的预加载策略在空闲时提前拉取(见 3.3.3)。
第二,生命周期组合。 single-spa 支持将生命周期定义为函数数组,会按顺序依次执行。乾坤利用这一点在 mount 前后包上了 loader(true)/loader(false);更多的钩子——沙箱挂载、beforeMount/afterMount、容器渲染——则是在 loadApp 内部以同样的方式编进 mount/unmount 数组的。这是一种经典的中间件模式。
第三,frameworkStartedDefer。 这是一个延迟 Promise,确保 loadApp 不会在 start() 调用之前执行。这给了开发者一个初始化的时间窗口——你可以在 registerMicroApps 和 start 之间做一些准备工作(如获取全局配置)。
3.3.2 第二步:loadApp——应用加载
loadApp 是乾坤最核心、最复杂的函数。它负责:加载子应用的 HTML 和资源、创建沙箱、设置 CSS 隔离、包装生命周期钩子。
// qiankun/src/loader.ts(v2.10.16,简化,保留核心流程)
export async function loadApp<T extends ObjectType>(
app: LoadableApp<T>,
configuration: FrameworkConfiguration = {},
lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObjectGetter> {
const { entry, name: appName } = app;
// 同一个 name 多次加载时生成 name、name_1、name_2……作为实例 id
const appInstanceId = genAppInstanceIdByName(appName);
const {
singular = false,
sandbox = true,
excludeAssetFilter,
globalContext = window,
...importEntryOpts // fetch、getPublicPath、getTemplate 等透传给 import-html-entry
} = configuration;
// ========== 第一阶段:加载 HTML 并解析资源 ==========
const { template, execScripts, assetPublicPath, getExternalScripts } = await importEntry(entry, importEntryOpts);
// 先把外链脚本全部拉下来,保证执行阶段不再等网络
await getExternalScripts();
// single-spa 会让新应用的加载与旧应用的卸载并行,
// 单例模式下要等上一个子应用完全卸载后再继续
if (await validateSingularMode(singular, app)) {
await (prevAppUnmountedDeferred && prevAppUnmountedDeferred.promise);
}
// 用 <div id="__qiankun_microapp_wrapper_for_xxx__"> 包一层,并把 <head> 换成 <qiankun-head>
const appContent = getDefaultTplWrapper(appInstanceId, sandbox)(template);
const strictStyleIsolation = typeof sandbox === 'object' && !!sandbox.strictStyleIsolation;
const scopedCSS = isEnableScopedCSS(sandbox); // experimentalStyleIsolation
// 创建包裹元素:strictStyleIsolation 时套 Shadow DOM,scopedCSS 时改写 <style>
let initialAppWrapperElement: HTMLElement | null = createElement(appContent, strictStyleIsolation, scopedCSS, appInstanceId);
const initialContainer = 'container' in app ? app.container : undefined;
const legacyRender = 'render' in app ? app.render : undefined; // 已废弃的自定义 render
const render = getRender(appInstanceId, appContent, legacyRender);
// 第一次加载就把 DOM 结构放进容器(loading 态)
render({ element: initialAppWrapperElement, loading: true, container: initialContainer }, 'loading');
const initialAppWrapperGetter = getAppWrapperGetter(
appInstanceId, !!legacyRender, strictStyleIsolation, scopedCSS, () => initialAppWrapperElement,
);
// ========== 第二阶段:创建沙箱 ==========
let global = globalContext;
let mountSandbox = () => Promise.resolve();
let unmountSandbox = () => Promise.resolve();
const useLooseSandbox = typeof sandbox === 'object' && !!sandbox.loose;
// 2.9.0 起默认开启 speedy 模式
const speedySandbox = typeof sandbox === 'object' ? sandbox.speedy !== false : true;
let sandboxContainer;
if (sandbox) {
sandboxContainer = createSandboxContainer(
appInstanceId,
initialAppWrapperGetter, // 挂载容器的 getter(Shadow DOM 时返回 shadowRoot)
scopedCSS,
useLooseSandbox,
excludeAssetFilter,
global,
speedySandbox,
);
// 用沙箱的代理对象作为接下来使用的全局对象
global = sandboxContainer.instance.proxy as typeof window;
mountSandbox = sandboxContainer.mount;
unmountSandbox = sandboxContainer.unmount;
}
// 框架钩子 = 内置 addons(注入 __POWERED_BY_QIANKUN__、publicPath 等)+ 用户传入的 lifeCycles
const { beforeUnmount = [], afterUnmount = [], afterMount = [], beforeMount = [], beforeLoad = [] } =
mergeWith({}, getAddOns(global, assetPublicPath), lifeCycles, (v1, v2) => concat(v1 ?? [], v2 ?? []));
await execHooksChain(toArray(beforeLoad), app, global);
// ========== 第三阶段:在沙箱中执行子应用 JS ==========
const scriptExports: any = await execScripts(global, sandbox && !useLooseSandbox, {
scopedGlobalVariables: speedySandbox ? cachedGlobals : [],
});
const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(
scriptExports,
appName,
global,
sandboxContainer?.instance?.latestSetProp,
);
const { onGlobalStateChange, setGlobalState, offGlobalStateChange } = getMicroAppStateActions(appInstanceId);
// 把新建/释放的包裹元素同步回 initialAppWrapperElement(沙箱持有的 getter 读的是它)
const syncAppWrapperElement2Sandbox = (element: HTMLElement | null) => (initialAppWrapperElement = element);
// ========== 第四阶段:包装生命周期并返回 ==========
const parcelConfigGetter: ParcelConfigObjectGetter = (remountContainer = initialContainer) => {
let appWrapperElement: HTMLElement | null;
let appWrapperGetter: () => HTMLElement | ShadowRoot;
const parcelConfig: ParcelConfigObject = {
name: appInstanceId,
bootstrap,
mount: [
// 单例模式:等上一个子应用卸载完
async () => {
if ((await validateSingularMode(singular, app)) && prevAppUnmountedDeferred) {
return prevAppUnmountedDeferred.promise;
}
},
// 准备容器 DOM:卸载时元素已被销毁,或换了容器时,重新 createElement
async () => {
appWrapperElement = initialAppWrapperElement;
appWrapperGetter = getAppWrapperGetter(appInstanceId, !!legacyRender, strictStyleIsolation, scopedCSS, () => appWrapperElement);
},
async () => {
const useNewContainer = remountContainer !== initialContainer;
if (useNewContainer || !appWrapperElement) {
appWrapperElement = createElement(appContent, strictStyleIsolation, scopedCSS, appInstanceId);
syncAppWrapperElement2Sandbox(appWrapperElement);
}
render({ element: appWrapperElement, loading: true, container: remountContainer }, 'mounting');
},
// 激活沙箱 + 打上全局补丁
mountSandbox,
async () => execHooksChain(toArray(beforeMount), app, global),
// 调用子应用的 mount,并把 container / 通信 API 塞进 props
async (props) => mount({ ...props, container: appWrapperGetter(), setGlobalState, onGlobalStateChange }),
async () => render({ element: appWrapperElement, loading: false, container: remountContainer }, 'mounted'),
async () => execHooksChain(toArray(afterMount), app, global),
// 挂载完成后初始化"卸载完成"的 Deferred,供下一个子应用等待
async () => {
if (await validateSingularMode(singular, app)) {
prevAppUnmountedDeferred = new Deferred<void>();
}
},
],
unmount: [
async () => execHooksChain(toArray(beforeUnmount), app, global),
async (props) => unmount({ ...props, container: appWrapperGetter() }),
// 清理副作用补丁 + 失活沙箱
unmountSandbox,
async () => execHooksChain(toArray(afterUnmount), app, global),
// 清空容器、注销全局状态监听、释放包裹元素
async () => {
render({ element: null, loading: false, container: remountContainer }, 'unmounted');
offGlobalStateChange(appInstanceId);
appWrapperElement = null;
syncAppWrapperElement2Sandbox(appWrapperElement);
},
async () => {
if ((await validateSingularMode(singular, app)) && prevAppUnmountedDeferred) {
prevAppUnmountedDeferred.resolve();
}
},
],
};
if (typeof update === 'function') {
parcelConfig.update = update;
}
return parcelConfig;
};
return parcelConfigGetter;
}
下图展示了 loadApp 四个阶段的完整执行流程:
sequenceDiagram
participant QK as qiankun(loadApp)
participant IHE as import-html-entry
participant SB as SandboxContainer
participant SubApp as 子应用JS
Note over QK: 阶段一: 加载HTML并解析资源
QK->>IHE: importEntry(entry)
IHE->>IHE: fetch HTML
IHE->>IHE: 正则提取script/style
IHE-->>QK: template + execScripts + styles
Note over QK: 阶段二: 创建沙箱
QK->>SB: createSandboxContainer(appInstanceId, ...)
SB->>SB: 检测Proxy支持
alt 支持Proxy且非loose
SB->>SB: new ProxySandbox()
else 支持Proxy且loose
SB->>SB: new LegacySandbox()
else 不支持Proxy
SB->>SB: new SnapshotSandbox()
end
SB->>SB: patchAtBootstrapping(动态样式劫持)
SB-->>QK: sandbox.instance.proxy
Note over QK: 阶段三: 沙箱中执行JS
QK->>IHE: execScripts(proxy, strictGlobal)
IHE->>SubApp: (0,eval) with(proxy){code}
SubApp-->>QK: {bootstrap, mount, unmount}
Note over QK: 阶段四: 包装生命周期
QK->>QK: 将沙箱active/inactive编织进mount/unmount
QK-->>QK: 返回ParcelConfigGetter工厂函数
这个函数的代码量虽大,但逻辑非常清晰——四个阶段依次执行:
- 加载 HTML:通过 import-html-entry 获取并解析子应用的 HTML,并预先下载全部外链脚本
- 创建沙箱:根据配置创建 ProxySandbox(默认)、LegacySandbox(
loose)或 SnapshotSandbox(无 Proxy) - 执行 JS:先跑
beforeLoad钩子,再在沙箱环境中执行子应用的 JavaScript 代码 - 包装生命周期:将沙箱的激活/恢复、容器渲染、框架钩子、全局状态 API 编织进 mount/unmount 钩子数组
💡 深度洞察:
loadApp返回的不是生命周期对象本身,而是一个工厂函数(ParcelConfigObjectGetter)。沙箱实例、脚本执行都发生在loadApp本体里、只做一次;工厂函数每调用一次,只是基于这份已加载的结果生成一份新的 parcel 配置,换了容器时还会重建包裹 DOM。loadMicroApp正是利用这一点:它以"应用名 + 容器 XPath"为键缓存loadApp返回的 Promise,同一个子应用再次渲染进同一个容器时,直接调用缓存的工厂函数,不再重新拉取和执行脚本。这个"工厂模式"的选择看似微小,实则是支撑乾坤loadMicroApp手动加载能力的关键架构决策。
3.3.3 第三步:start——启动框架
注册完子应用后,需要调用 start() 来启动乾坤:
// qiankun/src/apis.ts(v2.10.16)
let started = false;
const defaultUrlRerouteOnly = true;
const frameworkStartedDefer = new Deferred<void>();
export function start(opts: FrameworkConfiguration = {}) {
// 保存框架配置:默认开启预加载、单例、沙箱
frameworkConfiguration = { prefetch: true, singular: true, sandbox: true, ...opts };
const { prefetch, urlRerouteOnly = defaultUrlRerouteOnly, ...importEntryOpts } = frameworkConfiguration;
// 根据 prefetch 配置执行预加载
if (prefetch) {
doPrefetchStrategy(microApps, prefetch, importEntryOpts);
}
// 低版本浏览器自动降级:没有 Proxy → sandbox.loose = true(最终落到 SnapshotSandbox);
// 不支持 const 解构 → 关闭 speedy 模式
frameworkConfiguration = autoDowngradeForLowVersionBrowser(frameworkConfiguration);
// 启动 single-spa
startSingleSpa({ urlRerouteOnly });
started = true;
// 解锁 frameworkStartedDefer,允许 loadApp 执行
frameworkStartedDefer.resolve();
}
start 做了三件事:
第一,预加载。 默认开启。乾坤会在首个子应用挂载后(监听 single-spa 的 single-spa:first-mount 事件)、浏览器空闲时(requestIdleCallback,没有时用 MessageChannel/setTimeout 模拟),提前下载其余子应用的 HTML、JS 和 CSS——只下载进 import-html-entry 的缓存、不执行;离线或弱网(navigator.connection 判断为 2G/3G 或开启了省流量)时直接跳过。这样当用户真正切换到某个子应用时,资源已经在缓存中。
// qiankun/src/prefetch.ts(v2.10.16,简化)
function prefetch(entry: Entry, opts?: ImportEntryOpts): void {
if (!navigator.onLine || isSlowNetwork) {
// 离线或弱网时不预加载
return;
}
requestIdleCallback(async () => {
// 只下载进缓存,不执行
const { getExternalScripts, getExternalStyleSheets } = await importEntry(entry, opts);
requestIdleCallback(getExternalStyleSheets);
requestIdleCallback(getExternalScripts);
});
}
function prefetchAfterFirstMounted(apps: AppMetadata[], opts?: ImportEntryOpts): void {
// single-spa 在第一个应用 mount 完成后派发这个事件
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);
});
}
export function doPrefetchStrategy(apps: AppMetadata[], prefetchStrategy: PrefetchStrategy, importEntryOpts?: ImportEntryOpts) {
const appsName2Apps = (names: string[]) => apps.filter((app) => names.includes(app.name));
if (Array.isArray(prefetchStrategy)) {
// 指定名字:首屏挂载后预取这些应用
prefetchAfterFirstMounted(appsName2Apps(prefetchStrategy as string[]), importEntryOpts);
} else if (isFunction(prefetchStrategy)) {
// 自定义函数:criticalAppNames 立即预取,minorAppsName 首屏后预取
(async () => {
const { criticalAppNames = [], minorAppsName = [] } = await prefetchStrategy(apps);
prefetchImmediately(appsName2Apps(criticalAppNames), importEntryOpts);
prefetchAfterFirstMounted(appsName2Apps(minorAppsName), importEntryOpts);
})();
} else {
switch (prefetchStrategy) {
case true: // 默认:首屏挂载后预取全部
prefetchAfterFirstMounted(apps, importEntryOpts);
break;
case 'all': // start() 时立即预取全部
prefetchImmediately(apps, importEntryOpts);
break;
}
}
}
第二,沙箱降级检测。 在不支持 Proxy 的浏览器中,把 sandbox.loose 置为 true 并给出警告,createSandboxContainer 随后会因为 window.Proxy 不存在而创建 SnapshotSandbox;在不支持 const 解构赋值的浏览器中则关闭 speedy 模式。
第三,启动 single-spa。 调用 single-spa 的 start 函数,开始监听路由变化并触发子应用的生命周期。
3.3.4 第四步:挂载与卸载——运行时的协调
当用户从 /order 导航到 /product,以下是完整的事件序列:
用户点击导航链接
↓
history.pushState('/product')
↓
single-spa 拦截到路由变化
↓
reroute() 被调用
↓
计算 app 变更:
- appsToUnmount: ['app-order'] (当前路由不再匹配)
- appsToMount: ['app-product'] (当前路由匹配)
↓
执行卸载序列(app-order):
1. beforeUnmount 钩子(乾坤)
2. app-order.unmount()(子应用清理 DOM)
3. 沙箱 inactive()(恢复全局变量)
4. afterUnmount 钩子(乾坤)
5. 清理容器 DOM
↓
执行挂载序列(app-product):
1. 如果是首次加载:
a. loadApp()(获取 HTML、创建沙箱、执行 JS)
b. app-product.bootstrap()(一次性初始化)
2. 准备容器 DOM
3. 沙箱 active()(激活隔离)
4. beforeMount 钩子(乾坤)
5. app-product.mount()(子应用渲染)
6. afterMount 钩子(乾坤)
↓
用户看到商品页面
下面用一段伪代码把这个过程串起来(为清晰起见写成"先卸载、再加载挂载"两段;实际中新应用的加载和 bootstrap 与旧应用的卸载并行,只有 mount 等卸载完成,见 3.2.2):
// 完整的挂载流程伪代码(综合 qiankun + single-spa)
async function performAppSwitch(
appsToUnmount: AppConfig[],
appsToMount: AppConfig[]
) {
// Phase 1: 卸载旧应用
await Promise.all(
appsToUnmount.map(async (app) => {
// single-spa 调用 unmount 数组中的每个函数
for (const fn of app.unmount) {
await fn(getCustomProps(app));
}
// 更新状态机
app.status = AppStatus.NOT_MOUNTED;
})
);
// Phase 2: 加载并挂载新应用
await Promise.all(
appsToMount.map(async (app) => {
// 如果尚未加载,先加载
if (app.status === AppStatus.NOT_LOADED) {
app.status = AppStatus.LOADING_SOURCE_CODE;
// 调用注册时提供的 app() 函数
// 在乾坤中,这会触发 loadApp
const lifecycles = await app.loadApp();
app.bootstrap = lifecycles.bootstrap;
app.mount = lifecycles.mount;
app.unmount = lifecycles.unmount;
app.status = AppStatus.NOT_BOOTSTRAPPED;
}
// 如果尚未 bootstrap,先 bootstrap
if (app.status === AppStatus.NOT_BOOTSTRAPPED) {
app.status = AppStatus.BOOTSTRAPPING;
for (const fn of toArray(app.bootstrap)) {
await fn(getCustomProps(app));
}
app.status = AppStatus.NOT_MOUNTED;
}
// 执行挂载
app.status = AppStatus.MOUNTING;
for (const fn of toArray(app.mount)) {
await fn(getCustomProps(app));
}
app.status = AppStatus.MOUNTED;
})
);
}
这里有一个容易被忽视的细节:bootstrap 只执行一次。当用户从 /order 切到 /product,再切回 /order 时,app-order 的 bootstrap 不会再次执行——因为它的状态是 NOT_MOUNTED 而非 NOT_BOOTSTRAPPED。这意味着 bootstrap 中的初始化逻辑(如加载 polyfill、建立 WebSocket 连接)只会运行一次,后续的挂载只会调用 mount。
3.3.5 createSandboxContainer:沙箱的创建与管理
在 loadApp 中,createSandboxContainer 负责创建沙箱实例。我们来看它的详细实现:
// qiankun/src/sandbox/index.ts(v2.10.16)
export function createSandboxContainer(
appName: string,
elementGetter: () => HTMLElement | ShadowRoot,
scopedCSS: boolean,
useLooseSandbox?: boolean,
excludeAssetFilter?: (url: string) => boolean,
globalContext?: typeof window,
speedySandBox?: boolean,
) {
let sandbox: SandBox;
if (window.Proxy) {
sandbox = useLooseSandbox
? new LegacySandbox(appName, globalContext) // 单例 Proxy 沙箱
: new ProxySandbox(appName, globalContext, { speedy: !!speedySandBox }); // 多例 Proxy 沙箱
} else {
sandbox = new SnapshotSandbox(appName); // 快照沙箱
}
// bootstrap 阶段就可能有副作用(比如 style-loader 在开发态动态插入样式),
// 所以动态 style/link/script 的劫持在创建沙箱时就打上,整个应用生命周期只打一次
const bootstrappingFreers = patchAtBootstrapping(appName, elementGetter, sandbox, scopedCSS, excludeAssetFilter, speedySandBox);
// mounting 阶段的补丁是一次性的,每次 mount 重新打
let mountingFreers: Freer[] = [];
let sideEffectsRebuilders: Rebuilder[] = [];
return {
instance: sandbox,
async mount() {
/* 1. 启动/恢复沙箱 */
sandbox.active();
const sideEffectsRebuildersAtBootstrapping = sideEffectsRebuilders.slice(0, bootstrappingFreers.length);
const sideEffectsRebuildersAtMounting = sideEffectsRebuilders.slice(bootstrappingFreers.length);
// 先重建 bootstrap 阶段的副作用(主要是上次卸载时被摘掉的动态样式)
if (sideEffectsRebuildersAtBootstrapping.length) {
sideEffectsRebuildersAtBootstrapping.forEach((rebuild) => rebuild());
}
mountingFreers = patchAtMounting(appName, elementGetter, sandbox, scopedCSS, excludeAssetFilter, speedySandBox);
/* 3. 重建 mount 阶段的副作用 */
if (sideEffectsRebuildersAtMounting.length) {
sideEffectsRebuildersAtMounting.forEach((rebuild) => rebuild());
}
sideEffectsRebuilders = [];
},
async unmount() {
// 依次调用每个补丁的 free():清理副作用、还原被劫持的 API,并拿到各自的 rebuilder
sideEffectsRebuilders = [...bootstrappingFreers, ...mountingFreers].map((free) => free());
sandbox.inactive();
},
};
}
下图展示了沙箱副作用补丁的劫持范围与清理流程:
flowchart TD
Mount["子应用挂载(mount)"] --> Activate["沙箱active()"]
Activate --> Patch["应用副作用补丁(patchAtMounting)"]
Patch --> P1["patchInterval<br/>劫持setInterval/clearInterval"]
Patch --> P2["patchWindowListener<br/>劫持window.addEventListener"]
Patch --> P3["patchStrictSandbox/patchLooseSandbox<br/>劫持动态追加的style/link/script"]
Patch --> P4["patchHistoryListener<br/>劫持umi的window.g_history.listen"]
P1 --> Record["记录所有副作用ID"]
P2 --> Record
P3 --> Record
P4 --> Record
Unmount["子应用卸载(unmount)"] --> Free["调用free()清理副作用"]
Free --> Restore["恢复原始API"]
Free --> Clean["清理未释放的定时器/监听器"]
Free --> Rebuild["返回rebuild()用于下次恢复"]
Rebuild -.->|下次mount时| Activate
style Mount fill:#e8f5e9,stroke:#2e7d32
style Unmount fill:#ffebee,stroke:#c62828
patchAtMounting 是沙箱体系中最"脏"但最重要的部分——它劫持了一系列全局 API,确保子应用产生的副作用能被正确追踪和清理:
// qiankun/src/sandbox/patchers/index.ts(v2.10.16)
export function patchAtMounting(
appName: string,
elementGetter: () => HTMLElement | ShadowRoot,
sandbox: SandBox,
scopedCSS: boolean,
excludeAssetFilter?: CallableFunction,
speedySandBox?: boolean,
): Freer[] {
const basePatchers = [
() => patchInterval(sandbox.proxy), // 1. 劫持 setInterval/clearInterval(注意:没有 setTimeout)
() => patchWindowListener(sandbox.proxy), // 2. 劫持 window.addEventListener/removeEventListener
() => patchHistoryListener(), // 3. 劫持 umi 挂在 window.g_history 上的 listen
];
// 4. 劫持 document.createElement / head.appendChild 等,接管动态插入的 style/link/script
const patchersInSandbox = {
[SandBoxType.LegacyProxy]: [...basePatchers, () => patchLooseSandbox(appName, elementGetter, sandbox, true, scopedCSS, excludeAssetFilter)],
[SandBoxType.Proxy]: [...basePatchers, () => patchStrictSandbox(appName, elementGetter, sandbox, true, scopedCSS, excludeAssetFilter, speedySandBox)],
[SandBoxType.Snapshot]: [...basePatchers, () => patchLooseSandbox(appName, elementGetter, sandbox, true, scopedCSS, excludeAssetFilter)],
};
return patchersInSandbox[sandbox.type]?.map((patch) => patch());
}
定时器劫持的实现在 patchers/interval.ts,只有 30 余行,值得完整看一遍:
// qiankun/src/sandbox/patchers/interval.ts(v2.10.16)
const rawWindowInterval = window.setInterval;
const rawWindowClearInterval = window.clearInterval;
export default function patch(global: Window) {
let intervals: number[] = [];
// 注意补丁打在 sandbox.proxy 上:ProxySandbox 下写进 fakeWindow,真实 window 不受影响
global.clearInterval = (intervalId: number) => {
intervals = intervals.filter((id) => id !== intervalId);
return rawWindowClearInterval.call(window, intervalId as any);
};
global.setInterval = (handler: CallableFunction, timeout?: number, ...args: any[]) => {
const intervalId = rawWindowInterval(handler, timeout, ...args);
intervals = [...intervals, intervalId];
return intervalId;
};
return function free() {
// 清掉子应用没清的定时器,还原 API
intervals.forEach((id) => global.clearInterval(id));
global.setInterval = rawWindowInterval;
global.clearInterval = rawWindowClearInterval;
// 定时器不需要"重建"——rebuilder 是个 noop
return noop;
};
}
patchWindowListener 的结构与之完全对称:用一个 Map<type, listener[]> 记录子应用通过 window.addEventListener 注册的监听器,free() 时统一 removeEventListener,同样返回 noop。
💡 深度洞察:乾坤沙箱的副作用管理采用了"记录-清理-重建"三步策略。卸载时,每个补丁的
free()清理副作用、还原被劫持的 API,并返回一个rebuild();下次挂载时依次调用rebuild()。但要看清楚哪些副作用会被重建:定时器和 window 事件监听的free()返回的都是noop——它们只被清理、不会恢复,子应用必须在下一次mount里重新注册;真正会被重建的是子应用动态插入的样式(<style>/<link>,包括 bootstrap 阶段由 style-loader 插入的那些)以及 umi 的g_history.listen监听器。之所以这样区分,是因为样式属于"应用初始化后就应当一直存在的环境",而定时器和事件监听属于"挂载期间的运行时行为",前者跟着 bootstrap 走,后者跟着 mount 走。这也是createSandboxContainer注释里"app 环境沙箱"与"render 沙箱"两层划分的由来。
3.3.6 getLifecyclesFromExports:如何找到子应用的生命周期
子应用的 JS 在沙箱中执行后,乾坤需要从执行结果中提取生命周期函数。这个过程比想象中复杂:
// qiankun/src/loader.ts(v2.10.16)
function getLifecyclesFromExports(
scriptExports: LifeCycles<any>,
appName: string,
global: WindowProxy,
globalLatestSetProp?: PropertyKey | null,
) {
// 策略一:import-html-entry 从入口脚本执行前后的全局属性 diff 中拿到了导出
if (validateExportLifecycle(scriptExports)) {
return scriptExports;
}
// 策略二:退而求其次,用沙箱记录的"最后一次被 set 的 window 属性"
// (LegacySandbox / ProxySandbox 的 set trap 会记录 latestSetProp)
if (globalLatestSetProp) {
const lifecycles = (<any>global)[globalLatestSetProp];
if (validateExportLifecycle(lifecycles)) {
return lifecycles;
}
}
if (process.env.NODE_ENV === 'development') {
console.warn(`[qiankun] lifecycle not found from ${appName} entry exports, fallback to get from window['${appName}']`);
}
// 策略三:最后兜底,找 window[appName]
const globalVariableExports = (global as any)[appName];
if (validateExportLifecycle(globalVariableExports)) {
return globalVariableExports;
}
throw new QiankunError(`You need to export lifecycle functions in ${appName} entry`);
}
// qiankun/src/utils.ts
export function validateExportLifecycle(exports: any) {
const { bootstrap, mount, unmount } = exports ?? {};
return isFunction(bootstrap) && isFunction(mount) && isFunction(unmount);
}
这段代码揭示了一个重要的实践信息:乾坤为什么要求子应用的 webpack 配置中设置 output.library 和 output.libraryTarget? 正是为了让子应用的生命周期导出能被可靠地获取。最稳妥的方式是:
// 子应用的 webpack 配置
const { name: appName } = require('./package.json');
module.exports = {
output: {
library: `${appName}-[name]`,
libraryTarget: 'umd',
// 或 jsonpFunction / chunkLoadingGlobal
chunkLoadingGlobal: `webpackJsonp_${appName}`,
},
};
3.4 乾坤在 2026 年的地位:仍是存量项目的主流选择
3.4.1 客观数据
截至 2026 年 9 月,微前端领域的格局已经发生了显著变化。让我们用数据说话:
在看数据之前、我想提醒你一件事——开源项目的"活跃度"和"实际使用量"常常是两件完全不同的事。一个每天都有 PR 的项目、实际部署可能并不多;一个看起来"没那么活跃"的项目、可能在几万家公司的生产环境里稳定运行。乾坤就是后者——作为一个 "功能稳定、问题可控" 的框架、它不需要频繁发版、但它在中文前端社区的实际装机量、远大于那些新兴方案。所以判断一个技术的"市场地位"、不能只看 GitHub 的 star 数和 commit 频率、还要看 npm 的实际下载量、以及你周围团队的真实使用情况。
// 2026 年 9 月各微前端方案的公开数据(GitHub star 取整;npm 周下载量为 2026 年 6–9 月间的波动区间,取整)
interface FrameworkStats {
name: string;
githubStars: string;
npmWeeklyDownloads: string;
firstRelease: number;
maintenanceStatus: 'active' | 'maintenance' | 'archived';
typicalUseCase: string;
}
const stats: FrameworkStats[] = [
{
name: 'qiankun',
githubStars: '16k+',
npmWeeklyDownloads: '40k–80k',
firstRelease: 2019,
maintenanceStatus: 'maintenance', // 2.10.16(2023-11-15)之后 2.x 再未发正式版;3.0 仍在 rc
typicalUseCase: '存量项目改造、多框架并存',
},
{
name: 'Module Federation',
githubStars: '(Webpack 内置)',
npmWeeklyDownloads: '(Webpack/Rspack 内置)',
firstRelease: 2020,
maintenanceStatus: 'active',
typicalUseCase: '新项目、同构技术栈、高性能共享',
},
{
name: 'micro-app',
githubStars: '6k+',
npmWeeklyDownloads: '3k–7k',
firstRelease: 2021,
maintenanceStatus: 'active',
typicalUseCase: '追求简单接入的新项目',
},
{
name: 'wujie',
githubStars: '5k+',
npmWeeklyDownloads: '4k–6k',
firstRelease: 2022,
maintenanceStatus: 'active',
typicalUseCase: '需要强隔离的场景',
},
{
name: 'single-spa',
githubStars: '13k+',
npmWeeklyDownloads: '300k–510k',
firstRelease: 2015,
maintenanceStatus: 'active',
typicalUseCase: '底层框架、高度定制场景',
},
];
几个关键观察:
- single-spa 的周下载量远超乾坤——因为很多方案(包括乾坤)底层依赖它
- 乾坤仍保持每周数万次的下载量——远高于 micro-app、无界等后来者,说明存量项目仍在大量使用
- Module Federation 已是新项目的重要候选——尤其在统一技术栈的团队中(上表没有它独立的下载量数据,这一条是定性判断)
- 乾坤 2.x 进入 maintenance 模式——
v2.10.16(2023-11-15)是 2.x 最后一个正式版本,此后再没有打过 2.x 的 tag(npm 上只多了一个 2026-06-23 发布、挂在2.0-beta标签下的预发布版2.10.17-beta.0):2.x分支上仍有零星修复提交(最近一条是 2025-06-23 的 revert),但分支上package.json的版本号至今仍是2.10.16。维护者的精力放在仍处于 rc 阶段的 3.0 上(最新一个 tag 是 2026-08-30 的v3.0.0-rc.22)
3.4.2 乾坤的优势仍然存在
尽管新方案层出不穷,乾坤在以下场景中仍然是最合理的选择:
场景一:多框架并存。 你的主应用是 Vue 3,但有两个老的 React 15 子应用和一个 Angular 子应用。Module Federation 在这种场景下会很痛苦——它的前提是构建工具层面的协作,而不同框架的构建配置差异巨大。乾坤的 HTML Entry 不关心子应用用什么框架、什么构建工具——只要它能产出一个可以运行的 HTML 页面。
场景二:子应用团队不可控。 当子应用由第三方团队或外包团队开发时,你无法要求他们使用特定的构建工具版本或输出特定的模块格式。乾坤的接入成本很低——子应用几乎不需要修改构建配置。
场景三:渐进式迁移。 一个运行了五年的单体应用,需要逐步拆分为微前端。乾坤允许你一次只拆出一个模块,不需要重构整个构建体系。
// 渐进式迁移的典型路径
const migrationPlan = {
phase1: {
action: '将主应用升级为乾坤基座',
effort: '1-2 周',
risk: '低——只添加了 qiankun 依赖,不修改现有代码',
code: `
// 主应用入口只需添加几行代码
import { registerMicroApps, start } from 'qiankun';
registerMicroApps([
// 暂时不注册任何子应用
]);
start();
// 现有应用完全不受影响
`,
},
phase2: {
action: '将第一个独立模块拆为子应用',
effort: '2-4 周',
risk: '中——需要处理模块间的耦合',
},
phase3: {
action: '逐步拆分其余模块',
effort: '按模块规模定',
risk: '随经验积累逐步降低',
},
};
3.4.3 乾坤的局限性也很明确
同时,我们也要诚实地面对乾坤的问题:
局限一:性能开销。 HTML Entry 意味着每个子应用的加载都经过"获取 HTML → 解析 → 获取 JS/CSS → 执行"的完整流程。对比 Module Federation 的"直接加载 JS 模块",这个过程多了至少 1-2 次网络请求(HTML 本身 + 可能的额外 CSS 请求)。
局限二:沙箱的不完美。 Proxy 沙箱无法拦截所有场景。比如通过 document.querySelector 获取其他子应用的 DOM 元素、通过间接 eval((0, eval)(...))在全局作用域执行代码、以及修改内建原型或第三方库共享单例状态的行为——这些都可能突破沙箱的隔离。
// 沙箱无法覆盖的场景示例
const sandboxLimitations = [
{
scenario: 'document.querySelector 越界',
description: '子应用可以访问到主应用甚至其他子应用的 DOM',
workaround: '使用 Shadow DOM 模式(strictStyleIsolation: true)——只能挡住外部查进本子应用,子应用自己仍能经 document 查到外部 DOM',
},
{
scenario: '第三方库的全局副作用',
description: '如修改内建对象原型、moment.locale() 这类改动共享单例内部状态的调用',
workaround: '把这类库提到主应用统一加载并约定使用方式;沙箱层面无法拦截',
},
{
scenario: 'CSS 全局污染',
description: '子应用的 CSS 可能影响主应用或其他子应用',
workaround: '启用 scopedCSS 或 Shadow DOM,但两者都有兼容性代价',
},
{
scenario: 'localStorage/sessionStorage 共享',
description: '所有子应用共享同域名下的 Storage',
workaround: '在 key 中添加子应用前缀(需手动实现)',
},
];
局限三:维护节奏放缓。 2.x 稳定线自 2.10.16 之后再没有发过正式版——修复只落在 2.x 分支上,除了一个 2.10.17-beta.0 预发布版,没有对应的正式 npm 版本可装;维护者的精力放在 3.0 上,而 3.0 从 alpha 到 rc 已经走了几年仍未正式发布。如果你在 2.x 上遇到了一个核心 bug,修复可能需要自己 fork。
3.4.4 选型决策框架
下图展示了微前端方案选型的决策流程:
flowchart TD
Start["项目需要微前端?"] --> MF{"多框架并存?"}
MF -->|是| ISO{"需要强隔离?"}
ISO -->|是| Wujie["选择Wujie"]
ISO -->|否| QK["选择乾坤"]
MF -->|否| UT{"技术栈统一?"}
UT -->|是| BT{"构建工具支持MF?"}
BT -->|是| ModFed["选择Module Federation"]
BT -->|否| QK2["选择乾坤"]
UT -->|否| Legacy{"存量项目改造?"}
Legacy -->|是| QK3["选择乾坤"]
Legacy -->|否| TS{"团队规模 >= 5人?"}
TS -->|否| NoMFE["可能不需要微前端"]
TS -->|是| ModFed2["选择Module Federation"]
style Wujie fill:#e8eaf6,stroke:#283593
style QK fill:#e3f2fd,stroke:#1565c0
style QK2 fill:#e3f2fd,stroke:#1565c0
style QK3 fill:#e3f2fd,stroke:#1565c0
style ModFed fill:#e8f5e9,stroke:#2e7d32
style ModFed2 fill:#e8f5e9,stroke:#2e7d32
style NoMFE fill:#fff3e0,stroke:#e65100
基于以上分析,我给出一个简明的选型建议:
// 微前端方案选型决策树
function chooseMicroFEFramework(context: ProjectContext): string {
// 决策点 1:是否需要多框架并存?
if (context.hasMultipleFrameworks) {
// 多框架场景下,运行时方案是刚需
if (context.needStrongIsolation) {
return 'wujie'; // 需要强隔离 → iframe 增强方案
}
return 'qiankun'; // 乾坤是成熟的多框架方案
}
// 决策点 2:技术栈是否统一?
if (context.unifiedTechStack) {
if (context.buildToolSupported) {
return 'Module Federation'; // 统一技术栈 → 构建期声明共享更合适
}
return 'qiankun'; // 构建工具不支持 MF → 退回运行时方案(与上图一致)
}
// 决策点 3:是新项目还是存量改造?
if (context.isLegacyMigration) {
return 'qiankun'; // 存量改造 → 接入成本最低的方案
}
// 决策点 4:团队规模与技术能力(5 人是经验阈值,不是硬性标准)
if (context.teamSize < 5) {
return '考虑是否真的需要微前端';
}
return 'Module Federation'; // 新项目默认推荐
}
💡 深度洞察:技术选型最大的陷阱不是"选错了方案",而是"在不需要微前端的时候引入了微前端"。如果项目只有一个团队在维护、构建时间在可接受范围内、技术栈统一且无迁移压力,往往不需要微前端——一个合理划分的 monorepo 能解决大多数"看起来需要微前端"的问题。
3.4.5 乾坤源码的架构启示
抛开"用不用乾坤"的实际问题,乾坤的源码本身是一个优秀的架构案例。从中我们可以提炼出几个值得学习的设计原则:
原则一:分层抽象。 qiankun → single-spa → import-html-entry 的三层架构,每一层只关心自己的职责。single-spa 不知道也不关心子应用是通过 HTML Entry 还是 JS Entry 加载的;import-html-entry 不知道也不关心执行的 JS 会被用于什么目的。这种分层让每一层都可以独立测试、独立替换。
原则二:约定优于配置。 乾坤的默认配置覆盖了大多数场景:默认开启沙箱、默认开启预加载、默认单例模式。开发者只需要提供 name、entry、container、activeRule 四个必填项就能跑起来。高级配置留给需要的人。
原则三:渐进增强,优雅降级。 Proxy 沙箱 → 快照沙箱的自动切换、Shadow DOM → Scoped CSS 的手动切换——乾坤总是提供一个"基本能用"的降级方案。这让它能在更广泛的浏览器环境中运行。
// 这种"检测能力,自动降级"的模式值得在你的项目中借鉴
function createOptimalSandbox(appName: string): SandboxInstance {
if (typeof Proxy !== 'undefined') {
// 最佳方案
return new ProxySandbox(appName);
}
console.warn(
`[${appName}] Proxy 不可用,降级到快照沙箱。` +
`注意:快照沙箱不支持多个子应用同时运行。`
);
return new SnapshotSandbox(appName);
}
原则四:可组合的生命周期。 将 mount/unmount 定义为函数数组而非单一函数,允许框架在子应用的生命周期前后插入任意逻辑(如沙箱激活、性能监控、错误捕获)。这种"在核心调用前后插入逻辑"的中间件思路在 Express/Koa 中间件、Redux 中间件中都有体现——它是处理横切关注点的经典模式,也是"开放扩展、封闭修改"(OCP)原则在框架设计中的具体落地。
本章小结
- HTML Entry 是乾坤区别于 single-spa 的核心创新:通过获取和解析子应用的 HTML,实现了"给一个 URL 就能接入"的极简体验
- 三层依赖架构——qiankun(沙箱+增强)→ single-spa(生命周期调度)→ import-html-entry(HTML 加载执行)——职责清晰,分层合理
- 沙箱系统提供了 ProxySandbox(多例,默认)、LegacySandbox(单例,
loose模式)和 SnapshotSandbox(不支持 Proxy 时的降级)三种实现,配合 setInterval 劫持、window 事件监听劫持、动态样式劫持等副作用补丁,实现了基本可靠的运行时隔离 - 生命周期流转遵循 NOT_LOADED → NOT_BOOTSTRAPPED → NOT_MOUNTED → MOUNTED 的状态机模型,bootstrap 只执行一次,mount/unmount 可多次调用
- registerMicroApps 通过延迟加载(
app回调只在路由命中时才执行),避免了注册时的性能开销 - loadApp 是乾坤最核心的函数,包含 HTML 加载、沙箱创建、JS 执行、生命周期包装四个阶段
- start 函数启动预加载和 single-spa 路由监听,通过 Deferred Promise 与 registerMicroApps 协调执行时序
- 乾坤在 2026 年仍是存量项目改造和多框架并存场景的主流选择,但新项目应优先评估 Module Federation
下一章,我们将深入乾坤最具技术含量的模块——JS 沙箱,逐行分析 SnapshotSandbox、LegacySandbox 和 ProxySandbox 的实现,理解 with 语句和 (0, eval) 背后的原理,以及为什么沙箱是微前端中最容易出 bug 的地方。
乾坤的设计里有三条原则在其他架构设计中同样适用:兼容优先——HTML Entry 让子应用几乎不用改代码就能接入;分层清晰——qiankun + single-spa + import-html-entry 的三层划分让每一层可独立演进;显式优于隐式——生命周期、props、全局状态都是显式 API 而非"魔法",调试和维护都更简单。这三点与《Claude Code 源码深度解析》专栏第 15 章讨论的 Skill/Plugin 扩展系统的设计思路是一致的。
思考题
-
概念理解:乾坤的 HTML Entry 和 iframe 都是"给一个 URL 加载应用"。请从 JS 执行上下文、CSS 隔离、DOM 访问、路由同步、性能开销五个维度,系统对比两者的差异。在什么场景下 iframe 反而是更好的选择?
-
源码分析:
loadApp返回的是一个工厂函数(ParcelConfigObjectGetter)而非直接返回生命周期对象。请分析这个设计决策的原因。如果直接返回对象会导致什么问题?提示:考虑loadMicroApp的多实例场景。 -
实践调试:在一个使用乾坤的项目中,子应用 A 设置了
window.globalConfig = { theme: 'dark' },但子应用 B 读取window.globalConfig时得到了undefined。请分析可能的原因,并给出至少两种解决方案。 -
架构设计:乾坤选择在运行时进行沙箱隔离(Proxy/Snapshot),而 Module Federation 不做运行时隔离,而是在构建时声明、运行时协商共享模块。请分析这两种策略各自的优缺点。如果你要设计一个全新的微前端框架,你会选择哪种策略?为什么?
-
性能优化:乾坤的预加载策略使用
requestIdleCallback在浏览器空闲时加载子应用资源。请设计一个更智能的预加载策略——基于用户行为预测(如鼠标悬停在导航链接上)来决定预加载哪些子应用。给出核心实现代码。