1.1 概念
微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。
微前端借鉴了微服务的架构理念,将一个庞大的前端应用拆分为多个独立灵活的小型应用,每个应用都可以独立开发、独立运行、独立部署,再将这些小型应用联合为一个完整的应用。微前端既可以将多个项目融合为一,又可以减少项目之间的耦合,提升项目扩展性,相比一整块的前端仓库,微前端架构下的前端仓库倾向于更小更灵活。
1.2 特点
技术栈无关 主框架不限制接入应用的技术栈,子应用可自主选择技术栈
独立开发/部署 各个团队之间仓库独立,单独部署,互不依赖
增量升级 当一个应用庞大之后,技术升级或重构相当麻烦,而微应用具备渐进式升级的特性
独立运行时 微应用之间运行时互不依赖,有独立的状态管理
提升效率 应用越庞大,越难以维护,协作效率越低下。微应用可以很好拆分,提升效率
单⼀职责 每个⼦项⽬只做和⾃⼰相关的业务⼯作,各⼦项⽬之间不存在依赖关系,保持隔离
耦合性更低 更简单的代码库的代码库,微前端的架构让开发者们可以更容易编写和维护更⼩、更简单、更容易开发的项⽬
1.3 微前端的问题
CSS样式隔离:不同项目,相同的样式名在页面中是会相互污染的
js隔离:不同项目,在向window挂在变量时,有可能会互相污染
公共依赖:对于各个子项目,或多或少都会存在公共依赖,无论是从开发效率还是从页面性能都是微前端需要解决的点
路由状态:子应用的路由改变需要同步到主应用上
预加载:利用用户浏览空闲时间,提前加载其他项目的JS文件,提高用户体验
通信方式:子应用之间互相通信也是必不可少的,要尽量解耦不要互相调用。
2 目前可用的微前端方案
2.1 基于 iframe 完全隔离的方案
优点:
非常简单,无需任何改造
完美隔离,JS、CSS 都是独立的运行环境
不限制使用,页面上可以放多个
缺点:
无法保持路由状态,刷新后路由状态就丢失
完全的隔离导致与子应用的交只能采用postMessage方式。
iframe 中的弹窗无法突破其本身
整个应用全量资源加载,加载太慢
2.2 基于 single-spa 路由劫持方案
single-spa 是社区公认的主流方案,可以基于它做二次开发。
通过劫持路由的方式来做子应用之间的切换,但接入方式需要融合自身的路由,有一定的局限性。
案例 阿里 qiankun
qiankun 对 single-spa 做了一层封装。主要解决了 single-spa 的一些痛点和不足。通过 import-html-entry 包解析 HTML 获取资源路径,然后对资源进行解析、加载。
qiankun 可以用于任意 js 框架,微应用接入像嵌入一个 iframe 系统一样简单。qiankun@2.0 将跳出 route-based 的微前端场景。
优点
阿里团队开发维护,文档多。
基于single-spa 封装,提供了更加开箱即用的 API。
HTML Entry 接入方式,让你接入微应用像使用 iframe 一样简单。
样式隔离,确保微应用之间样式互相不干扰。
JS 沙箱,确保微应用之间 全局变量/事件 不冲突。
资源预加载,在浏览器空闲时间预加载未打开的微应用资源,加速微应用打开速度。
umi 插件,提供了@umijs/plugin-qiankun 供 umi 应用一键切换成微前端架构系统。
兼容IE11
缺点
上线部署文档较少
qiankun 只能解决子项目之间的样式相互污染,不能解决子项目的样式污染主项目的样式
2.3 京东 micro-app 方案
micro-app 是基于 webcomponent + qiankun sandbox 的微前端方案。
优点
使用 webcomponet 加载子应用相比 single-spa 这种注册监听方案更加优雅;
复用经过大量项目验证过 qiankun 的沙箱机制也使得框架更加可靠;
组件式的 api 更加符合使用习惯,支持子应用保活;
降低子应用改造的成本,提供静态资源预加载能力;
不足
接入成本较 qiankun 有所降低,但是路由依然存在依赖;
多应用激活后无法保持各子应用的路由状态,刷新后全部丢失;
css 沙箱依然无法绝对的隔离,js 沙箱做全局变量查找缓存,性能有所优化;
支持 vite 运行,但必须使用 plugin 改造子应用,且 js 代码没办法做沙箱隔离;
对于不支持 webcompnent 的浏览器没有做降级处理;
2.4 EMP 方案
EMP 方案是基于 webpack 5 module federation 的微前端方案。
特点
webpack 联邦编译可以保证所有子应用依赖解耦;
应用间去中心化的调用、共享模块;
模块远程 ts 支持;
不足
对 webpack 强依赖,老旧项目不友好;
没有有效的 css 沙箱和 js 沙箱,需要靠用户自觉;
子应用保活、多应用激活无法实现;
主、子应用的路由可能发生冲突;
2.5 无界微前端 方案
特点
接入简单只需要四五行代码
不需要针对vite额外处理
预加载
应用保活机制
不足
隔离js使用一个空的iframe进行隔离
子应用axios需要自行适配
iframe沙箱的src设置了主应用的host,初始化iframe的时候需要等待iframe的location.orign从’about:blank’初始化为主应用的host,这个采用的计时器去等待的不是很悠亚。
底层原理 使用shadowDom 隔离css,js使用空的iframe隔离,通讯使用的是proxy
3 CSS样式隔离
3.1、Shadow DOM
这项技术属于浏览器技术,属于Web components下的一个子项,它可以将一个隐藏的、独立的 DOM 附加到一个元素上,这项技术能很好的做到样式隔离,
这里呢,我说一下这项技术的一些问题:
1.1、浏览器支持很不友好,在国内的一些情况还是很难推行,当然像微前端这样的技术大多数用于B端,可以限制一下用户的浏览器。
1.2、对react比较熟悉的同学都是知道 react把事件都代理到了document上,这样会导致shadowDOM中的绑定的事件不会被触发,不过新版本的react已经做了相关的修改。
1.3、有一些UI库会把组件动态挂在到document上,如antd的Modal组件,这样会导致一个问题,我们在shadow DOM对Modal进行样式修改是不会生效的。
3.2、BEM规范、CSS Modules
这俩的原理基本一致,这里我就放在一起来说了
2.1、BEM全称:Block Element Module命名约束
B:Block 一个独立的模块,一个本身就有意义的独立实体 比如:header、menu、container
E:Element 元素,块的一部分但是自身没有独立的含义 比如:header title、container input
M:Modifier 修饰符,块或者元素的一些状态或者属性标志 比如:small、checked
2.2、CSS Modules:跟BEM的原理基本相同,也是用来生产一个单一的类名,不同的是这个类名由程序对类名做hash来达到单一类名的目的
小结一下——对于BEM如果老项目开始没有采用,后面做修改的成本很高,对于CSS Modules也有同样的问题,比如项目A和B用了相同一个UI库的不同版本,如果这俩版本的相同class相互不兼容,也会造成一些问题。
2.3、css in js
这个原理就是用JS写CSS,完全没有CSS也就不存在隔离不隔离的问题了,与2一样,对老项目不友好,也需要大量的修改
而且个人感觉这个方案有点不伦不类,业内争论也很多,不建议使用,说不定后面就被ban了
2.4、postcss
这个原理很简单,使用postcss为整体css添加一个外层的命名空间,这缺点是,要增加编译的时间,不过可以通过编译缓存处理。
小结
总结一下,总体来说,无论使用哪种,对老项目,都会有一些开发的工作量,其中postcss 相对比较简单一些,css in js业内存在争议,个人不建议使用,shadowDOM的技术比较新,也是个人比较推荐的方案。
4、JS隔离
由于es6引入了块级作用域,JS的隔离已经简单很多了,熟悉var和let区别的同学,应该知道var是有提升问题的,在讲js隔离前,首先要保证项目中不能有var,如果有祖传代码转微前端,首先要处理var的问题。
为什么要隔离?举一个简单的例子,比如不同的子项目之间都对window对象下相同变量进行了赋值,这就会造成一些不必要的问题,那如何处理呢?
目前主流方案有三个
4.1、记录变化做对主项目window做频繁修改
先说原理,这个方案主要来自于“阿里的qiankun的legacySandBox”
1.1、legacySandBox通过激活沙箱时还原子应用的状态,卸载时还原主应用的状态来实现沙箱隔离的,代码如下:
// 子应用沙箱激活
active() {
// 通过状态池,还原子应用上一次写在前的状态
if (!this.sandboxRunning) {
this.currentUpdatedPropsValueMap.forEach((v, p) => setWindowProp(p, v));
}
this.sandboxRunning = true;
}
// 子应用沙箱卸载
inactive() {
// 还原运行时期间修改的全局变量
this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => setWindowProp(p, v));
// 删除运行时期间新增的全局变量
this.addedPropsMapInSandbox.forEach((_, p) => setWindowProp(p, undefined, true));
this.sandboxRunning = false;
}
这个方案优点是兼容性高,因为没有用到什么浏览器的新特性,但问题也有两个
1.1、频繁的遍历很影响性能,如果window下挂在的过多会造成卡顿,影响用户体验
1.2、由于都是在修改主应用的window无法实现多个子应用实例同时渲染
4.2、基于Proxy的沙箱机制
相信各位同学对这个已经很熟悉了,这个是vue2和vue3的主要区别,八股文嘛,懂得都懂,这里不多说了,不太了解的同学请百度“vue3 proxy”,在实现JS沙箱上的原理上与vue3是一致的,主要是代理window对象
这个的方案来自于“阿里的qiankun和京东的microApp”,由于代码较多,这里我简单写个例子,原理就是使用Proxy代理一个空对象,在操作这个空对象的时候,判断当前window中是否有该属性来决定是操作这个空对象还是操作window,其中所有针对当前子应用的操作都会被缓存,同时该方法也支持多实例,具体实现可以看qiankun的proxySandBox,下面我手写了个简单版,代码如下:
class CreateWindowFakeBox{
constructor(){
//创建一个空对象
const fakeWindow = Object.create({});
const proxyObj = new Proxy(fakeWindow, {
set: (target, name, value) => {
//如果window中有这个属性 就修改window
if(window[name]){
window[name] = value
}
//如果没有 就修改fakeWindow中的属性
fakeWindow[name] = value
},
get: (target, name) => {
//如果window中有这个属性 就使用window
if(window[name]){
return window[name]
}
//如果window中没有这个属性 就使用fakeWindow
return fakeWindow[name]
}
})
this.proxy = proxyObj
}
}
const newFakeWindow1 = new CreateWindowFakeBox().proxy
const newFakeWindow2 = new CreateWindowFakeBox().proxy
newFakeWindow1.a = 1
newFakeWindow2.a = 2
console.log(`newFakeWindow1: ${newFakeWindow1.a};`,
`newFakeWindow2: ${newFakeWindow2.a};`,
`window: ${window.a};`)
原理大致就是这个意思,可以看一下代码中的注释,另外说一句,针对不支持proxy的浏览器,qiankun还有一个别的方案来处理snapshotSandbox,这个方案是proxy的降级方案,不过随着ie的淘汰,这个模式估计很快也会被废弃了,感兴趣的同学,可以找源码看一下。
4.3、基于iframe的沙箱机制
这个方案来源于腾讯的wujie,原理是利用iframe的隔离优点,让子应用的js代码在iframe中运行,使用proxy拦截document的操作,把对当前iframe的dom操作指向主应用的shadowRoot,让主应用展示执行的结果。
这个设计的就很巧妙了,因为JS在iframe中运行了也就不存在额外的隔离措施了,同时,页面的执行逻辑,由于在iframe中没有被删除,子应用在被切换之后依然处于活跃状态,只是无法交互了,当然了这样的措施,也会造成页面内存占用过大,原理如下图:
小结
个人觉得,使用proxy是各个方案中最为简单的,因为只需要拦截一些对象的操作即可,但兼容性稍差,采用激活时修改环境的方法,虽然兼容性比较好,但问题也很多,最主要的是无法实现多实例,也无法实现子应用保活,这里比较优秀的是wujie的iframe隔离方案,这个方案不但不用处理隔离,还可以实现应用的保活和多实例。
5、路由状态更新
路由方面,主要分两大块:
5.1、路由劫持
路由事件的监听加上history对象下具体方法的劫持,可以使用Proxy也可以使用Object.defineProperties(这里感觉像vue2处理数组变成响应式的方法)
熟悉vuerouter原理的同学,应该知道如下几个关键技术点:
1.1、popstats 当history中记录的条目发生改变时会触发
1.2、hashchange 当 URL 的片段标识符更改时,另外说一句,新版本的vuerouter已经废弃了个方式,统一使用popstats了
1.3、pushState和replaceState,主要是新增和修改history的历史记录
方案原理大致是监听了popstats或者hashchange事件,并劫持了浏览器history下的pushState和replaceState后,做了个性化处理。
5.2、主应用控制路由
1.1、实现思路,主应用使用现有的路由库,vuerouter或者reactrouter
子应用使用webpack5 的“联邦模块”,将现有的页面发布成独立的服务
在主应用中重新配置路由,使用webpack提供的import()函数,动态加载子用的模块
1.2、由于技术相对较新,我也没有实际使用,目前能看到的一些问题有,子应用如果想自己独立跑起来,需要维护两套路由,要是还有其他的问题欢迎,留言交流。
6、公共依赖
这个问题属于我们软件开发中项目管理的部分,属于降本增效,子项目多了之后,公共依赖如果处理不好,不但造成我们开发工时的浪费,有各种重复工作,同时BUG的风险也会随着复制的代码过多,成指数增长,更不要说日后的长期维护,为了处理依赖的问题解决方案也有很多:
6.1、NPM包
可以企业内部搭建npm服务器发布到,但问题也很明显
1.1、npm包更新的问题,因为package-lock.json会在项目第一次安装依赖的时候生成,而大多数团队基本会把这个文件提交到git,让后面下载代码的同学能更好的run起来项目,但问题也随之而来,这样就会造成npm包更新不及时的问题,需要手动更新。
1.2、任何改动都需要子应用重新部署上线,在项目后期,运行稳定的时候,很多改动都是很小的,尤其是这些公共依赖,哪怕是一个很小的改动,也需要子项目跟着重新build和上线,非常麻烦,影响我们软件迭代速度。
6.2、webpack external 外部扩展
可以将通用的一些包排除在bundle之外,然后使用直接访问公共包JS的方式(一般采用CDN),直接在index.html中引入
2.1、所有子包都需要配置external,当然这个工作可以教给脚手架来完成
2.2、由于需要访问JS,所以所有的公共依赖必须采用UMD格式
2.3、由于采用了UMD的方式引入,原有项目代码也需要做一些兼容处理,否则可能会导致项目无法运行。
6.3、webpack federation 模块联邦
这个是webpack 5的新特性,可以使一个JS应用,动态加载其他JS应用的代码,并且我们可以把一些公共依赖,都抽离到主包。
在子包中,只输出业务代码即可,模块联邦提供对应的配置功能,并且由于是从网络获取,可以做做热更新。
这个我会在之后单独写篇文章说明一下,这个很有意思的哦
3.1、需要升级现有的低版本webapck项目,由于webpack5一些字段的调整,可能会有一些开发的工作量
3.2、子项目和主项目都需要进行webpack federation的配置工作,才可使用
6.4、monorepo 多包管理
目前主流使用lerna框架进行多包管理,把单独的包抽离到独立的子项目中维护,后期如果项目稳定,可以把依赖抽离到webpack federation 做热更新。
7、通信方式
子应用间通信,看似很重要,个人觉得不是很重要,从软件设计中来说,应该尽量解耦,如果两个子应用之间需要频繁通信,不如考虑把两个项目做到一起。因为频繁的与其他项目通信,会对项目维护造成很大的困扰。
但是,也不能没有,目前主流的方案如下:
7.1、基于URL通信
原理不多说了,很简单,说说问题
- 1.1、传输的数据有限,url是有最大长度限制的,而且各个浏览器还不一样,以chrom为例大小为8182字节
1.2、因为是字符串,需要进行序列化,有些数据类型无法序列化,
1.3、由于是url还要注意特殊字符的转换 比如#、=、?等,都需要做编码和解码
7.2、基于 props
原理是主应用向子应用传值,使用场景比如共享的组件、共享的方法、常量等,从数据流动原则讲,这些数据都是不允许在子应用中修改的,否则可能会影响到其他子应用。
7.3、基于 localStorage sessionStorage
原理不多说了,很简单,说说问题,与url一样存在序列化的问题,只会转Number String Boolean Array等数据类型,对于undefined、function、NaN、regExp、Date等都会丢失
7.4、基于postmessage
这个原理也很简单,简单的说就是个自定义事件,代码如下:
window.addEventListener(‘message’, (e) => {
//处理事件
})
//派发事件
window.dispatchEvent(new CustomEvent(‘message’, {detail: value}))
这个看起来似乎很美妙,但也有写问题
由于事件的发布订阅分散在各个项目中,当事件变多时有可能重名,我们在设计之初就需要规定事件的命名规范,且需要遍历全部项目检查
7.5、基于 发布订阅和状态管理
这个方案,可以使用现在主流的状态管理方案,这里首推redux(特别注意是redux不是react-redux哦),因为vuex依赖vue 而redux是完全独立的框架,不依赖任何框架就可以运行。
这个方案我个人是很推荐的,因为redux自己就是一个发布订阅,不仅支持状态保存,而且修改数据还需要Reducer来进行修改。
总结
最后总结一下,以上的方案是笔者调研了几个微前端框架总结的,这些框架有single-spa、qiankun、microApp、EMP、wujie,也有一些是几个框架间相互穿插形成的新方案。
由于微前端这样的复杂的方案,各项子方案之间关系比较复杂,对于这样框架,我们还需要引入一个生命周期的概念
关于生命周期的设计呢,可以参照一下webpack所使用的Tapable,围绕 主应用与子应用创建与销毁的状态来创建同步钩子,同时也可以在各个生命周期间引入异步钩子,方便第三方插件的接入。