logo
登录 / 注册

微前端single-spa: 从应用到源码解析,看这一篇就够了!

头像
好未来技术
22-03-17 · 技术

导读: 本文从single-spa的应用、源码入手,探究微前端single-spa实现原理,帮助读者更好的了解single-spa的工作流程从而更好的应用到现实项目中去。在阅读之前,建议先看一下single-spa源码,带着问题再来看, 这样才能更好的加深对微前端理解。

01

背景

基础服务中台的统一运维平台是一个包含资源组织、监控配置与结果查看、跳板机关联等内容的一站式解决方案平台。资源组织是用服务树节点的方式来管理我们的资源与权限操作,同时提供了容量管理等多样的监控手段。从前端技术上来讲,我们需要把服务树节点的数据传递到监控、跳板机、容量管理等功能模块。同时随着前端技术栈的演进,我们也在逐步从Vue2技术栈演进到Vue3。

根据以上背景,我们需要解决的问题:

1. 共享登录状态,共享服务树的节点数据;

2. 与技术栈无关;

3. 各模块独立维护,独立部署;

4. 从用户体验角度考虑,我们需要用户切换各模块时与单应用切换路由感觉一样。

基于以上要求,我们选择了qiankun微前端解决方案。qiankun是基于single-spa开发的,要想吃透qiankun必须先了解single-spa的运行机制和原理实现,在这里跟大家分享一下我对single-spa实现原理的一些探究跟见解。

通过这篇文章你能找到以下问题答案:

1. qiankun和single-spa是什么关系;

2. single-spa如何获取子应用;

3. single-spa是如何访问子应用的生命周期函数,同时对于生命周期的调度时机是怎么样的;

4. single-spa是如何控制路由;

5. 子应用在主应用中如何进行挂载跟卸载。


02

什么是single-spa

single-spa 是一个将多个单页面应用聚合为一个整体应用的JavaScript微前端框架。从现代框架组件生命周期中获得灵感,将生命周期应用于整个应用程序。
single-spa 进行前端架构设计可以带来很多好处,例如:在同一页面上使用多个前端框架而不用刷新页面 (React, AngularJS, Angular, Ember, 你正在使用的框架),独立部署每一个单页面应用,新功能使用新框架,旧的单页应用不用重写可以共存,改善初始加载时间,延迟加载代码。
可参考:
http://zh-hans.single-spa.js.org/docs/getting-started-overview
接下来我从single-spa的应用着手,逐步介绍single-spa的原理与源码。

03

single-spa在项目中的应用

single-spa微前端分主应用和子应用,主应用主要是负责整体布局、注册子应用以及路由管理,子应用就是各个模块的单页应用。
主应用实现:
挂载节点配置,当我们加载子应用时,让子应用挂载在id为capacity这里。
主应用的主要工作就是拦截路由,加载子应用。我们调用single-spa提供给我们的registerApplication和start的方法,其中registerApplication参数有四个:appNameOrConfig、appOrLoadApp、activeWhen和customProps,分别对应的是注册子项目名和一些配置,下面会详细介绍。
子应用实现:
配置子项目的打包方式


子应用注意点:
暴露single-spa需要的bootstrap、mount、unmount方法,single-spa是协议接入,这几个方法都是必须要提供的,而且必须是异步的;
子应用的文件打包为window或UMD格式,并将生命周期对象挂载到window上,以便父应用使用。
stats-webpack-plugin插件能够生成资源清单文件,这个清单文件让主应用根据Webpack配置的入口名称找到资源文件路径来加载子应用资源。

整体流程方案:

04

源码解析

上面的原理流程图帮助我们对single-spa的流程有个大致的了解,所以在这里先对流程图做一个简单的介绍。

当启动主应用的时候会调用registerApplication方法和start方法,两个方法内部都调用了reroute函数,其中registerApplication注册子应用,并将包装后的子应用存入app数组;start方法执行时会监听url路由变化来调用reroute方法,此时浏览器导航操作的hasChange或popState事件回调函数将收集到captureEventLister对象中待reroute后遍历执行。
reroute方法内部调用getAppChanges,该方法会遍历app应用数组判断生命周期,根据shouldBeActive方法location匹配的app激活规则判断子应用是已激活,返回不同状态的应用。然后reroute方法根据started变量的状态走了两个分支,如果started是未开启状态会调用loadApp函数执行app.loadApp来实际加载子应用。再调用callAllEventListeners遍历执行路由收集的函数,因为没有mounted所以reroute方法返回[]。
如果started是开启状态则调用performAppChanges方法先卸载需要卸载的应用,再执行appsToLoad、appsToMount加载启动挂载应用,期间子应用的生命周期函数会挂载到app配置对象的属性上,在指定的情况下执行。因为start方法内部简单调用了reroute方法,通过阅读源码我们发现可以通过registerApplication和reroute作为切入点来探究源码。
registerApplication()注册应用:
通过以上代码我们发现 registerApplication方法来注册子应用,每个应用程序都注册了四件东西:
1. appNameOrConfig // 表示子应用的名称
2. appOrLoadApp // 为加载子应用的方法
3. activeWhen // 确定应用程序何时处于活动状态/非活动状态
4. customProps // 表示主应用传给子应用的对象数据
接着继续执行registerApplication函数。
这段比较好理解,就把这个registration和一个对象进行合并,推入一个apps的数组里面,对子应用的信息进行缓存。
接下来进入最重要的环节,继续看回registerApplication代码,最后执行了一个叫做reroute的方法。

single-spa的核心 reroute():

reroute返回一个promise子应用数组,数组中的元素都是异步的,reroute在整个single-spa就是负责改变app.status和执行在子应用中注册的生命周期函数。
上面代码是根据Started判断是否执行start方法,就会走入两个不同的分支。

1.start未开启时调用loadApp

我们接着来看看getAppChanges方法。上面通过调用getAppChanges解构出了几个变量,函数的源码如下:
getAppChanges在遍历我们apps数组的时候,留意这段代码
const appShouldBeActive = app.status !== SKIP_BECAUSE_BROKEN && shouldBeActive(app);
这句话的作用就是根据我们当前的url进行判断需要激活哪一个子应用,这里涉及到了我们的activeWhen参数选项,我们先回顾下这个选项有什么作用,这个参数作用就是用来激活应用的。
shouldBeActive方法:
现在就可以理解为什么registerApplication的参数activeWhen方法这么写:
activeWhen: () => location.pathname.startsWith('/vue')

接下来看看loadApps函数,它的源码如下:
这里注册了一个微任务,注意是微任务,说明并不会马上执行then之后的逻辑。appsToLoad是通过activeWhen规则分析当前用户所在url,得到需要加载的子应用的数组。通过map对需要激活的子应用进行遍历。
toLoadPromise的作用比较重要,是我执行我们调用registerApplication方法参数中的加载函数选项执行的地方。
toLoadPromise()源码如下:
该方法也是注册一个微任务,也不是同步执行的。app.loadApp(getProps(app)),这里开始执行loadApp,可以回头看看loadApp是什么东西,loadApp是我们传入registerApplication的加载函数,这里就是真正执行我们的加载函数的地方。我们的加载函数可能是这么写的:
说明这里就是把我们应用的script标签注入到html上。
作用:
1. 需要对子应用的代码进行加载,加载的写法不限。你可以通过插入<script>标签引用你的子应用代码,或者通过window.fetch去请求子应用的文件资源。
2. 需要在加载函数中返回子应用导出的生命周期函数提供给主应用。

从自定义的加载函数可以看出为什么single-spa支持不同的前端框架。例如vue,react接入,原因在于我们的前端框架最终打包都会变成app.js、vendor-chunk.js等js文件,变回原生的操作。我们从微前端的主应用去引入这些js文件去渲染出我们的子应用,本质上最终都是转为原生dom操作,所以说无论你的子应用用框架东西写的,其实都一样。所以加载函数就是single-spa对应子应用资源引入的入口地方。


2.子应用生命周期函数获取

那么从哪里看出需要返回子应用的生命周期函数?我们回过头来看LoadPromise的加载代码。
看看appOpts下面函数,可以看到传入的参数有bootstrap、mount、unmount、unload等等的生命周期关键词。
大概就能够知道,他在校验appOpts即val里是否有这些生命周期函数。说明single-spa要求我们在加载函数中需要return出子应用的生命周期函数。validLifecycleFn(appOpts.bootstrap)直接通过appOts.bootstrap去获取,说明我们子应用的所有的生命周期需要用对象存放起来。但是这里有一个问题,主应用和子应用环境是有区别的,那么我们怎么通过主应用去获取到我们子应用的生命周期函数呢?
那就是他们有一个共同的地方,就是window对象,在加载函数return之前我们已经通过手段在主应用中加载到了我们子应用的代码,window对象是共有的环境,在子应用vue的入口文件main.js去这样定义我们的生命周期函数。
然后在主应用的加载函数中,从window对象中去获取子应用生命周期函数。
//主应用registerApplication方法中的加载参数app: async () => {  ..加载js代码   return window['singleCapacity'];},
这样的写法使子应用和主应用的window环境混在了一起,造成了互相全局环境的污染。这个部分是自定义部分,只要你有办法能够拿到都是可以的。无论你怎么写,只需要注意一个条件,你拿到的生命周期必须用对象存放起来。
我们接下来解决子应用生命周期的调用问题。

3.start开启时执行performAppChanges调用生命周期函数。

在上面registerApplication函数中,我们加载了子应用的js代码,获得了应用的生命周期函数。但是在这个时候并没有真正地调用它们,那么他们的调用时机在哪里?
调用的时机在执行start函数的时候。接下来看看start函数(如下),其本质上最核心的还是调用reroute函数。
但是在start中调用reroute函数和在registerApplication中调用是有区别的。

在start走入的分支会执行一个叫做performAppChanges的方法
disptach自定义事件用于在mounted之前,可以做自己想做的事。appsToUnmount.map(toUnloadPromise)先卸载需要卸载的应用(根据状态是否销毁子应用,更新各种状态),appsToLoad.map(toLoadPromise)、appsToMount.map(toMountpromise)两个函数用来加载和再挂载新的应用。appsToLoad缓存了在reroute开头通过getAppChange方法中根据activeWhen的规则匹配到需要加载的子应用(app就是我们需要加载子应用的配置信息的json数组),然后遍历执行了toLoadPromise,我们在上面分析过,它会执行app.loadApp,就是registerApplication参数的加载函数。然后得到的生命周期函数会挂载到app配置对象的属性上。在执行完了toLoadPromise后执行tryToBootstrapAndMount函数,它的源码如下:
这里继续调用shouldBeActive根据匹配规则检查url,判断是否需要执行该子应用的生命周期函数。如果确认了我们要渲染这个子应用那么就调用toBootstrapPromise函数,它的源码如下:
resonableTime源码
resonableTime() 是真正调用子应用生命周期钩子函数。 第一个参数是子应用的配置对象,第二个参数就是你需要调用的生命周期的钩子函数的名称。
应用的生命周期如图:

05

路由控制

single-spa对应的路由处理的代码在src/navigation/navigation-events.js。
在文件最底部有这么一段执行逻辑:
增加了hashchange、popstate两个监听,监听url的变化。如果你用的hash模式改变#后面的值或者在浏览器中后退,那么就重新执行reroute。监听了hashchange和popstate的变化,但是这两个api无法监听用户直接调用pushState方法进行url调转。
patchedUpdateState调用的时候传入的两个参数window.history.pushState, pushState。
假如原来的url和新的url是不同的,或者urlRerouteOnly为false的话那么都会执行if里面的createPopStateEvent方法。
createPopStateEvent自定义了一个popstate事件,并且把它返回,那么在上层的window.dispatch去触发这个事件的时候,本质上就是触发popstate事件,那么我们在上面源码的开头就已经监听了popstate事件,监听到了的回调函数就是执行reroute,所以说pushState执行reroute的手段本质上就是通过触发popstate事件,从而触发reroute。

以上代码实际做的事情很简单,总体分为以下几步:
1. 重写pushState以及replaceState方法,在方法中调用原有方法后执行如何处理子应用的逻辑监听hashchange及popstate事件,事件触发后执行如何处理子应用的逻辑。
2. 重写监听或移除事件函数,如果应用监听了hashchange及popstate事件就将回调函数保存起来以备后用。

06

子应用包装

子应用接入single-spa时,我们需在在mount方法中添加挂载逻辑,在 unmount方法中添加卸载逻辑,在update方法中添加更新逻辑。single-spa为不同的技术栈提供了一些逻辑抽象封装来对子应用进行包装。例如:
  • single-spa-react
  • single-spa-vue
  • single-spa-angular
  • single-spa-angularjs

我们就以single-spa-vue为例,single-spa-vue是一个针对vue项目的初始化、挂载、卸载的库函数,可以实现single-spa注册的应用、生命周期函数等功能。
首先看一下在子应用中如何使用:
调用singleSpaVue方法对子应用进行包装,然后在对应的生命周期钩子函数执行子应用挂载卸载更新等操作。
以mount方法为例,子应用在导出的mouned 方法里调用了vueLifecycles.mount() 如下:
export function mount (props) {  return vueLifecycles.mount(props)}
single-spa-vue源码如下:
通过阅读源码得知vueLifecycles.mount()方法主要是做了两件事:
1. 是否指定了挂载节点,如果没有就创建默认挂载节点。
2. 将子应用的vue对象初始化并挂载到指定的节点上。
同理vueLifecycles.unmount方法主要用于当前子应用调用$destroy方法来卸载子应用并清空dom节点。由此得知single-spa子项目的的挂载、更新、卸载等操作,并不是single-spa原生提供的,用户可以根据自己的需要来自行实现子应用的挂载、卸载及更新等逻辑。通过single-spa包装的主应用是一个基座,它提供相应的协议,子应用按照协议进行包装就可以接入主应用。主应用就像插座,而子应用就像不同的电器,只要遵循某种协议就可以轻松实现可插拔操作。

07

结语

single-spa通过reroute和路由控制来调用子应用。但是在single-spa的开发过程中,我们需要自己手动去写调用子应用的方法,而qiankun不需要,qiankun只需要你传入响应的apps的配置即可,它会帮助我们去加载。还有JS隔离问题,CSS样式隔离问题,为了解决single-spa这些问题,阿里基于single-spa研发了qiankun微前端框架,真正实现了微前端的所有特性。下篇文章我们会介绍qiankun在未来云平台的应用,敬请期待。


微前端single-spa: 从应用到源码解析,看这一篇就够了!脉脉
阅读 26
声明:本文内容由脉脉用户自发贡献,部分内容可能整编自互联网,版权归原作者所有,脉脉不拥有其著作权,亦不承担相应法律责任。如果您发现有涉嫌抄袭的内容,请发邮件至maimai@taou.com,一经查实,将立刻删除涉嫌侵权内容。
相关推荐
最新发布
大家都在看
热门人脉圈
    头像
    我来说几句...