Skip to content

第7章 single-spa 核心机制

"框架的灵魂不在它暴露了多少 API——在于它隐藏了多少复杂度,又在正确的地方把控制权还给你。"

本章要点

  • 理解 single-spa "路由即应用边界"的设计哲学及其对微前端架构的深远影响
  • 深入 registerApplication 的参数设计,掌握内部状态机如何管理应用的完整生命周期
  • 掌握 12 种应用状态(NOT_LOADED → UNLOADING)的完整流转规则与边界条件
  • 剖析 reroute 函数的核心调度逻辑:getAppChanges 如何决定加载、挂载与卸载
  • 理解 toLoadPromise / toBootstrapPromise / toMountPromise / toUnmountPromise 四大 Promise 链的执行机制

如果说乾坤是中国微前端生态的代名词,那么 single-spa 就是全球微前端的基石。

这个由 Joel Denning 在 2018 年创建的框架,做了一件看似简单却意义深远的事情:它在浏览器的路由系统和多个独立应用之间,架起了一座桥梁。 当 URL 发生变化时,single-spa 自动判断哪些应用该加载、哪些该挂载、哪些该卸载——整个过程对用户来说是无感知的单页应用体验。

但"简单"是表象。当你打开 single-spa 的源码,你会发现:一个只有约 2000 行核心代码的框架,内部竟然维护着 12 种应用状态、一个精密的状态机、一套复杂的并发调度逻辑。每一行代码都在处理你可能从未想到过的边界情况——应用加载失败怎么办?用户在应用还没挂载完成时又切换了路由怎么办?两个应用的激活条件重叠时该如何处理?

本章将从源码层面彻底剖析 single-spa 的三大核心机制:注册(registerApplication)、状态管理(12 种状态的流转)、和调度(reroute)。读完这一章,你不仅能理解 single-spa 的每一个设计决策,更能看到乾坤、Wujie 等上层框架为什么要在 single-spa 之上做那些增强——因为你会清楚地看到 single-spa "故意不做"的部分。

7.1 设计哲学:路由即应用边界

7.1.1 一个核心假设

single-spa 的整个架构建立在一个核心假设之上:URL 路径是划分应用边界的最自然单位。

这个假设如此朴素,以至于很容易被忽略。但仔细想想——在一个 SPA 中,路由本来就是组织页面的方式。single-spa 只是把这个概念提升了一个层次:路由不仅组织页面,还组织应用。

typescript
// 传统 SPA:路由 → 页面
const routes = [
  { path: '/order', component: OrderPage },
  { path: '/product', component: ProductPage },
];

// single-spa:路由 → 应用
import { registerApplication, start } from 'single-spa';

registerApplication({
  name: 'order-app',
  app: () => System.import('https://cdn.example.com/order/main.js'),
  activeWhen: '/order',
});

registerApplication({
  name: 'product-app',
  app: () => System.import('https://cdn.example.com/product/main.js'),
  activeWhen: '/product',
});

start();

从外部看,这只是把"组件"换成了"应用"。但这一步的跨越带来了根本性的不同:每个"应用"可以是一个独立构建、独立部署、独立运行的前端项目。

7.1.2 "不做什么"比"做什么"更重要

single-spa 最大的设计智慧不在于它做了什么,而在于它故意不做什么

typescript
// single-spa 不做的事情
interface WhatSingleSpaDoesNot {
  jsSandbox: never;       // 不提供 JS 沙箱
  cssSandbox: never;      // 不提供 CSS 隔离
  htmlEntry: never;       // 不支持 HTML Entry 加载
  communication: never;   // 不提供应用间通信机制
}

// single-spa 只做的事情
interface WhatSingleSpaDoes {
  registration: '注册应用与激活条件';
  lifecycle: '管理 bootstrap / mount / unmount 生命周期';
  routing: '监听路由变化,调度应用的挂载与卸载';
  status: '维护每个应用的状态';
}

这是一个极其克制的设计选择。single-spa 的定位是微前端的调度层——它只负责"什么时候加载什么应用",至于应用如何隔离、如何通信、如何共享依赖,全部留给上层方案或开发者自行解决。正是这种克制,使得 single-spa 成为了微前端的"Linux 内核"——乾坤在它之上加了沙箱和 HTML Entry,Wujie 在它之上加了 iframe 隔离。如果 single-spa 自己做了太多,反而会限制上层方案的设计空间。

7.1.3 架构全景

┌─────────────────────────────────────────────────────┐
│                  浏览器路由事件                        │
│       (hashchange / popstate / pushState)            │
└────────────────────┬────────────────────────────────┘

┌─────────────────────────────────────────────────────┐
│                reroute() 调度中枢                     │
│  ┌───────────┐  ┌────────────┐  ┌─────────────┐    │
│  │getAppChg  │  │ Promise 链  │  │ 并发控制     │    │
│  │ 分类应用   │  │ load→boot→ │  │ appChangeUn │    │
│  │ 状态变更   │  │ mount→unmt │  │ derway 标志  │    │
│  └───────────┘  └────────────┘  └─────────────┘    │
└────────────────────┬────────────────────────────────┘
         ┌───────────┼──────────┐
         ▼           ▼          ▼
   ┌─────────┐ ┌──────────┐ ┌────────┐
   │ App A   │ │  App B   │ │ App C  │
   │ MOUNTED │ │ NOT_     │ │ LOAD_  │
   │         │ │ LOADED   │ │ ERROR  │
   └─────────┘ └──────────┘ └────────┘

整个架构可以用一句话概括:路由变化触发 reroute,reroute 根据每个应用的激活条件和当前状态,决定执行加载、启动、挂载或卸载操作。

下图用 Mermaid 展示了 single-spa 的核心调度架构:

🔥 深度洞察:single-spa 的"管道架构"

single-spa 的架构本质上是一个管道(Pipeline)模式——路由事件作为输入,经过 getAppChanges 分类、Promise 链执行、状态更新三个阶段,最终输出一组 DOM 变更。这种管道架构的优势在于:每个阶段都是纯函数式的(输入决定输出),易于测试和推理。它的劣势也同样明显:整个管道是同步触发的,如果一个应用的 mount 函数执行时间过长,会阻塞后续应用的处理。理解这个权衡,才能理解为什么乾坤要在 single-spa 之上加入超时控制和并发优化。

7.2 注册机制:registerApplication 的参数设计与内部状态机

7.2.1 API 表面:四个参数的设计意图

registerApplication 是 single-spa 暴露给开发者的核心 API:

typescript
// 简化后的类型定义
interface AppConfig {
  name: string;
  app: () => Promise<LifeCycles> | LifeCycles;
  activeWhen: ActivityFn | string | (ActivityFn | string)[];
  customProps?: object | ((name: string, location: Location) => object);
}

interface LifeCycles {
  bootstrap: LifeCycleFn | LifeCycleFn[];
  mount: LifeCycleFn | LifeCycleFn[];
  unmount: LifeCycleFn | LifeCycleFn[];
  unload?: LifeCycleFn | LifeCycleFn[];
}

type LifeCycleFn = (props: CustomProps) => Promise<void>;
type ActivityFn = (location: Location) => boolean;

activeWhen 是最灵活的参数,支持三种形式:

typescript
// 形式1:字符串前缀匹配
registerApplication({
  name: 'order',
  app: () => import('./order.js'),
  activeWhen: '/order', // 匹配 /order, /order/list, /order/detail/123
});

// 形式2:函数(完全自定义)
registerApplication({
  name: 'admin',
  app: () => import('./admin.js'),
  activeWhen: (location) =>
    location.pathname.startsWith('/admin')
    && localStorage.getItem('role') === 'admin',
});

// 形式3:数组(多条件 OR)
registerApplication({
  name: 'shared-layout',
  app: () => import('./layout.js'),
  activeWhen: ['/order', '/product', '/user'],
});

7.2.2 源码剖析:registerApplication 的内部实现

javascript
// single-spa 源码 - src/applications/apps.js(简化版)

const apps = []; // 全局应用注册表

export function registerApplication(
  appNameOrConfig, appOrLoadApp, activeWhen, customProps
) {
  // 第一步:参数归一化
  const registration = sanitizeArguments(
    appNameOrConfig, appOrLoadApp, activeWhen, customProps
  );

  // 第二步:校验——不允许重复注册
  if (getAppNames().indexOf(registration.name) !== -1)
    throw Error(`There is already an app registered with name ${registration.name}`);

  // 第三步:创建内部应用对象并放入注册表
  apps.push(
    assign(
      {
        loadErrorTime: null,
        status: NOT_LOADED,  // 关键:初始状态为 NOT_LOADED
        parcels: {},
        devtools: { overlays: { options: {}, selectors: [] } },
      },
      registration
    )
  );

  // 第四步:立即触发一次 reroute
  if (isInBrowser) {
    ensureJQuerySupport();
    reroute();
  }
}

参数归一化中最精巧的是 activeWhen 的处理——字符串、函数、数组被统一转换为一个判定函数:

javascript
// single-spa 源码 - sanitizeActiveWhen
function sanitizeActiveWhen(activeWhen) {
  let activeWhenArray = Array.isArray(activeWhen) ? activeWhen : [activeWhen];
  activeWhenArray = activeWhenArray.map((item) =>
    typeof item === 'function' ? item : pathToActiveWhen(item)
  );
  // 返回 OR 逻辑的组合函数
  return (location) => activeWhenArray.some((fn) => fn(location));
}

function pathToActiveWhen(path, exactMatch) {
  const regex = toDynamicPathValidatorRegex(path, exactMatch);
  return (location) => {
    const route = location.href
      .replace(location.origin, '')
      .replace(location.search, '')
      .split('?')[0];
    return regex.test(route);
  };
}

注意 pathToActiveWhen 做了前缀匹配而非精确匹配——'/order' 会匹配 /order/order/list/order/detail/123。这是有意为之的设计:在微前端场景中,一个子应用通常管理一个路径前缀下的所有页面。

最后一行 reroute() 至关重要——每次注册新应用时,single-spa 都会立即重新评估当前 URL 下哪些应用该被激活。如果新注册的应用恰好匹配当前路由,它会立即开始加载流程,无需等待下一次路由变化。

🔥 深度洞察:apps 数组而非 Map

single-spa 用数组而非 Map 来存储注册的应用。这不是偶然的——数组保留了注册顺序,而这个顺序在某些场景下很重要。当多个应用同时被激活时,它们的挂载顺序与注册顺序一致。如果你的共享布局应用(如导航栏)需要先于业务应用挂载,只需确保它先注册即可。这是一个通过数据结构的选择来隐式表达语义的经典案例。

7.3 应用状态管理:12 种状态的流转

7.3.1 为什么需要 12 种状态

下图展示了 single-spa 完整的 12 种应用状态流转,每个异步操作都有进行中的过渡状态和失败分支:

在一般的 UI 组件生命周期中,我们习惯了简单的状态模型:创建 → 挂载 → 更新 → 卸载。但在微前端场景中,应用代码需要从远端加载(可能失败),加载后需要初始化(可能失败),初始化后需要挂载到 DOM(可能失败),路由变化时需要卸载(可能失败),某些情况下需要彻底卸载释放内存。每个异步操作都有"正在进行中"的过渡状态。当你把"成功/失败"和"进行中/已完成"两个维度交叉组合,12 种状态就自然浮现了。

7.3.2 12 种状态的完整定义

javascript
// 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 UNMOUNTING = 'UNMOUNTING';                 // 正在卸载
export const UNLOADING = 'UNLOADING';                   // 正在完全卸载
export const LOAD_ERROR = 'LOAD_ERROR';                 // 加载失败
export const SKIP_BECAUSE_BROKEN = 'SKIP_BECAUSE_BROKEN'; // 应用已损坏
export const UPDATING = 'UPDATING';                     // 仅 Parcel 使用

基于 VitePress 构建