前言
我们公司的业务主要是做crm以及围绕crm系统的上下游的一些其他服务,有两个特点:生命周期长、代码量庞大。这带来了技术栈落后,编译部署慢,项目难管理三个大问题。虽然我们也在做了一些努力,但始终没有解决真正的痛点。为此,微前端拆分势在必行。
于是我做了一些微前端的技术调研,iframe,多包策略都不做考虑,主要是在乾坤,single-spa,模块联邦这三个框架之间做考虑,接下来介绍一下具体的探索过程
微前端概念
在接到微前端改造项目之前,对于微前端的概念只是一个大概的了解,只是耳闻过乾坤大法。在真正的去实践之前,有必要对微前端做个调研,毕竟不能一味照搬,看别人用啥就用啥,还是需要结合项目本身的特点来选用最终的技术类型。
什么是微前端?
从已有认知来看,我觉得只要需要满足以下几点都可以称作微前端
- 技术栈无关,彼此项目间不应有代码耦合
- 能够做到独立部署,部署互不干扰,项目之间互不干扰
- 能够方便各项目的管理,运营
single-spa 官方解释
-
微前端是指存在于浏览器中的微服务,是浏览器内的javascript模块
-
微前端作为用户界面的一部分,通常由许多组件组成,并使用类似于React、Vue和Angular等框架来渲染组件。每个微前端可以由不同的团队进行管理,并可以自主选择框架。
-
每个微前端都拥有独立的git仓库、package.json和构建工具配置。因此,每个微前端都拥有独立的构建进程和独立的部署/CI。这通常意味着,每个仓库能快速构建。
三种微前端的介绍以及各自的实现原理
single-spa
在single-spa眼中,微前端就是浏览器内的javascript模块。不管是应用,组件,还是一些公用的逻辑都可以作为模块来使用,因此它可以分为三种不同的形式来使用
-
single-spa applications
为一组特定路由渲染组件的微前端。最常用到的类型,适用于应用级别的拆分,根据不同的activity规则动态加载各自的应用资源
-
single-spa parcels
不受路由控制,渲染组件的微前端。single-spa 的 parcel,指的是一个与框架无关的组件,由一系列功能构成,可以大到一个应用,也可以小至一个组件,可以用任何语言实现,只要能导出正确的生命周期事件即可。可以被应用手动挂载,无需担心由哪种框架实现。Parcels 和注册应用的api一致,不同之处在于parcel组件需要手动挂载,而不是通过activity方法被激活
-
utility modules
非渲染组件,用于暴露共享javascript逻辑的微前端。公共模块是共享通用逻辑的好地方。 您可以用一个普通的JavaScript对象 (single-spa 公共模块)共享一段逻辑,代替在每个应用程序中都创建自己的通用逻辑实现
不管是哪种形式的single-spa,实现原理都是一样的。首先要在主应用中为子应用注册,在子应用中声明bootstrap,mount,unmout生命周期函数,在主应用中通过start启动
如图所示,是single-spa为主应用注册子应用的一段简单代码,主要是通过registerApplication函数。
1、registerApplication参数说明
第一个参数是应用名称,第二个参数是子应用的加载函数,第三个必须是一个纯函数,作为激活子应用的规则,第四个参数是一些自定义的props 在子应用的生命周期及子应用中使用
2、内部实现
export function registerApplication(
appNameOrConfig,
appOrLoadApp,
activeWhen,
customProps
) {
const registration = sanitizeArguments(
appNameOrConfig,
appOrLoadApp,
activeWhen,
customProps
);
if (getAppNames().indexOf(registration.name) !== -1)
throw Error(
formatErrorMessage(
21,
__DEV__ &&
`There is already an app registered with name ${registration.name}`,
registration.name
)
);
apps.push(
assign(
{
loadErrorTime: null,
status: NOT_LOADED,
parcels: {},
devtools: {
overlays: {
options: {},
selectors: [],
},
},
},
registration
)
);
if (isInBrowser) {
ensureJQuerySupport();
reroute();
}
}
复制代码
registerApplication函数中首先通过sanitizeArguments函数对传入的参数进行校验,检验是否规范化。对于某些参数进行规范化处理后再返回。如:当activeWhen写成函数时就直接返回,如果是一个字符串就帮你处理为函数再返回。
随后校验注册的应用是否有重名。之后将应用信息推进apps树组进行缓存,缓存信息中包含当前应用的加载方式,加载状态等等。
最后执行reroute方法,通过reroute和路由控制不断地在调度子应用,加载子应用的代码,切换子应用,改变子应用的app.status。
reroute方法做到了调度子应用,负责改变app.status,执行在子应用中注册的生命周期函数,这也是实现微前端中最重要的一个环节
export function reroute(pendingPromises = [], eventArguments) {
//appChangeUnderway默认是置为false。
//所以在registerApplication方法使用的时候,是不会出发的if的逻辑
//在start之后就会被置为true。意味着在start重新调用reroute的时候
//就会进入这段if逻辑
if (appChangeUnderway) {
return new Promise((resolve, reject) => {
peopleWaitingOnAppChange.push({
resolve,
reject,
eventArguments,
});
});
}
const {
appsToUnload,
appsToUnmount,
appsToLoad,
appsToMount,
} = getAppChanges();
let appsThatChanged,
navigationIsCanceled = false,
//oldUrl在文件开头获取
oldUrl = currentUrl,
//新的url在本文件中获取
newUrl = (currentUrl = window.location.href);
//isStarted判断是否执行start方法,start方法开头把started置为true,就会走入这个分支
if (isStarted()) {
appChangeUnderway = true;
appsThatChanged = appsToUnload.concat(
appsToLoad,
appsToUnmount,
appsToMount
);
return performAppChanges();
} else {
appsThatChanged = appsToLoad;
return loadApps();
}
function cancelNavigation() {
navigationIsCanceled = true;
}
function loadApps() {
...
}
function performAppChanges() {
...
}
function finishUpAndReturn() {
...
}
}
export function getAppChanges() { //将应用分为4类 //需要被移除的
const appsToUnload = [], //需要被卸载的
appsToUnmount = [], //需要被加载的
appsToLoad = [], //需要被挂载的
appsToMount = [];
// We re-attempt to download applications in LOAD_ERROR after a timeout of 200 milliseconds
const currentTime = new Date().getTime();
//apps是我们在registerApplication方法中注册的子应用的信息的json对象会被缓存在apps数组中,apps装有我们子应用的配置信息
apps.forEach((app) => {
//shouldBeActive这里就是真正执行activeWhen中定义的函数如果根据当前的location.href匹配路径成功的话,就说明此时
//应该激活这个应用
const appShouldBeActive =
app.status !== SKIP_BECAUSE_BROKEN && shouldBeActive(app);
//我们在执行registerApplication前面的时候把app.status设置为了NOT_LOADED,看看下面的swtich,如果在上面的匹配成功的话就把app推入appsLoad数组中,表明这个子应用即将被加载。
switch (app.status) {
case LOAD_ERROR:
if (appShouldBeActive && currentTime - app.loadErrorTime >= 200) {
appsToLoad.push(app);
}
break;
//最开始注册完之后的app状态就是NOT_LOADED
case NOT_LOADED:
case LOADING_SOURCE_CODE:
//如果app需要激活的话就推入数组
if (appShouldBeActive) {
appsToLoad.push(app);
}
break;
case NOT_BOOTSTRAPPED:
case NOT_MOUNTED:
if (!appShouldBeActive && getAppUnloadInfo(toName(app))) {
appsToUnload.push(app);
} else if (appShouldBeActive) {
appsToMount.push(app);
}
break;
case MOUNTED:
if (!appShouldBeActive) {
appsToUnmount.push(app);
}
break;
// all other statuses are ignored
}
});
return { appsToUnload, appsToUnmount, appsToLoad, appsToMount };
}
复制代码
当子应用第一次挂载的时候,会执行 bootstrap 做一些初始化,然后执行 mount 将它挂载。如果你是一个 React 技术栈的子应用,你可能就在 mount 里面写ReactDOM.render ,把你的 ReactNode 挂载到真实的节点上,把应用渲染出来。当你应用切换走的时候,会执行 unmount 把应用卸载掉,当它再次回来的时候是不需要重新执行一次所有的生命周期钩子的,会直接从 mount 阶段继续,这就也做到了应用的缓存。
通过分析siangle-spa的实现,你可能会有这几个问题
-
主应用是如何加载子应用
即registerApplication 的第二个参数,返回一个promise或者一个应用。singlespa 没有约束,可以通过systemjs 动态模块加载器,也可以自定义
-
不同框架的子应用怎样实现生命周期的
通过不同的中间价 如single-spa-react
-
主应用是如何调度子应用的
single-spa 通过reroute方法
复制代码
-
应用之间的隔离怎么解决
singlespa 没有做过多处理,当应用之间发生了冲突程序可能就跑不下去了
qiankun
基于single-spa和import-html-entry两个库,single-spa帮住qiankun如何调度子应用,import-html-entry提供了一种window.fetch方案去加载子应用的代码
部分源码
// 封装了single-spa的registerApplication
function registerMicroApps() {
unregisteredApps.forEach((app) => {
// 调用single-spa的registerApplication
registerApplication({
name,
app: async () => {
loader(true);
await frameworkStartedDefer.promise;
const { mount, ...otherMicroAppConfigs } = (
// 核心部分,隔离的加载子应用
await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles)
)();
return {
mount: [async () => loader(true), ...toArray(mount), async () => loader(false),
...otherMicroAppConfigs,
};
},
activeWhen: activeRule,
customProps: props,
});
});
}
function start(opts = {}) {
frameworkConfiguration = { prefetch: true, singular: true, sandbox: true, ...opts };
const { prefetch, sandbox, singular, urlRerouteOnly, ...importEntryOpts } = frameworkConfiguration;
if (prefetch) {
doPrefetchStrategy(microApps, prefetch, importEntryOpts);
}
if (sandbox) {
if (!window.Proxy) {
}
}
// startSingleSpa是single-spa的start方法的别名
startSingleSpa({ urlRerouteOnly });
frameworkStartedDefer.resolve();
}
复制代码
乾坤的特点
-
改变了子应用的加载方式
带来的好处
1、加载子应用更方便,在singlespa中 加载子应用,需要加载动态生成的js文件。如果有多个chunk需要按照顺序依次加载,有点麻烦。而这些信息在子应用的index.html 中已经具备,加载html等同于加载了对应的所有资源。
2、主子应用无约定关系,如果加载的是js文件 ,那么子应用需要挂载主应用中,根节点有一定的耦合约定关系。如果加载的是html 可以把子应用和主应用更加的解耦了
-
解决了应用隔离 –js沙箱
实现思路 – 快照沙箱
快照沙箱就是在应用沙箱挂载和卸载的时候记录快照,在应用切换的时候依据快照恢复环境。
举个例子,我们在 A 应用运行时,声明了一个全局变量,window.a = 1 ,在应用 A 卸载之后,快照还原, window.a 会被重新删除,你在全局环境中并不会继续看到 a 变量。
怎么打快照
一种是直接用 windows diff。把当前的环境和原来的环境做一个比较,跑两个循环,把两个环境作一次比较,然后去全量地恢复回原来的环境。
另一种思路其实是借助 ES6 的 proxy 就是代理。通过劫持 window ,我们可以劫持到子应用对全局环境的一些修改。当子应用往 window 上挂东西、修改东西和删除东西的时候,我们可以把这个操作记录下来。当我们恢复回外面的全局环境的时候,我们只需要反向执行之前的操作即可。比如我们在沙箱内部设了一个新的变量 window.a = 1 。那在离开的时候,我们只需要把 a 变量删了即可。
快照沙箱这个思路也正是 qiankun1.0 所使用的思路,它相对完善,但是缺点在于无法支持多个实例,也就是说我两个沙箱不可能同时激活,我不能同时挂载两个应用,不然的话这两个沙箱之间会打架,导致错误。
样式隔壁
除了js沙箱,样式隔壁也是必不可少的部分。不过这部分更多的还是需要开发者遵循一些规范。如
1、UI库统一加前缀
2、尽量避免样式泄露等等
3、Css in js 等等
模块联邦
模块联邦基于webpack5.0
webpack构建的应用可以是remote–即服务的提供方,也可以是host–即服务的消费方,也可以同时扮演服务提供者和服务消费者。如果是提供方,在webpack配置中向外暴露提供的模块,如果是消费者需要在webpack中配置远程提供方信息。简单demo配置如图
3、项目改造背景与技术选型
背景
- 多个部门,多个产品都基于一套系统开发,开发维护困难,迭代困难,牵一发,动全身
- 巨石项目,打包越来越慢,代码维护困难,已经到了非拆不可的境地
公司目标是想做到和阿里云产品类似,切换不同产品得到对应的产品。提出两个要求
- 快速实现
- 做到应用级别分离
基于领导的考虑,最终还是选择了乾坤大法。主要原因如下
- Singlespa 更适合自研一套微前端框架的公司,我们人员有限,时间又很紧急,选择single-spa显的有点不足
- 模块联邦 对webpack 版本有要求,个别项目技术栈太老,需要升级成本
乾坤踩坑记录
- 正常情况下,乾坤手册中的常见错误能够帮你解决掉正在遇到的问题
如图所示,在主应用注册子应用时,子应用的容器都是在sub-container中,出现此类问题,首先查看乾坤手册会指导你查看生命周期是否存在等,如果经检查配置的都没有问题,需要检查子应用中是否包含移除html dom元素的代码,document.write。 因为种种原因,子应用html中有document.write这么一行代码,导致找不到子应用的容器。
- webpack5.0 子应用加载失败,参考这条issues github.com/umijs/qiank…
- 资源问题, 配置__webpack_public_path__ = window.INJECTED_PUBLIC_PATH_BY_QIANKUN
- 子应用切换主应用再切回子应用 子应用路由跳转不对,不清楚具体原因,处理的比较粗暴,在每次由主应用切回到子应用的根组件时,主动跳转一次当前路由
- 主子应用实时通信问题,了借助地址栏和浏览器作为载体外,乾坤官方的发布订阅模式能够做到实时通信,但是面对复杂的消息体和多个子应用时,都去注册观察者函数显得有点错乱。最终同时使用发布订阅模式来作为接收实时消息的一个前置信号,当子应用接收到这个信号之后,通过shared方法来主动触发获取真实的消息体
- 样式隔离问题,这个需要开发人员多做规范约束
- 由于我们主子应用都是react,像react这种大框架还是应该抽成公用的部分来尽量减少子应用的体积的
后记
以上就是我这次探究微前端的一个大概过程了,在经过咔咔一顿神操作后,我们的项目已经按照乾坤大法进行了拆分,并已稳定上线。谈下具体感受吧。
乾坤确实能够很快的帮助你进行微前端拆分,不过只适用应用级别的拆分,没有single-spa,模块联邦强大。适合中小企业的快速实现。同时乾坤的文档也并没有那么全面,很多时候找不到错误的具体原因。
就现在来说乾坤能够解决我们现有的一些大痛点,随着时间的积累,最终应用级别的拆分可能也是满足不了项目的需求。
最好的方案就是企业自己针对不一样的痛点来开发一套适用自己的框架。