Skip to content

single-spa 流程解析及简单实现

什么是 single-spa

官方定义

single-spa 是一个将多个单页面应用聚合为一个整体应用的 JavaScript 微前端框架。

核心机制

single-spa 的核心在于设计了一套完整的生命周期机制,并负责调度子应用的生命周期执行。它通过以下方式工作:

  • 路由劫持:监听 URL 变化(或通过手动挂载 Parcel),匹配对应的子应用并执行其生命周期流程
  • 状态管理:维护各个子应用的状态,在适当的时机更改状态并执行相应的生命周期函数
  • 生命周期调度:具体的生命周期实现(如 DOM 挂载、卸载等)由开发者自行决定

[!NOTE] 虽然 single-spa 自称为微前端框架,但它本质上是一个子应用加载器 + 状态机。框架本身并未实现样式隔离、JS 沙箱等微前端特性,这些能力需要开发者在加载子应用时自行实现。

核心概念

Root Config(根配置)

根配置由主应用的 index.htmlmain.js 组成:

  • HTML:负责声明资源路径和基础页面结构
  • JavaScript:负责注册子应用和启动主应用

Application(应用)

子应用必须暴露三个生命周期钩子:bootstrapmountunmount。通常在 mount 阶段开始渲染子 SPA 应用。

Parcel(包裹组件)

Parcel 需要暴露四个生命周期钩子:bootstrapmountunmountupdate

特点

  • 粒度灵活:可大到一个完整的 Application,也可小到一个功能组件
  • 手动控制:与 Application 不同,Parcel 需要开发者手动调用生命周期

SystemJS

一个模块加载器,允许在浏览器中使用 ES6 的 import/export 语法,通过 importmap 指定依赖库的地址。功能上与 webpack 的模块化能力有所重叠。

single-spa-layout

用于在 index.html 中声明式地指定子应用的渲染位置和路由规则。

single-spa-react / single-spa-vue

框架适配器,为 React/Vue 子应用快速生成 bootstrapmountunmount 等生命周期函数的工具库。

single-spa-css

用于隔离前后两个子应用的 CSS 样式,防止样式污染。

single-spa-leaked-globals

管理全局变量的工具库:

  • 在子应用 mount 时恢复/添加全局变量(如 jQuery 的 $ 或 lodash 的 _
  • 在子应用 unmount 时清理这些全局变量

示例代码

主应用 HTML

index.html

html
<div class="navbar">
  <ul>
    <a onclick="singleSpaNavigate('/app1')">
      <li>App 1</li>
    </a>
    <a onclick="singleSpaNavigate('/app2')">
      <li>App 2</li>
    </a>
  </ul>
</div>

主应用 JavaScript

root-application.js

js
import * as singleSpa from 'single-spa';

const pathPrefix = (prefix) => (location) => location.pathname.startsWith(prefix);

singleSpa.registerApplication('app-1', () => import('../app1/app1.js'), pathPrefix('/app1'), {
  content: 'app1111111',
});
singleSpa.registerApplication('app-2', () => import('../app2/app2.js'), pathPrefix('/app2'), {
  content: 'app2222222',
});

console.log('start');
singleSpa.start();

子应用入口

app1/app1.js

js
import React from 'react';
import ReactDOMClient from 'react-dom/client';
import singleSpaReact from 'single-spa-react';
import Root from './root.component.tsx';

console.log('app1');

const reactLifecycles = singleSpaReact({
  React,
  ReactDOMClient,
  rootComponent: Root,
  domElementGetter,
});

export function bootstrap(props) {
  console.log('app1 bootstrap', props);
  return reactLifecycles.bootstrap(props);
}

export function mount(props) {
  console.log('app1 mount', props);
  return reactLifecycles.mount(props);
}

export function unmount(props) {
  console.log('app1 unmount', props);
  return reactLifecycles.unmount(props);
}

function domElementGetter() {
  // 确保有一个 div 供我们渲染
  let el = document.getElementById('app1');
  if (!el) {
    el = document.createElement('div');
    el.id = 'app1';
    document.body.appendChild(el);
  }
  return el;
}

子应用组件

app1/root.component.tsx

jsx
import * as React from 'react';

export default class Root extends React.Component<any, any> {
  render() {
    const message: string = 'This was rendered by app 1 which was written in React';

    return <div style={{ marginTop: '100px' }}>{message}</div>;
  }
}

[!NOTE] app2 的配置代码与 app1 基本一致,只需修改相应的应用名称和 DOM 元素 ID 即可。

主流程分析

以下以访问 http://localhost:8080/ 为例,分析 single-spa 的完整执行流程。

1. 应用注册流程(registerApplication)

执行过程

  1. 通过 sanitizeArguments 格式化用户传递的子应用配置参数
  2. 将格式化后的配置 push 到 apps 数组中
  3. 执行 reroute 函数

2. 路由重定向(reroute)

核心逻辑

reroute 函数内部执行 getAppChanges,该方法会:

  1. 遍历应用数组:遍历 apps 数组,判断每个应用的生命周期状态
  2. 匹配激活规则:通过 shouldBeActive 方法根据当前 location 匹配子应用的激活规则
  3. 分类应用状态:根据 app.status 将应用分为 4 大类:
    • appsToUnload:需要卸载的应用
    • appsToUnmount:需要取消挂载的应用
    • appsToLoad:需要加载的应用
    • appsToMount:需要挂载的应用

初始状态

此时路由尚未匹配到任何应用,因此 appsToLoad 为空。

后续处理

判断 isStarted 状态:

  • 如果未启动,执行 loadApps
  • loadApps 返回一个立即 resolved 的 Promise
  • 内部执行 appsToLoad.map(toLoadPromise),通过 Promise.all 确保所有子应用加载完成
  • 由于 appsToLoad 为空,此阶段结束

3. 启动流程(start)

执行 singleSpa.start() 时:

  1. started 标志置为 true
  2. 执行 patchHistoryApi 进行路由劫持

4. 路由劫持(patchHistoryApi)

该方法完成两项关键任务:

(1)监听路由事件

js
function urlReroute() {
  reroute([], arguments);
}
window.addEventListener('hashchange', urlReroute);
window.addEventListener('popstate', urlReroute);

此时,浏览器导航操作触发的 hashchangepopstate 事件回调函数会被收集到 capturedEventListeners 对象中,待 reroute 后遍历执行。

(2)重写 History API

js
window.history.pushState = patchedUpdateState(window.history.pushState, 'pushState');
window.history.replaceState = patchedUpdateState(originalReplaceState, 'replaceState');

通过劫持 pushStatereplaceState 方法,确保路由变化时能够触发 reroute

后续执行

接着再次执行 reroute,内部判断 isStartedtrue,执行 performAppChanges()。由于此时 apps 中没有任何应用,流程结束。

5. 路由切换流程

触发过程

点击按钮切换到 app1 时,执行链路如下:

text
singleSpaNavigate
  → navigateToUrl
  → window.history.pushState(null, null, url)
  → urlReroute
  → reroute

应用加载

此时 getAppChanges 中的 appsToLoad 包含了匹配当前路由的应用,再次执行 performAppChanges

6. 执行状态变更及生命周期(performAppChanges)

执行机制

立即返回 Promise.resolve(),在回调中执行各状态对应的生命周期钩子列表:

js
const unloadPromises = appsToUnload.map(toUnloadPromise);
const unmountUnloadPromises = appsToUnmount
  .map(toUnmountPromise)
  .map((unmountPromise) => unmountPromise.then(toUnloadPromise));
const allUnmountPromises = unmountUnloadPromises.concat(unloadPromises);
const unmountAllPromise = Promise.all(allUnmountPromises);

// ...

const loadThenMountPromises = appsToLoad.map((app) => {
  return toLoadPromise(app).then((app) => tryToBootstrapAndMount(app, unmountAllPromise));
});

const mountPromises = appsToMount
  .filter((appToMount) => appsToLoad.indexOf(appToMount) < 0)
  .map((appToMount) => {
    return tryToBootstrapAndMount(appToMount, unmountAllPromise);
  });

生命周期函数

  • toUnloadPromise
  • toUnmountPromise
  • toBootstrapPromise(在 tryToBootstrapAndMount 内部)
  • toMountPromise(在 tryToBootstrapAndMount 内部)

这些函数都会执行对应子应用暴露的生命周期钩子方法,通过 Promise 链保证执行顺序。

7. 加载子应用(toLoadPromise)

执行过程

  1. 立即返回 Promise.resolve()
  2. 在回调中执行 loadApp,即注册应用时配置的 import('../app1/app1.js')
  3. 对返回结果进行生命周期函数的合法性检查
  4. 获取子应用的各个生命周期函数并赋值到 app 对象
  5. 通过 flattenFnArray 使钩子方法可串联执行

收尾工作

执行 callAllEventListeners,即执行之前收集的 capturedEventListeners

8. 生命周期事件

performAppChanges 过程中会触发以下钩子事件:

  • single-spa:before-no-app-changesingle-spa:before-app-change
  • single-spa:before-routing-event
  • single-spa:before-mount-routing-event(依赖上一个钩子是否调用 cancelNavigationunmountAllPromise 回调)
  • single-spa:no-app-changesingle-spa:app-change
  • single-spa:routing-event
  • single-spa:before-first-mount
  • single-spa:first-mount

流程图

micro-3-1

single-spa 的局限性

虽然 single-spa 提供了基础的微前端应用加载和生命周期管理能力,但它并未提供完整的微前端解决方案。开发者在使用时需要自行解决以下问题:

1. 复杂的子应用加载逻辑

需要手动配置子应用的加载方式、资源路径和依赖管理,缺乏开箱即用的加载方案。

2. 应用隔离问题

  • JavaScript 隔离:缺少 JS 沙箱机制,子应用间的全局变量可能相互污染
  • CSS 隔离:没有内置的样式隔离方案,需要借助 single-spa-css 等第三方库

3. 副作用清理

子应用切换时遗留的副作用(如定时器、事件监听器、全局变量等)需要开发者手动清理。

4. 状态恢复

子应用卸载后的状态保存和恢复需要自行实现,框架不提供状态管理机制。

5. 应用间通信

缺少内置的应用间通信机制,需要开发者自行设计通信方案(如事件总线、共享状态等)。

6. 性能优化

  • 预加载:没有内置的子应用预加载能力
  • 缓存策略:缺少资源缓存和复用机制

简易版 single-spa 实现

以下是一个简化版的 single-spa 实现,展示了其核心工作原理:

js
// 应用状态常量定义
const NOT_LOADED = 'NOT_LOADED'; // 未加载
const LOADING_SOURCE_CODE = 'LOADING_SOURCE_CODE'; // 加载中
const NOT_BOOTSTRAPPED = 'NOT_BOOTSTRAPPED'; // 未启动
const BOOTSTRAPPING = 'BOOTSTRAPPING'; // 启动中
const NOT_MOUNTED = 'NOT_MOUNTED'; // 未挂载
const MOUNTING = 'MOUNTING'; // 挂载中
const MOUNTED = 'MOUNTED'; // 已挂载
const UNMOUNTING = 'UNMOUNTING'; // 卸载中

// 存放所有注册的子应用
const apps = [];
// 标识 single-spa 是否已启动
let isStarted = false;

/**
 * 注册子应用
 * @param {Object} appConfig - 应用配置对象
 */
export const registerApplication = (appConfig) => {
  apps.push({ ...appConfig, status: NOT_LOADED });
  reroute();
};

/**
 * 启动 single-spa
 */
export const start = () => {
  isStarted = true;
  reroute();
};

/**
 * 路由重定向,核心调度函数
 */
const reroute = async () => {
  const { appsToLoad, appsToMount, appsToUnmount } = getAppChanges();

  if (isStarted) {
    // 已启动:执行完整的生命周期流程
    await Promise.all(appsToUnmount.map(toUnmount));
    await Promise.all(appsToLoad.map(toLoad));
    await Promise.all(appsToMount.map(tryToBoostrapAndMount));
  } else {
    // 未启动:仅加载应用
    await Promise.all(appsToLoad.map(toLoad));
  }
};

/**
 * 加载应用
 * @param {Object} app - 应用对象
 */
const toLoad = async (app) => {
  if (app.status !== NOT_LOADED) return;

  app.status = LOADING_SOURCE_CODE;
  const res = await app.app(); // 执行应用加载函数
  Object.assign(app, { ...res, status: NOT_BOOTSTRAPPED });
};

/**
 * 卸载应用
 * @param {Object} app - 应用对象
 */
const toUnmount = async (app) => {
  if (app.status !== MOUNTED) return;

  app.status = UNMOUNTING;
  await app.unmount(app.customProps);
  app.status = NOT_MOUNTED;
};

/**
 * 尝试启动并挂载应用
 * @param {Object} app - 应用对象
 */
const tryToBoostrapAndMount = async (app) => {
  if (!shouldBeActive(app)) return;
  if (app.status !== NOT_BOOTSTRAPPED && app.status !== NOT_MOUNTED) return;

  // 启动阶段
  app.status = BOOTSTRAPPING;
  await app.bootstrap(app.customProps);
  app.status = NOT_MOUNTED;

  // 挂载阶段
  if (shouldBeActive(app)) {
    app.status = MOUNTING;
    await app.mount(app.customProps);
    app.status = MOUNTED;
  }
};

/**
 * 获取需要变更状态的应用列表
 * @returns {Object} 包含 appsToLoad、appsToMount、appsToUnmount 的对象
 */
const getAppChanges = () =>
  apps.reduce(
    (acc, app) => {
      const { appsToLoad, appsToMount, appsToUnmount } = acc;
      const isActive = shouldBeActive(app);

      if (app.status === NOT_LOADED || (app.status === NOT_BOOTSTRAPPED && isActive)) {
        appsToLoad.push(app);
      } else if ((app.status === NOT_MOUNTED || app.status === BOOTSTRAPPING) && isActive) {
        appsToMount.push(app);
      } else if (app.status === MOUNTED && !isActive) {
        appsToUnmount.push(app);
      }

      return acc;
    },
    { appsToLoad: [], appsToMount: [], appsToUnmount: [] },
  );

/**
 * 判断应用是否应该激活
 * @param {Object} app - 应用对象
 * @returns {boolean} 是否应该激活
 */
const shouldBeActive = (app) => {
  try {
    return app.activeWhen(window.location);
  } catch (err) {
    console.error('activeWhen error', err);
    return false;
  }
};

/**
 * 劫持 History API
 * @param {Function} updateState - 原始的 pushState 或 replaceState 方法
 * @returns {Function} 劫持后的方法
 */
const patchHistoryUpdate =
  (updateState) =>
  (...args) => {
    const urlBefore = window.location.href;
    const result = Reflect.apply(updateState, window.history, args);
    const urlAfter = window.location.href;

    // URL 变化时触发 reroute
    if (urlBefore !== urlAfter) reroute();

    return result;
  };

// 监听路由变化
window.addEventListener('hashchange', reroute);
window.history.pushState = patchHistoryUpdate(window.history.pushState);
window.history.replaceState = patchHistoryUpdate(window.history.replaceState);

核心要点

  1. 状态管理:通过状态常量管理应用的生命周期状态
  2. 路由劫持:劫持 hashchange 事件和 History API,在路由变化时触发 reroute
  3. 应用分类getAppChanges 根据应用状态和激活条件,将应用分为需要加载、挂载、卸载三类
  4. 异步流程:使用 async/awaitPromise.all 确保生命周期按顺序执行
  5. 生命周期:实现了 loadbootstrapmountunmount 四个核心生命周期