微前端架构之single-spa
single-spa是什么
Single-spa 是一个将多个单页面应用聚合为一个整体应用的 JavaScript 微前端框架。
好处:
- 在同一页面上使用多个前端框架 而不用刷新页面 (React, AngularJS, Angular, Ember, 你正在使用的框架)
- 独立部署每一个单页面应用
- 新功能使用新框架,旧的单页应用不用重写可以共存
- 改善初始加载时间,迟加载代码
single-spa做了什么
single-spa是一个顶层路由。当路由处于活动状态时,它将下载并执行该路由下的相关代码。
路由的代码被称为应用,每个代码都可以(可选)拥有自己的git仓库、CI进程,并且可以独立部署。这些应用即可以用相同框架实现,也可以用不同框架实现。
single-spa包括些什么:
- 1、Applications,每个应用程序本身就是一个完整的 SPA (某种程度上)。 每个应用程序都可以响应 url 路由事件,并且必须知道如何从 DOM 中初始化、挂载和卸载自己。 传统 SPA 应用程序和 Single SPA 应用程序的主要区别在于,
它们必须能够与其他应用程序共存,而且它们没有各自的 html 页面
。
例如,React 或 Vue spa 就是应用程序。 当激活时,它们监听 url 路由事件并将内容放在 DOM上。 当它们处于非活动状态时,它们不侦听 url 路由事件,并且完全从 DOM 中删除。
- 一个 single-spa-config配置, 这是html页面和向Single SPA注册应用程序的JavaScript。每个应用程序都注册了三件东西
- A name (应用的标识)
- A function (加载应用程序的代码)
- A function (确定应用程序何时处于活动状态/非活动状态)
single-spa的使用方式
Single-spa 适用于 ES5、 ES6 + 、 TypeScript、 Webpack、 SystemJS、 Gulp、 Grunt、 Bower、 ember-cli 或 任何可用的构建系统。 您可以 npm 安装它,jspm 安装它,如果您愿意,甚至可以使用 <script> 标签。
新项目中使用single-spa
1、创建相当简单 create-single-spa cli
https://github.com/single-spa/create-single-spa/
# 全局安装
npm install --global create-single-spa
# or
yarn global add create-single-spa
# 之后执行
create-single-spa
# 本地安装
npm init single-spa
# or
npx create-single-spa
# or
yarn create single-spa
推荐设置
我们建议使用浏览器内ES模块 + import maps (或者SystemJS填充这些,如果你需要更好的浏览器支持)的设置。这种设置有几个优点:
- 公共模块易于管理,并且只下载一次。如果使用SystemJS,也可以预加载它们来提高速度。
- 共享代码/函数/变量就像导入/导出一样简单,就像在一个整体中设置一样。
- 延迟加载应用程序很容易,这使您能够加速初始加载时间。
- 每个应用程序(又名微服务,又名ES模块)都可以独立开发和部署。团队可以按照自己的进度工作、实验(在组织定义的合理范围内)、QA和部署。这通常也意味着发布周期可以缩短到几天,而不是几周或几个月。
- 很棒的开发人员体验(DX):转到dev环境并添加一个导入映射,该映射将应用程序的url指向您的本地主机。请参阅下面的章节了解详细信息。
single-spa中微前端的类型
- single-spa applications:为一组特定路由渲染组件的微前端。
- single-spa parcels: 不受路由控制,渲染组件的微前端。
- utility modules: 非渲染组件,用于暴露共享javascript逻辑的微前端。
容器Root | 应用程序 | 沙箱 | 公共模块 |
---|---|---|---|
主路由 | 有多个路由 | 无路由 | 无路由 |
API | 声明API | 必要的API | 没有single-spa API |
渲染UI | 渲染UI | 渲染UI | 不直接渲染UI |
生命周期 | single-spa管理生命周期 | 自定义管理生命周期 | 没有生命周期 |
什么时候用 | 核心构建模块 | 仅在多个框架中需要 | 共享通用逻辑时使用 |
应用程序
single-spa 提供 registerApplication
API注册应用
沙箱
主要是让您在多个框架中编写应用程序时可以在应用程序之间重用UI。
管理parcels的生命周期
mountParcel
或 mountRootParcel
将立即挂载parcel并返回这个parcel对象。 需要卸载需要手动调用 parcel的 unmount
.
Parcels 最适合在框架之间共享UI部分 ???
如: application1 用Vue编写,包含创建用户的所有UI和逻辑。 application2是用React编写的,需要创建一个用户。 使用single-spa parcels可以让您包装application2Vue组件。尽管框架不同,但它可以在`application2'内部运行。 将Parcels视为Web组件的single-spa特定实现。
公共模块
共享通用逻辑,可以是一个普通的js对象
如: 登录授权、 读取数据fetch
1、每个应用都访问服务器,这会在每个应用中创建重复的工作;
2、使用公共模块,创建一个实现授权逻辑的模块,通过导出/导入的方式使用这些授权
Root Config
根目录下的两个配置,用于启动single-spa应用
- 所有微前端应用共享的根Html页面 【index.ejs】
- 调用
singleSpa.registerApplication()
的js 【study-root-config.js】
// single-spa-config.js
import { registerApplication, start } from 'single-spa';
// param1: 一个应用的标识
// param2: Function 一个应用要执行的代码
// param3: Function 何时激活这些应用:主路由
// param4: 可选的扩展参数
registerApplication(
'app2',
() => import('src/app2/main.js'),
(location) => location.pathname.startsWith('/app2'),
{ some: 'value' }
);
registerApplication({
name: 'app1',
app: () => import('src/app1/main.js'),
activeWhen: '/app1',
customProps: {
some: 'value',
}
);
start();
参数说明
name:
应用的标识,必须Sting-
Loading Function or Application
registerApplication
可以是一个Promise类型的 加载函数,也可以是一个已经被解析的应用。const application = { bootstrap: () => Promise.resolve(), //bootstrap function mount: () => Promise.resolve(), //mount function unmount: () => Promise.resolve(), //unmount function } registerApplication('applicationName', application, activityFunction)
加载函数
registerApplication
的第二个参数必须是返回promise
的函数(或"async function
"方法)。这个函数没有入参,会在应用第一次被下载时调用。返回的Promise resolve
之后的结果必须是一个可以被解析的应用。常见的实现方法是使用import
加载:() => import('/path/to/application.js')
-
激活函数
第3个参数要求是一个纯函数(只依赖参数,不产生副作用), 根据 location.path决定哪个应用被激活。
single-spa根据顶级路由查找应用,每个应用自己处理自身的子路由。
支持通配符方式配置:'/users/:userId/profile'
支持多路径方式配置:['/pathname/#/hash', '/app1']包含以下情况
1、
hashchange or popstate
事件触发时
2、pushState or replaceState
被调用时
3、在single-spa上手动调用[triggerAppChange
] 方法
4、checkActivityFunctions
方法被调用时 -
自定义属性
第4个参数:参数会传给single-spa的lifecycle
函数singleSpa.registerApplication({ name: 'myApp', app: () => import('src/myApp/main.js'), activeWhen: ['/myApp', (location) => location.pathname.startsWith('/some/other/path')], customProps: { some: 'value', }, }); singleSpa.registerApplication({ name: 'myApp', app: () => import('src/myApp/main.js'), activeWhen: ['/myApp', (location) => location.pathname.startsWith('/some/other/path')], // 函数时,参数1:应用名:myapp, 参数2: window.location customProps: (name, location) => ({ some: 'value', }), });
-
最后调用
singleSpa.start()
start()
方法,必须被single-spa的配置文件调用, 这样应用才会真的被挂载。 在start被调用之前,应用先被下载,但不会初始化/挂载/卸载。import { start } from 'single-spa'; /*在注册应用之前调用start意味着single-spa可以立即安装应用,无需等待单页应用的任何初始设置。*/ start(); // 注册应用。。。。
同时注册两个路由
一个path的变动,同时两个应用被激活?? 可以。
<div>需要一个id,这个id的以single-spa-application前缀开头,后面接着你的应用的名字。比如,如果你的应用名字叫做app-name,就创建一个id为 single-spa-application:app-name的div。
<div id="single-spa-application:app-name"></div>
<div id="single-spa-application:other-app"></div>
构建应用
single-spa 应用与普通的单页面是一样的,只不过它没有HTML页面。在一个single-spa中,有N多被注册的应用,这些应用可以框架不同,自己维护自己的路由,只需要挂载便可以渲染自己的页面及功能。
“挂载”(mounted)的概念指的是被注册的应用内容是否已展示在DOM上。我们可通过应用的activity function来判断其是否已被挂载。未挂载前,一直休眠。
创建并注册应用
要添加一个应用,首先需要注册该应用。一旦应用被注册后,必须在其入口文件(entry point)实现下面提到的各个生命周期函数。
生命周期
- 下载(loaded): 注册的应用在第1次 activity时开始下载,下载过程中尽可能执行少的操作,如果需要下载时执行的操作,可以放到子应用入口文件中。
- 初始化(bootstrap/initialized):required 第1次被挂载前执行一次
- 被挂载(mounted) required 应用被激活时执行,会根据当前url激活主路由,创建dom,监听事件,render等,子路由的改变(如:hashchange 或 popstate)不会再触发,需要应用自己处理
- 卸载(unmounted) required 应用由激活变为未激活时触发,会清理挂载应用的dom,event,内存,全局变量,消息订阅等
- 被移除(unloaded) 可选 无代表应用无需被移除,移除的应用,下次激活时,会重新初始化。可以实现 热下载。
注:
1、bootstrap, mount, and unmount的实现是必须的,unload则是可选的
2、生命周期函数必须有返回值,可以是Promise或者async函数
3、如果导出的是函数数组而不是单个函数,这些函数会被依次调用,对于promise函数,会等到resolve之后再调用下一个函数
4、如果 single-spa 未启动,各个应用会被下载,但不会被初始化、挂载或卸载。
超时配置
millis: 最终控制台输出的警告毫秒数
warningMillis: 警告每隔多少毫秒输出一次
切换应用时的过渡
在生命周期函数中自己实现过滤效果
demo:
https://github.com/frehner/singlespa-transitions
https://github.com/reactjs/react-transition-group
旧项目迁移至single-spa
拆分应用
前端系统应用
-
1、一个代码仓库, 一个build包
优点:容易部署,有单一版本控制的优点(monorepo)
不足:项目越大时,打包越慢;构建部署在捆绑在一起,不能临时发版 -
2、NPM包
优点:开发熟悉,易实现;发布到npm前可以分别打包
不足:父应用必须重装子应用重新构建部署 -
2、动态加载模块
优点:灵活,代码独立
不足:搭建难度稍大
实现:- web服务器,创建动态脚本加载子应用正确版本;
- 使用模块加载,如: systemJs在浏览器动态下载并执行js
迁移现在应用
三步
1、创建一个single-spa配置
2、将spa应用转为注册应用
3、调整html,使用single-spa配置生效
1、实现生命周期
single-spa 生态系统 包含了single-spa对大部分框架的支持
https://single-spa.js.org/docs/ecosystem/
自己实现,就需要在 unmount
中,能够清理其 DOM 节点,DOM 事件侦听(所有的事件侦听,尤其是 hashchange 和 popstate)以及释放内存。
2、解决css、font、script依赖问题
现有spa应用转为无html应用后,这些资源依赖问题都需要解决:一种方案全部打包到js中; 其他方案呢?
沙箱 Parcels
single-spa的一个高级特性,与框架无关,api与注册应用一致,不同的是:parcel组件需要手动挂载,而不是通过 activity 方法被动激活。在不熟悉它之前,尽量不要用。
示例
// parcel 的实现
const parcelConfig = {
bootstrap() {
// 初始化
return Promise.resolve()
},
mount() {
// 使用某个框架来创建和初始化dom
return Promise.resolve()
},
unmount() {
// 使用某个框架卸载dom,做其他的清理工作
return Promise.resolve()
}
}
// 如何挂载parcel
const domElement = document.getElementById('place-in-dom-to-mount-parcel')
const parcelProps = {domElement, customProp1: 'foo'}
const parcel = singleSpa.mountRootParcel(parcelConfig, parcelProps)
// parcel 被挂载,在mountPromise中结束挂载
parcel.mountPromise.then(() => {
console.log('finished mounting parcel!')
// 如果我们想重新渲染parcel,可以调用update生命周期方法,其返回值是一个 promise
parcelProps.customProp1 = 'bar'
return parcel.update(parcelProps)
})
.then(() => {
// 在此处调用unmount生命周期方法来卸载parcel. 返回promise
return parcel.unmount()
})
Pacel配置
一个parcel只是一个由3到4个方法组成的对象。每个方法返回的都是一个prmise。 生命周期与应用基本一致。
- 初始化(Bootstrap) 在parcel第一次挂载前调用一次
- 挂载(mount) 在mountParcel方法被调用且parcel未挂载时触发,一般会创建DOM元素、初始化事件监听等,从而为用户提供展示内容。
- 卸载(unmount) parcel已经被挂载,且满足下列某个条件:1、unmount()被调用; 2、父parcel或者应用被卸载
- 更新(Update) 可选 调用parcel.update()时触发,使用者调用前需确认parcel已实现
single-spa的API
参考文档: https://zh-hans.single-spa.js.org/docs/api
single-spa的扩展
一般来说,微前端需要解决的问题分为两大类:
1、应用的加载与切换
2、应用的隔离与通信
应用的加载与切换需要解决的问题包括:路由问题、应用入口、应用加载;应用的隔离与通信需要解决的问题包括:js隔离、css样式隔离、应用间通信。
single-spa很好地解决了路由和应用入口两个问题,但并没有解决应用加载问题,而是将该问题暴露出来由使用者实现(一般可以用system.js或原生script标签来实现);qiankun在此基础上封装了一个应用加载方案(即import-html-entry),并给出了js隔离、css样式隔离和应用间通信三个问题的解决方案,同时提供了预加载功能。
single-spa原理
应用入口
single-spa采用的是协议入口,即只要实现了single-spa的入口协议规范,它就是可加载的应用。single-spa的规范要求应用入口必须暴露出以下三个生命周期钩子函数,且必须返回Promise,以保证single-spa可以注册回调函数:
应用加载
<script type="systemjs-importmap">
{
"imports": {
"app1": "http://localhost:8080/app1.js",
"app2": "http://localhost:8081/app2.js",
"single-spa": "https://cdnjs.cloudflare.com/ajax/libs/single-spa/4.3.7/system/single-spa.min.js"
}
}
</script>
... // system.js的相关依赖文件
<script>
(function(){
// 加载single-spa
System.import('single-spa').then((res)=>{
var singleSpa = res;
// 注册子应用
singleSpa.registerApplication('app1',
() => System.import('app1'),
location => location.hash.startsWith(`#/app1`);
);
singleSpa.registerApplication('app2',
() => System.import('app2'),
location => location.hash.startsWith(`#/app2`);
);
// 启动single-spa
singleSpa.start();
})
})()
</script>
// single-spa 的start方法
export function start(opts) {
started = true;
if (opts && opts.urlRerouteOnly) {
setUrlRerouteOnly(opts.urlRerouteOnly);
}
if (isInBrowser) {
reroute();
}
}
single-spa的弊端:
首先我们必须手动实现应用加载逻辑,挨个罗列子应用需要加载的资源,这在大型项目里是十分困难的(特别是使用了文件名hash时);另外它只能以js文件为入口,无法直接以html为入口,这使得嵌入子应用变得很困难,也正因此,single-spa不能直接加载jQuery应用。
single-spa只是负责把应用加载到一个页面中,至于应用能否协同工作,是很难保证的
qiankun解决方案
https://github.com/umijs/qiankun
1、应用加载
使用npm插件 import-html-entry
主要方法:importHTML(url, opts = {})
简单点说:importHtml 通过fetch获取远程的脚本、样式文件内容, 然后通过正则表达式,把js,css提取出来,放到各自的数组里,js能过eval执行,并导出供其他模块调用
2、css,js隔离
- 通过importHtml 加载html并把外部样式转为内部样式(使用类个shandow dom 或 vue scope)方式, 实现样式隔离
- execScripts方法: 为应用生成一个window的代理对象,作为参数传入,以保证不影响全局window; 在ie11通过快照方式实现隔离
//正常实现js隔离
(function(window, arguments){
// do something
})(window)
// execScripts
(function(window, arguments){
// do something
})(window.proxy)
4、应用通信
// 基座中
import { initGlobalState, MicroAppStateActions } from 'qiankun';
const initialState = {};
const actions: MicroAppStateActions = initGlobalState(initialState);
export default actions;
// 子应用中监听
actions.onGlobalStateChange (globalState, oldGlobalState) {
...
}
// 子应用中修改
actions.setGlobalState(...);
webpack5 模块联邦 VS single-spa
模块联邦: webpack 受打包工具 和 生态的限制,
single-spa: 已经有一些成熟的解决方案:qiankun & 京东的MicroApp
京东出品微前端框架MicroApp介绍与落地实践
https://mp.weixin.qq.com/s/6A6TqQpWgN1_KoxUMx3FFw
QA:
如何在应用程序间共享状态
1、建议尽量避免应用共享状态,如果出现,可以优先考虑重新划分应用的边界
2、实现方案:
- 创建可以缓存请求及其响应的共享API请求库。如果同一个API被多个应用重复命中,则使用缓存数据。
- 将共享状态公开为导出,其他的库可以导入它。可观测值(如:RxJS) 在这里很有用,因为他们能够将新值流式传输给订阅服务器。
- 使用custom browser events来交流。
- 使用cookies, local/session storage或其他能够存取状态的工具。
参考文档:
https://single-spa.js.org/
https://single-spa.js.org/docs/examples/
https://github.com/systemjs/systemjs
SystemJS >=3 已实现IE11的polyfill 目前已到 6.10.1