微前端架构之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/

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 全局安装
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填充这些,如果你需要更好的浏览器支持)的设置。这种设置有几个优点:

    1. 公共模块易于管理,并且只下载一次。如果使用SystemJS,也可以预加载它们来提高速度。
    1. 共享代码/函数/变量就像导入/导出一样简单,就像在一个整体中设置一样。
    1. 延迟加载应用程序很容易,这使您能够加速初始加载时间。
    1. 每个应用程序(又名微服务,又名ES模块)都可以独立开发和部署。团队可以按照自己的进度工作、实验(在组织定义的合理范围内)、QA和部署。这通常也意味着发布周期可以缩短到几天,而不是几周或几个月。
    1. 很棒的开发人员体验(DX):转到dev环境并添加一个导入映射,该映射将应用程序的url指向您的本地主机。请参阅下面的章节了解详细信息。

single-spa中微前端的类型

    1. single-spa applications:为一组特定路由渲染组件的微前端。
    1. single-spa parcels: 不受路由控制,渲染组件的微前端。
    1. utility modules: 非渲染组件,用于暴露共享javascript逻辑的微前端。
容器Root 应用程序 沙箱 公共模块
主路由 有多个路由 无路由 无路由
API 声明API 必要的API 没有single-spa API
渲染UI 渲染UI 渲染UI 不直接渲染UI
生命周期 single-spa管理生命周期 自定义管理生命周期 没有生命周期
什么时候用 核心构建模块 仅在多个框架中需要 共享通用逻辑时使用

应用程序

single-spa 提供 registerApplication API注册应用

沙箱

主要是让您在多个框架中编写应用程序时可以在应用程序之间重用UI。
管理parcels的生命周期
mountParcelmountRootParcel 将立即挂载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】
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 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类型的 加载函数,也可以是一个已经被解析的应用。

    1
    2
    3
    4
    5
    6
    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函数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    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被调用之前,应用先被下载,但不会初始化/挂载/卸载。

    1
    2
    3
    4
    import { start } from 'single-spa';
    /*在注册应用之前调用start意味着single-spa可以立即安装应用,无需等待单页应用的任何初始设置。*/
    start();
    // 注册应用。。。。
  • 同时注册两个路由
    一个path的变动,同时两个应用被激活?? 可以。

需要一个id,这个id的以single-spa-application前缀开头,后面接着你的应用的名字。比如,如果你的应用名字叫做app-name,就创建一个id为 single-spa-application:app-name的div。
1
2
3
4

<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、动态加载模块
    优点:灵活,代码独立
    不足:搭建难度稍大
    实现:
    1. web服务器,创建动态脚本加载子应用正确版本;
    2. 使用模块加载,如: 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 方法被动激活。在不熟悉它之前,尽量不要用。

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 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的入口协议规范,它就是可加载的应用。single-spa的规范要求应用入口必须暴露出以下三个生命周期钩子函数,且必须返回Promise,以保证single-spa可以注册回调函数:
single-spa应用入口

应用加载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<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执行,并导出供其他模块调用
import-html

2、css,js隔离

  • 通过importHtml 加载html并把外部样式转为内部样式(使用类个shandow dom 或 vue scope)方式, 实现样式隔离
  • execScripts方法: 为应用生成一个window的代理对象,作为参数传入,以保证不影响全局window; 在ie11通过快照方式实现隔离
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    //正常实现js隔离
    (function(window, arguments){
    // do something
    })(window)

    // execScripts
    (function(window, arguments){
    // do something
    })(window.proxy)

4、应用通信
应用通信

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 基座中
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
single-spa

京东出品微前端框架MicroApp介绍与落地实践
https://mp.weixin.qq.com/s/6A6TqQpWgN1_KoxUMx3FFw
QA:

如何在应用程序间共享状态

1、建议尽量避免应用共享状态,如果出现,可以优先考虑重新划分应用的边界
2、实现方案:

  1. 创建可以缓存请求及其响应的共享API请求库。如果同一个API被多个应用重复命中,则使用缓存数据。
  2. 将共享状态公开为导出,其他的库可以导入它。可观测值(如:RxJS) 在这里很有用,因为他们能够将新值流式传输给订阅服务器。
  3. 使用custom browser events来交流。
  4. 使用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

文章作者: YolkPie
文章链接: https://yolkpie.net/2021/06/21/%E5%BE%AE%E5%89%8D%E7%AB%AF%E5%AE%9E%E8%B7%B5%E4%BA%8C/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 YolkPie