微前端源码精讲

第3章 乾坤架构总览

作者 杨艺韬 · 14,967 字 · 发布于 · 更新于

第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)去加载它。这意味着你需要:

  1. 确保子应用的构建产物是加载器认得的模块格式(如 SystemJS 的 System.register 或 UMD)
  2. 手动管理子应用的 CSS 加载
  3. 处理子应用内部的静态资源路径问题
  4. 解决子应用多个 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工厂函数

这个函数的代码量虽大,但逻辑非常清晰——四个阶段依次执行:

  1. 加载 HTML:通过 import-html-entry 获取并解析子应用的 HTML,并预先下载全部外链脚本
  2. 创建沙箱:根据配置创建 ProxySandbox(默认)、LegacySandbox(loose)或 SnapshotSandbox(无 Proxy)
  3. 执行 JS:先跑 beforeLoad 钩子,再在沙箱环境中执行子应用的 JavaScript 代码
  4. 包装生命周期:将沙箱的激活/恢复、容器渲染、框架钩子、全局状态 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: '底层框架、高度定制场景',
  },
];

几个关键观察:

  1. single-spa 的周下载量远超乾坤——因为很多方案(包括乾坤)底层依赖它
  2. 乾坤仍保持每周数万次的下载量——远高于 micro-app、无界等后来者,说明存量项目仍在大量使用
  3. Module Federation 已是新项目的重要候选——尤其在统一技术栈的团队中(上表没有它独立的下载量数据,这一条是定性判断)
  4. 乾坤 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 扩展系统的设计思路是一致的。


思考题

  1. 概念理解:乾坤的 HTML Entry 和 iframe 都是"给一个 URL 加载应用"。请从 JS 执行上下文、CSS 隔离、DOM 访问、路由同步、性能开销五个维度,系统对比两者的差异。在什么场景下 iframe 反而是更好的选择?

  2. 源码分析:loadApp 返回的是一个工厂函数(ParcelConfigObjectGetter)而非直接返回生命周期对象。请分析这个设计决策的原因。如果直接返回对象会导致什么问题?提示:考虑 loadMicroApp 的多实例场景。

  3. 实践调试:在一个使用乾坤的项目中,子应用 A 设置了 window.globalConfig = { theme: 'dark' },但子应用 B 读取 window.globalConfig 时得到了 undefined。请分析可能的原因,并给出至少两种解决方案。

  4. 架构设计:乾坤选择在运行时进行沙箱隔离(Proxy/Snapshot),而 Module Federation 不做运行时隔离,而是在构建时声明、运行时协商共享模块。请分析这两种策略各自的优缺点。如果你要设计一个全新的微前端框架,你会选择哪种策略?为什么?

  5. 性能优化:乾坤的预加载策略使用 requestIdleCallback 在浏览器空闲时加载子应用资源。请设计一个更智能的预加载策略——基于用户行为预测(如鼠标悬停在导航链接上)来决定预加载哪些子应用。给出核心实现代码。