一文搞懂微前端
前言
阅读本文,可以获得,对微前端技术原理的大概的了解
微前端——是前端多种技术栈结合的产物,即便目前业务对微前端没有什么需求,里面所涉及到的技术栈,也能让我们的开发体验或者用户体验有所提升。
本文 6000字左右,全文阅读时间15-20分钟,如果只对部分内容感兴趣,可以看左侧大纲,选择性阅读。
如果有问题,欢迎一起在评论区交流讨论,感谢阅读。
一、背景
随着前端的发展,单页面应用(SPA)已经成为,前端的主流技术方向,而随着时间的推移,前端的所承载的功能也越来越丰富,单页应用的也越来越庞大,越来越难以维护。
一、 发版、修改、重构的成本越来越高。而微前端就是将这些庞大的分支拆分,并解耦,让日常维护中,只修改单一一个或者几个子项目,每个子项目可以独立部署单独维护,提高开发效率
二、 一些祖传项目,如jquery,Angular.js 1等B端项目,介于日常运营,这些系统需要结合到新框架中来使用还不能抛弃,对此我们也没有理由浪费时间和精力重写旧的逻辑,也可以使用微前端进行拆分。我们可以将老项目整合到新的vue react项目中,可以基本不修改老项目的逻辑,同时兼容新项目,甚至可以在,需求不多的时候,逐步替换老项目中的逻辑。
在介绍微前端前,我们先简单的,看图了解一下基本原理:
说到这呢,对于微前端市面上已经有一些主流方案来解决这个问题了,方案有很多,但无论哪种方案,它们要解决的基本问题,大致由上图这些组成,下面我在详细列举一下:
- CSS样式隔离:不同项目,相同的样式名在页面中是会相互污染的
- js隔离:不同项目,在向window挂在变量时,有可能会互相污染
- 公共依赖:对于各个子项目,或多或少都会存在公共依赖,无论是从开发效率还是从页面性能都是微前端需要解决的点
- 路由状态:子应用的路由改变需要同步到主应用上
- 预加载:利用用户浏览空闲时间,提前加载其他项目的JS文件,提高用户体验
- 通信方式:子应用之间互相通信也是必不可少的,要尽量解耦不要互相调用。
好的,问题我们已经分析完了,接下来我们来分别介绍一下各个问题的具体解决方案。
二、CSS样式隔离
样式隔离的方案有很多,每个方案都各有优缺点,这里简单介绍一下:
1、Shadow DOM
这项技术属于浏览器技术,属于Web components下的一个子项,它可以将一个隐藏的、独立的 DOM 附加到一个元素上,这项技术能很好的做到样式隔离,感兴趣的同学可以移步MDN:
这里呢,我说一下这项技术的一些问题:
1.1、 浏览器支持很不友好,在国内的一些情况还是很难推行,当然像微前端这样的技术大多数用于B端,可以限制一下用户的浏览器。
1.2、对react比较熟悉的同学都是知道 react把事件都代理到了document上,这样会导致shadowDOM中的绑定的事件不会被触发,不过新版本的react已经做了相关的修改。
1.3、有一些UI库会把组件动态挂在到document上,如antd的Modal组件,这样会导致一个问题,我们在shadow DOM对Modal进行样式修改是不会生效的。
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
举个例子 比如我有个子应用主要负责订单相关业务,这个业务下面有个子页面是订单详情,页面下有个子元素是个钮按,这个按钮有个选中状态,这样的场景用BEM怎么表示呢?
模块-多单词_子元素_状态:Order-Details_Button_Check
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添加一个外层的命名空间,这缺点是,要增加编译的时间,不过可以通过编译缓存处理。
3、小结
总结一下,总体来说,无论使用哪种,对老项目,都会有一些开发的工作量,其中postcss 相对比较简单一些,css in js业内存在争议,个人不建议使用,shadowDOM的技术比较新,也是个人比较推荐的方案。
三、JS隔离
由于es6引入了块级作用域,JS的隔离已经简单很多了,熟悉var和let区别的同学,应该知道var是有提升问题的,在讲js隔离前,首先要保证项目中不能有var,如果有祖传代码转微前端,首先要处理var的问题。
为什么要隔离?举一个简单的例子,比如不同的子项目之间都对window对象下相同变量进行了赋值,这就会造成一些不必要的问题,那如何处理呢?
目前主流方案有三个
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无法实现多个子应用实例同时渲染
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的淘汰,这个模式估计很快也会被废弃了,感兴趣的同学,可以找源码看一下。
3、基于iframe的沙箱机制
这个方案来源于腾讯的wujie,原理是利用iframe的隔离优点,让子应用的js代码在iframe中运行,使用proxy拦截document的操作,把对当前iframe的dom操作指向主应用的shadowRoot,让主应用展示执行的结果。
这个设计的就很巧妙了,因为JS在iframe中运行了也就不存在额外的隔离措施了,同时,页面的执行逻辑,由于在iframe中没有被删除,子应用在被切换之后依然处于活跃状态,只是无法交互了,当然了这样的措施,也会造成页面内存占用过大,原理如下图:
这里在我看了源码后,手写了个简单版,原理大致相同,方便大家简单了解,实际的代码要复杂的多,代码如下:
运行结果:
4、小结
个人觉得,使用proxy是各个方案中最为简单的,因为只需要拦截一些对象的操作即可,但兼容性稍差,采用激活时修改环境的方法,虽然兼容性比较好,但问题也很多,最主要的是无法实现多实例,也无法实现子应用保活,这里比较优秀的是wujie的iframe隔离方案,这个方案不但不用处理隔离,还可以实现应用的保活和多实例。
四、路由状态更新
路由方面,主要分两大块:
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后,做了个性化处理。
2、主应用控制路由
1.1、 实现思路,主应用使用现有的路由库,vuerouter或者reactrouter
子应用使用webpack5 的“联邦模块”,将现有的页面发布成独立的服务
在主应用中重新配置路由,使用webpack提供的import()函数,动态加载子用的模块
1.2、 由于技术相对较新,我也没有实际使用 , 目前能看到的一些问题有,子应用如果想自己独立跑起来,需要维护两套路由,要是还有其他的问题欢迎,留言交流。
四、公共依赖
这个问题属于我们软件开发中项目管理的部分,属于降本增效,子项目多了之后,公共依赖如果处理不好,不但造成我们开发工时的浪费,有各种重复工作,同时BUG的风险也会随着复制的代码过多,成指数增长,更不要说日后的长期维护,为了处理依赖的问题解决方案也有很多:
1、NPM包
可以企业内部搭建npm服务器发布到,但问题也很明显
1.1、 npm包更新的问题,因为package-lock.json会在项目第一次安装依赖的时候生成,而大多数团队基本会把这个文件提交到git,让后面下载代码的同学能更好的run起来项目,但问题也随之而来,这样就会造成npm包更新不及时的问题,需要手动更新。
1.2、 任何改动都需要子应用重新部署上线,在项目后期,运行稳定的时候,很多改动都是很小的,尤其是这些公共依赖,哪怕是一个很小的改动,也需要子项目跟着重新build和上线,非常麻烦,影响我们软件迭代速度。
2、webpack external 外部扩展
可以将通用的一些包排除在bundle之外,然后使用直接访问公共包JS的方式(一般采用CDN),直接在index.html中引入
2.1、 所有子包都需要配置external,当然这个工作可以教给脚手架来完成
2.2、 由于需要访问JS,所以所有的公共依赖必须采用UMD格式
2.3、 由于采用了UMD的方式引入,原有项目代码也需要做一些兼容处理,否则可能会导致项目无法运行。
3、webpack federation 模块联邦
这个是webpack 5的新特性,可以使一个JS应用,动态加载其他JS应用的代码,并且我们可以把一些公共依赖,都抽离到主包。
在子包中,只输出业务代码即可,模块联邦提供对应的配置功能,并且由于是从网络获取,可以做做热更新。
这个我会在之后单独写篇文章说明一下,这个很有意思的哦
3.1、 需要升级现有的低版本webapck项目,由于webpack5一些字段的调整,可能会有一些开发的工作量
3.2、 子项目和主项目都需要进行webpack federation的配置工作,才可使用
4、monorepo 多包管理
目前主流使用lerna框架进行多包管理,把单独的包抽离到独立的子项目中维护,后期如果项目稳定,可以把依赖抽离到webpack federation 做热更新。
对这项技术感兴趣的同学可以移步我的另一篇文章:
4.1、 区别于传统开发,需要进行一定的学习
五、预加载
在实际用户使用中,浏览器大部分时间是处在空闲的,我们可以利用这样的空闲时间,去加载其他子应用的JS,用来优化用户体验,那如何做?
这里可以采用react fiber的概念来利用起空闲时间
相信对于熟悉react的同学都应该听过fiber这个概念,如果没听过,可以移步我的一片文章,我分四期详述了fiber的设计:
在这项技术里,有一个关键的事件 requestIdleCallback(特别说明,react用的不是这个回调,只是模仿了这个回调的逻辑,具体的实现方式,可以点击上方链接详细了解哦), 这个函数是一个回调 , 会在浏览器空闲的时候被调用,它可以传入一个function 在默认情况下,这个function 有50ms的执行时间。
嗯你没看做,就50ms,虽然很短但我们可以用它发起一个异步请求,比如请求一个其他子应用的JS,这个方案来自与wujie,代码如下:
requestIdleCallback(() => fetchAssets(src, scriptCache, fetch, false, loadError).then(resolve, reject))