Single SPA 与相关生态
1. 介绍
Single SPA 作为做早且最知名的微前端框架,目前已经更新到了第五个大版本,当前版本为 v5.9.4
。
在 Single SPA 中定义了三种微前端实现方式,也可以称为“微前端类型”
- Applications 应用程序:需要有对应的“路由匹配规则”,并且包含完整的 SIngle SPA API 和渲染部分,由 Single SPA 来管理应用生命周期。如果是按照路由来拆分应用的话,这种模式是最合适的一种
- Parcels 沙箱:与路由完全无关,类似一个第三方库。并且与当前使用的技术栈也没有关联,可以由任意应用来挂载或者卸载。每个沙箱模块需要自己实现生命周期函数对应的处理过程,必须包含 bootstrap 初始化、mount 挂载、unmount 卸载方法。
- Common Modules 通用模块:这个就和名字一样,给微应用共同使用的公共模块,不依赖路由,没有声明周期和渲染方式,仅作为一个公共依赖库使用,通常用来保存工具函数等内容
通常拆分一个“巨石应用”的方式,就是使用 Application 将每个业务功能模块进行拆分,针对全局共享的 UI 组件(比如用户信息弹窗、全局消息通知组件)等作为 Parcel 沙箱,最后将 Utils 和 Styles 等公共依赖模块放置到 Common Module 中进行共享。
2. 微应用拆分和管理
虽然将“巨石应用”拆分之后对各个微应用的管理更加便捷,也可以降低开发者的心智负担。但是,如何将所有微应用整合到一起呢?
Single SPA 团队在这个问题上给出了三个建议:
- 公用一个仓库和一套打包配置(Monorepo),对于每个微应用打包之后都在根目录下的 index.html 文件中插入一个 script 标签用来引入微应用。
- 采用 NPM dependencies 管理的方式,这样每个微应用都可以有自己的代码仓库和打包配置,并且互不影响;整体打包统一由根应用来安装微应用依赖并打包成整体。
- 动态加载微应用模块,这样可以让子应用单独部署,由根应用控制加载对应的微应用。这里提供了两种版本控制的方式:
- 修改 Web 服务器,添加对应的版本控制脚本,这样不需要修改其他应用配置
- 使用System.js这样的浏览器模块加载方式,通过控制对应版本的微应用 Url 来实现应用控制
个人总结了三种方式的优缺点对比如下:
拆分方式 | 优点 | 缺点 |
---|---|---|
Monorepo | 1. 拆分简单 2. 方便整体管理 |
1. 项目一样会越来越大 2. 公用打包配置,灵活性不够,受技术栈影响 3. 微应用不能独立构建和部署 4. 公用 package.json ,依赖限制较大,项目安装依赖和启动都不便利 |
NPM dependencies | 1. 每个应用可独立开发,各自的依赖版本与打包配置不受影响 2. 仓库独立,心智负担小,各应用管理更加便捷 3. 通过根应用的 package.json 管理微应用版本,版本管理更加清晰 |
1. 完整应用发布依然受微应用版本限制,不能分开发布和部署 2. 项目初始拆分的难度稍大 3. 通常是公司或者产品组内部项目,需要独立的 NPM 包管理服务或者 Git 配置 |
Dynamic Module Loading | 1. 微应用完全独立,心智负担小 2. 通过配置文件或者脚本进行版本管理,更加便捷 |
1. 首次应用拆分最为困难 |
3. Single SPA 生态
为了方便用户快速且简单的接入 Single SPA,该团队提供了 13 个用于创建微应用的 Generic lifecycle hooks 模板依赖,常用的包括:
- single-spa-react
- single-spa-vue
- single-spa-angularjs
- single-spa-preact
- single-spa-svelte
在接入对应技术栈的微应用时,只需要使用相应的依赖包来创建一个包含基础生命周期 bootstrap、mount、update、unmount 的微应用实例,之后在微应用入口处导出需要的生命周期数组即可。
并且每个依赖都实现了对应技术栈的 Parcel 沙箱构建方法。
以 Vue 2 项目为例初始化一个微应用:
import Vue from 'vue';
import singleSpaVue from 'single-spa-vue';
import App from './App.vue';
import router from './router';
// 根据依赖实例化对应的组件实例和生命周期
const vueLifecycles = singleSpaVue({
Vue,
appOptions: {
render: (h) => h(App),
router,
}
})
// 导出需要使用的生命周期变量
export const bootstrap = vueLifecycles.bootstrap;
export const mount = vueLifecycles.mount;
export const unmount = vueLifecycles.unmount;
// 如果你需要在某一个生命周期(例如初始化 bootstrap)时执行其他事件,也可以导出一个数组
const something1 = () => {
console.log('do something')
}
const something2 = () => {
console.log('do something')
}
export const bootstrap = [something1, something2, vueLifecycles.bootstrap];
这些依赖返回的生命周期函数都是 Promise
4. Single SPA Vue
因为笔者常用的框架就是 Vue,所以这里的代码分析也用的是 Vue 对应的依赖库。
single-spa-vue: Generic lifecycle hooks for Vue.js applications,一个为 Vue.js 应用程序的通用生命周期钩子函数的创建程序。
这个库的核心代码就 200 行,大致内容如下:
const defaultOpts = {
// ...
}
export default function singleSpaVue(userOpts) {
// 1. arguments validate 参数校验
const opts = {
...defaultOpts,
...userOpts,
};
// 2. 设置 Vue 实例创建方法,会判断 Vue 版本
opts.createApp = opts.createApp || (opts.Vue && opts.Vue.createApp);
// 3. 利用闭包,保存已挂载的实例
let mountedInstances = {};
// 4. 最后返回包含生命周期函数的对象
return {
bootstrap: bootstrap.bind(null, opts, mountedInstances),
mount: mount.bind(null, opts, mountedInstances),
unmount: unmount.bind(null, opts, mountedInstances),
update: update.bind(null, opts, mountedInstances),
};
}
4.1 bootstrap 初始化
bootstrap 方法仅作为实例的初始化方法,主要用于处理当前微应用的挂载对象,最后返回一个 Promise。
如果用户配置了 loadRootComponent,则将该实例对应的跟组件设置为 loadRootComponent 方法的返回值(Promise.resolve 的值)
function bootstrap(opts) {
if (opts.loadRootComponent) {
return opts.loadRootComponent().then((root) => (opts.rootComponent = root));
} else {
return Promise.resolve();
}
}
4.2 mount 挂载
该周期主要将微应用实例挂载到根应用(或者目标DOM)上,但是也包含了微应用实例的实例化过程。
function resolveAppOptions(opts, props) {
if (typeof opts.appOptions === "function") {
return opts.appOptions(props);
}
return Promise.resolve({ ...opts.appOptions });
}
function mount(opts, mountedInstances, props) {
// 0. 定义实例配置项
const instance = {};
return Promise.resolve().then(() => {
// 1. 处理微应用参数
return resolveAppOptions(opts, props).then((appOptions) => {
// 2. 配置 目标挂载节点查找条件
if (props.domElement && !appOptions.el) {
appOptions.el = props.domElement;
}
// 3. 查找挂载节点(这里省略了校验报错部分)
let domEl;
if (appOptions.el) {
if (typeof appOptions.el === "string") {
domEl = document.querySelector(appOptions.el);
} else {
domEl = appOptions.el;
appOptions.el = `#${CSS.escape(domEl.id)}`;
}
}
// 4. 不存在配置的查找条件,则创建一个 dom 节点用于挂载
else {
const htmlId = `single-spa-application:${props.name}`;
appOptions.el = `#${CSS.escape(htmlId)}`;
domEl = document.getElementById(htmlId);
if (!domEl) {
domEl = document.createElement("div");
domEl.id = htmlId;
document.body.appendChild(domEl);
}
}
// 5. 如果是要用实例替换挂载节点的话,会将Vue实例直接插入到该节点上
if (!opts.replaceMode) {
appOptions.el = appOptions.el + " .single-spa-container";
}
// 6. 希望始终将 Vue 实例挂载到该节点下的子节点中
if (!domEl.querySelector(".single-spa-container")) {
const singleSpaContainer = document.createElement("div");
singleSpaContainer.className = "single-spa-container";
domEl.appendChild(singleSpaContainer);
}
// 7. 设置实例的真正挂载 dom
instance.domEl = domEl;
// 8. 设置 Vue 实例化的 render 渲染函数和基础 data
if (!appOptions.render && !appOptions.template && opts.rootComponent) {
appOptions.render = (h) => h(opts.rootComponent);
}
if (!appOptions.data) {
appOptions.data = {};
}
appOptions.data = () => ({ ...appOptions.data, ...props });
// 9. 根据条件实例化 Vue 应用
if (opts.createApp) {
// vue 3
instance.vueInstance = opts.createApp(appOptions);
if (opts.handleInstance) {
return Promise.resolve(
opts.handleInstance(instance.vueInstance, props)
).then(function () {
instance.root = instance.vueInstance.mount(appOptions.el);
mountedInstances[props.name] = instance;
return instance.vueInstance;
});
} else {
instance.root = instance.vueInstance.mount(appOptions.el);
}
} else {
// vue 2
instance.vueInstance = new opts.Vue(appOptions);
if (instance.vueInstance.bind) {
instance.vueInstance = instance.vueInstance.bind(
instance.vueInstance
);
}
if (opts.handleInstance) {
return Promise.resolve(
opts.handleInstance(instance.vueInstance, props)
).then(function () {
mountedInstances[props.name] = instance;
return instance.vueInstance;
});
}
}
// 10. 在闭包对象中设置已实例化的微应用并返回应用实例
mountedInstances[props.name] = instance;
return instance.vueInstance;
});
});
}
4.3 update 更新与 unmount 卸载
更新与卸载其实和 bootstrap 初始化类似,逻辑比较简单。
// 数据更新
function update(opts, mountedInstances, props) {
return Promise.resolve().then(() => {
const instance = mountedInstances[props.name];
// 合并原始参数和新传入参数
const data = {
...(opts.appOptions.data || {}),
...props,
};
const root = instance.root || instance.vueInstance;
// 更新实例数据
for (let prop in data) {
root[prop] = data[prop];
}
});
}
// 实例卸载
function unmount(opts, mountedInstances, props) {
return Promise.resolve().then(() => {
// 1. 获取到当前微应用实例
const instance = mountedInstances[props.name];
// 2. 判断 Vue 版本来销毁实例
if (opts.createApp) {
instance.vueInstance.unmount(instance.domEl);
} else {
instance.vueInstance.$destroy();
instance.vueInstance.$el.innerHTML = "";
}
// 3. 从闭包实例对象中删除该应用实例的引用
delete instance.vueInstance;
// 4. 清空实例 DOM 节点并移除
if (instance.domEl) {
instance.domEl.innerHTML = "";
delete instance.domEl;
}
});
}
5. 总结
在拆分以前的“巨石应用”的最佳方式,就是采用 Dynamic Module Loading 动态模块加载的方式,不仅减少了单一仓库代码量和处理逻辑,更使得微应用之间的耦合关系达到了最低,各应用之间的独立部署和版本迭代也更加人性化。
为了方便用户使用,Single SPA 的团队也在努力完善他们的生态,在他们提供的生命周期处理依赖的帮助下,用户可以很轻松的接入一个新的微应用。