single-spa 流程解析及简单实现
什么是 single-spa
官方定义
single-spa 是一个将多个单页面应用聚合为一个整体应用的 JavaScript 微前端框架。
核心机制
single-spa 的核心在于设计了一套完整的生命周期机制,并负责调度子应用的生命周期执行。它通过以下方式工作:
- 路由劫持:监听 URL 变化(或通过手动挂载 Parcel),匹配对应的子应用并执行其生命周期流程
- 状态管理:维护各个子应用的状态,在适当的时机更改状态并执行相应的生命周期函数
- 生命周期调度:具体的生命周期实现(如 DOM 挂载、卸载等)由开发者自行决定
[!NOTE] 虽然 single-spa 自称为微前端框架,但它本质上是一个子应用加载器 + 状态机。框架本身并未实现样式隔离、JS 沙箱等微前端特性,这些能力需要开发者在加载子应用时自行实现。
核心概念
Root Config(根配置)
根配置由主应用的 index.html 和 main.js 组成:
- HTML:负责声明资源路径和基础页面结构
- JavaScript:负责注册子应用和启动主应用
Application(应用)
子应用必须暴露三个生命周期钩子:bootstrap、mount、unmount。通常在 mount 阶段开始渲染子 SPA 应用。
Parcel(包裹组件)
Parcel 需要暴露四个生命周期钩子:bootstrap、mount、unmount 和 update。
特点:
- 粒度灵活:可大到一个完整的 Application,也可小到一个功能组件
- 手动控制:与 Application 不同,Parcel 需要开发者手动调用生命周期
SystemJS
一个模块加载器,允许在浏览器中使用 ES6 的 import/export 语法,通过 importmap 指定依赖库的地址。功能上与 webpack 的模块化能力有所重叠。
single-spa-layout
用于在 index.html 中声明式地指定子应用的渲染位置和路由规则。
single-spa-react / single-spa-vue
框架适配器,为 React/Vue 子应用快速生成 bootstrap、mount、unmount 等生命周期函数的工具库。
single-spa-css
用于隔离前后两个子应用的 CSS 样式,防止样式污染。
single-spa-leaked-globals
管理全局变量的工具库:
- 在子应用
mount时恢复/添加全局变量(如 jQuery 的$或 lodash 的_) - 在子应用
unmount时清理这些全局变量
示例代码
主应用 HTML
index.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
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
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
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)
执行过程:
- 通过
sanitizeArguments格式化用户传递的子应用配置参数 - 将格式化后的配置 push 到
apps数组中 - 执行
reroute函数
2. 路由重定向(reroute)
核心逻辑:
reroute 函数内部执行 getAppChanges,该方法会:
- 遍历应用数组:遍历
apps数组,判断每个应用的生命周期状态 - 匹配激活规则:通过
shouldBeActive方法根据当前location匹配子应用的激活规则 - 分类应用状态:根据
app.status将应用分为 4 大类:appsToUnload:需要卸载的应用appsToUnmount:需要取消挂载的应用appsToLoad:需要加载的应用appsToMount:需要挂载的应用
初始状态:
此时路由尚未匹配到任何应用,因此 appsToLoad 为空。
后续处理:
判断 isStarted 状态:
- 如果未启动,执行
loadApps loadApps返回一个立即 resolved 的 Promise- 内部执行
appsToLoad.map(toLoadPromise),通过Promise.all确保所有子应用加载完成 - 由于
appsToLoad为空,此阶段结束
3. 启动流程(start)
执行 singleSpa.start() 时:
- 将
started标志置为true - 执行
patchHistoryApi进行路由劫持
4. 路由劫持(patchHistoryApi)
该方法完成两项关键任务:
(1)监听路由事件
function urlReroute() {
reroute([], arguments);
}
window.addEventListener('hashchange', urlReroute);
window.addEventListener('popstate', urlReroute);此时,浏览器导航操作触发的 hashchange 或 popstate 事件回调函数会被收集到 capturedEventListeners 对象中,待 reroute 后遍历执行。
(2)重写 History API
window.history.pushState = patchedUpdateState(window.history.pushState, 'pushState');
window.history.replaceState = patchedUpdateState(originalReplaceState, 'replaceState');通过劫持 pushState 和 replaceState 方法,确保路由变化时能够触发 reroute。
后续执行:
接着再次执行 reroute,内部判断 isStarted 为 true,执行 performAppChanges()。由于此时 apps 中没有任何应用,流程结束。
5. 路由切换流程
触发过程:
点击按钮切换到 app1 时,执行链路如下:
singleSpaNavigate
→ navigateToUrl
→ window.history.pushState(null, null, url)
→ urlReroute
→ reroute应用加载:
此时 getAppChanges 中的 appsToLoad 包含了匹配当前路由的应用,再次执行 performAppChanges。
6. 执行状态变更及生命周期(performAppChanges)
执行机制:
立即返回 Promise.resolve(),在回调中执行各状态对应的生命周期钩子列表:
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);
});生命周期函数:
toUnloadPromisetoUnmountPromisetoBootstrapPromise(在tryToBootstrapAndMount内部)toMountPromise(在tryToBootstrapAndMount内部)
这些函数都会执行对应子应用暴露的生命周期钩子方法,通过 Promise 链保证执行顺序。
7. 加载子应用(toLoadPromise)
执行过程:
- 立即返回
Promise.resolve() - 在回调中执行
loadApp,即注册应用时配置的import('../app1/app1.js') - 对返回结果进行生命周期函数的合法性检查
- 获取子应用的各个生命周期函数并赋值到
app对象 - 通过
flattenFnArray使钩子方法可串联执行
收尾工作:
执行 callAllEventListeners,即执行之前收集的 capturedEventListeners。
8. 生命周期事件
performAppChanges 过程中会触发以下钩子事件:
single-spa:before-no-app-change或single-spa:before-app-changesingle-spa:before-routing-eventsingle-spa:before-mount-routing-event(依赖上一个钩子是否调用cancelNavigation或unmountAllPromise回调)single-spa:no-app-change或single-spa:app-changesingle-spa:routing-eventsingle-spa:before-first-mountsingle-spa:first-mount
流程图

single-spa 的局限性
虽然 single-spa 提供了基础的微前端应用加载和生命周期管理能力,但它并未提供完整的微前端解决方案。开发者在使用时需要自行解决以下问题:
1. 复杂的子应用加载逻辑
需要手动配置子应用的加载方式、资源路径和依赖管理,缺乏开箱即用的加载方案。
2. 应用隔离问题
- JavaScript 隔离:缺少 JS 沙箱机制,子应用间的全局变量可能相互污染
- CSS 隔离:没有内置的样式隔离方案,需要借助
single-spa-css等第三方库
3. 副作用清理
子应用切换时遗留的副作用(如定时器、事件监听器、全局变量等)需要开发者手动清理。
4. 状态恢复
子应用卸载后的状态保存和恢复需要自行实现,框架不提供状态管理机制。
5. 应用间通信
缺少内置的应用间通信机制,需要开发者自行设计通信方案(如事件总线、共享状态等)。
6. 性能优化
- 预加载:没有内置的子应用预加载能力
- 缓存策略:缺少资源缓存和复用机制
简易版 single-spa 实现
以下是一个简化版的 single-spa 实现,展示了其核心工作原理:
// 应用状态常量定义
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);核心要点
- 状态管理:通过状态常量管理应用的生命周期状态
- 路由劫持:劫持
hashchange事件和 History API,在路由变化时触发reroute - 应用分类:
getAppChanges根据应用状态和激活条件,将应用分为需要加载、挂载、卸载三类 - 异步流程:使用
async/await和Promise.all确保生命周期按顺序执行 - 生命周期:实现了
load、bootstrap、mount、unmount四个核心生命周期