前端进阶(九)-微应用框架、Svelte
微前端的概念是由ThoughtWorks在2016年提出的,它借鉴了微服务的架构理念,核心在于将一个庞大的前端应用拆分成多个独立灵活的小型应用,每个应用都可以独立开发、独立运行、独立部署,再将这些小型应用融合为一个完整的应用,或者将原本运行已久、没有关联的几个应用融合为一个应用。微前端既可以将多个项目融合为一,又可以减少项目之间的耦合,提升项目扩展性,相比一整块的前端仓库,微前端架构下的前端仓库倾向于更小更灵活。
微前端架构具备以下几个核心价值:
增量升级
在面对各种复杂场景时,我们通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略
微前端架构旨在解决单体应用在一个相对长的时间跨度下,由于参与的人员、团队的增多、变迁,从一个普通应用演变成一个巨石应用(Frontend Monolith)后,随之而来的应用不可维护的问题。这类问题在企业级 Web 应用中尤其常见。
微前端是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将前端应用由单一的单体应用转变为多个小型前端应用聚合的应用。各个前端应用还可以独立开发、独立部署。同时,他们也可以进行并行开发,这些组件可以通过 npm,git 等工具进行代码管理。
微前端的特点
应用自治
单一职责
技术栈无关
为什么需要微前端
遗留的系统迁移
聚合前端应用
热闹驱动开发
实现微前端架构,就是要将一个大项目,拆分成若干个独立的小项目,常用的拆分方式如下:
路由分发式
路由分发式是通过 http 服务器的反向代理功能,将请求路由到对应的而应用上。
这种方式看上去更像是多个前端应用的聚合,即我们只是将不同的前端应用拼凑在一起,使他们看起来像一个完整的整体。但他们并非是一个整体,每当用户从 A 应用转换到 B 应用的时候,往往需要刷新一个页面、重新加载资源文件。
注意事项:
与微服务类似,要划分前端边界并不是一件容易的事。就当前而言,有以下几种方案:
按照业务划分
在大型前端应用里,往往包含了多个业务,这些业务在某种程度上存在关联,但并非强关联,我们可以通过这些业务来进行拆分。
例如:一个电商网站包含电子商务系统、物流系统、库存系统等,我们就可以拆分出 3 个模块。
权限划分
对于一个同时存在多种角色以及多种不同权限的网站来说,最适合不过了。尤其是这些权限在功能上是分开的,没有必要集中在一个前端应用中。
例如:一个教学网站,老师端和学生端所拥有的权限是不一样的,他们所看到的页面也不一样,我们可以根据权限,拆分出 2 个模块。
频率划分
在一个前端应用中,并非所有模块和业务代码都在不断地修改、添加新的功能。不同的业务模块拥有不同的变更频率。有些功能可能在上线之后,因为用户少而几乎不修改。而有些功能是用户最常用的,所以在不断迭代和优化中。
因此,可以按照变更频率来拆分前端应用,这样可以保持稳定的模块一直稳定下去。
按组织结构划分
对于后端来说,按照组织结构拆分服务,几乎是一个默认的做法。团队之间使用 api 文档和契约,就可以轻松的进行协作,对于前端应用来说,同样可以采用这种方式来进行。
例如:根据公司组织结构,电子商务部门,物流部门,库存部门,可以分别进行开发对应模块。
按后端微服务划分
对于微服务的实施,后端在很久之前早就开始进行架构了,即后端拥有现成的拆分好的模块,前端也可以追随后端的拆分方式来进行拆分。
然而,后端采用的拆分方式,并不都适合于前端应用,可能多数时候并不适合,所以这种方式多数情况只能作为参考。
从微前端应用间的关系来看,可以分为两种:
基座模式
自组织模式
就当前来看,基座模式实施起来比较方便,方案也很多。
不管哪一种方式,都需要体用一个查找应用的机制,在微前端中称为服务的注册表模式。它主要做以下一些事情:
微前端设计理念
在实践微前端时,应该考虑以下几点:
对于一个服务注册中心:服务提供方要注册通告服务地址,服务的调用方要能发现目标服务。
以路由形式的注册表为例,当我们添加了一个新的应用时,相当于在网页上添加了一个菜单链接,用户就能知道哪个页面是可以使用的,也就能访问到这个新的应用。从代码上来说,就是我们需要有一个地方来管理应用,目前存在哪些应用,哪个应用使用哪个路由。
标识化应用是指,建立某种规则来区分不同的应用,类似于唯一标识符,即 id。我们通过这个 id 来表示不同的应用,以便在安装和卸载的时候能寻找到指定的应用。
如果存在大量的不需要审核的应用,那么可以由系统后台来生成唯一的标识符。
当用户单机某个链接时,系统需要加载对应的应用,在这个过程中,还需要加载动画来响应用户的行为,并创建应用所需要的 dom 节点,将应用挂载到响应的 dom 节点上,然后运行应用。当用户不需要这个应用的时候,我们可以选择卸载应用,或者继续保留应用。这几个步骤所做的事情,就体现了应用的生命周期。
生命周期包括如下 3 个部分:
具体的生命周期案例:
如何确保各个子应用之间的全局变量不会互相干扰,从而保证每个子应用之间的软隔离?
这个问题比样式隔离的问题更棘手,社区的普遍玩法是给一些全局副作用加各种前缀从而避免冲突。但其实我们都明白,这种通过团队间的”口头“约定的方式往往低效且易碎,所有依赖人为约束的方案都很难避免由于人的疏忽导致的线上 bug。那么我们是否有可能打造出一个好用的且完全无约束的 JS 隔离方案呢?
即在应用的 bootstrap 及 mount 两个生命周期开始之前分别给全局状态打下快照,然后当应用切出/卸载时,将状态回滚至 bootstrap 开始之前的阶段,确保应用对全局状态的污染全部清零。而当应用二次进入时则再恢复至 mount 前的状态的,从而确保应用在 remount 时拥有跟第一次 mount 时一致的全局上下文。
社区通常的实践是通过约定 css 前缀的方式来避免样式冲突,即各个子应用使用特定的前缀来命名 class,或者直接基于 css module 方案写样式。对于一个全新的项目,这样当然是可行,但是通常微前端架构更多的目标是解决存量/遗产 应用的接入问题。很显然遗产应用通常是很难有动力做大幅改造的。
最主要的是,约定的方式有一个无法解决的问题,假如子应用中使用了三方的组件库,三方库在写入了大量的全局样式的同时又不支持定制化前缀?比如 a 应用引入了 antd 2.x,而 b 应用引入了 antd 3.x,两个版本的 antd 都写入了全局的 .menu class
,但又彼此不兼容怎么办?
解决方案与子应用入口文件相关。我们只需要在应用切出/卸载后,同时卸载掉其样式表即可,原理是浏览器会对所有的样式表的插入、移除做整个 CSSOM 的重构,从而达到 插入、卸载 样式的目的。这样即能保证,在一个时间点里,只有一个应用的样式表是生效的。
上文提到的 HTML Entry 方案则天生具备样式隔离的特性,因为应用卸载后会直接移除去 HTML 结构,从而自动移除了其样式表。
当子应用被替换或卸载时,subApp
节点的 innerHTML 也会被复写,//alipay.com/subapp.css
也就自然被移除样式也随之卸载了。
<html>
<body>
<main id="subApp">
// 子应用完整的 html 结构
<link rel="stylesheet" href="//alipay.com/subapp.css">
<div id="root">....</div>
</main>
</body>
</html>
子应用提供什么形式的资源作为渲染入口?
JS Entry 的方式通常是子应用将资源打成一个 entry script,比如 single-spa 的 example 中的方式。但这个方案的限制也颇多,如要求子应用的所有资源打包到一个 js bundle 里,包括 css、图片等资源。除了打出来的包可能体积庞大之外的问题之外,资源的并行加载等特性也无法利用上。
HTML Entry 则更加灵活,直接将子应用打出来 HTML 作为入口,主框架可以通过 fetch html 的方式获取子应用的静态资源,同时将 HTML document 作为子节点塞到主框架的容器中。这样不仅可以极大的减少主应用的接入成本,子应用的开发方式及打包方式基本上也不需要调整,而且可以天然的解决子应用之间样式隔离的问题(后面提到)。
在微前端架构中,我们应该按业务划分出对应的子应用,而不是通过功能模块划分子应用。这么做的原因有两个:
为什么不用iframe
iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。但他的最大问题也在于他的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题。
它主要解决了两个问题:
微前端导入主应用之后,主要优化指标:
FCP 400ms
微应用 FCP
(First Contentful Paint)首次内容绘制: 400ms以内。
主应用 FCP
时间不受影响
TTI 2000ms
微应用 TTI
(Time to Interactive)可交互时间 压缩到 2s以内(预加载子应用全局数据)。
主应用 TTI
要保证不受微应用的 http 的影响。
主要性能可以优化的地方:
qiankun
执行性能。<micro-app-container @visibleChange="onMicroAppVisibleChange"></micro-app-container>
<!-- 并列层级 -->
<router-view :class="{ 'app-content': !hideAppContent, 'micro-app-content': microAppVisible }" />
如果采用在路由的组件内加载就会导致只有进入到这路由页面,才会触发qiankun
的加载和渲染,会导致用户要等待微应用加载完之后才能体验页面
其次微应用的加载实际并不是越早越好,要在主应用完成加载和渲染之后的空闲时间,再启动微应用的加载。毕竟浏览器的请求并发存在限制,且qiankun
是通过解析 HTML 模板,重构 XHR 的 HTTP 请求,来进行 JS 和 CSS 资源的请求,其 HTTP 的请求压力就更大了。
因此选择在 Layout
组件挂载之后进行预加载。
// container.vue
{
mounted() {
registerMicroApps([...], {
beforeLoad: () => { /** 执行动画 */ },
afterMount: () => { /** 结束动画 */ },
});
start({
prefetch: 'all',
});
}
}
qiankun
预加载的属性是prefetch
,默认为 true
。
true
则会在第一个微应用 mount 完成后开始预加载其他微应用的静态资源'all'
则主应用 start
后即开始预加载所有微应用静态资源string[]
则会在第一个微应用 mounted 后开始加载数组内的微应用资源function
则可完全自定义应用的资源加载时机 (首屏应用及次屏应用)依赖库共享
webpack externals
能够将一些第三方包通过外部扩展的方式,通过 CDN 的方式引入到项目中,在 NPM 没有成为主流之前,前端 JS 的引入方式就是这种方式的雏形
<script
src="https://code.jquery.com/jquery-3.1.0.js"
integrity="sha256-slogkvB1K3VOkzAI8QITxV3VzpOnkeNVsKvtkYLMjfk="
crossorigin="anonymous"
></script>
module.exports = {
//...
externals: {
jquery: 'jQuery',
},
};
假如应用之间存在较大的公共依赖库,例如antd/g6
,antd
, react
, jquery
等大型仓库,就可以将这些依赖通过外部 CDN 的方式引入,避免应用之间 JS 文件的重复请求。
但是有些细节需要注意,假如你将子应用中的公共 js 文件请求通过上文代码中getTemplate
进行过滤,节省一次 Http 请求,你需要将qiankun
的沙箱模式关闭,不然会引入不可控的bug
。
这里实际不将公共的 JS 文件过滤也是可以的,性能影响经过测试,发现实际不大,因为资源是网络地址是完全一样的,因此浏览器会缓存它,最大的性能开销-http请求已经被解决了。
其次是使用 CDN 加载第三方库的数量不宜过多,过多的CDN请求,会占据浏览器并发请求数,导致父应用加载速度变慢,得不偿失了。
目前的方案也是官方推荐的方案,官方承诺在 qiankun2.0
给出更加智能方式使其自动化,让我们拭目以待吧。
shared关键字
影响微应用尽快让用户体验页面的最后一个流程是数据请求阶段,页面需要后台数据到达之后才能渲染对用户有意义的页面。 那第一个思考方向是将父应用和子应用,或者子应用之间的数据进行跨应用共享,每个数据只需要请求一次,然后所有应用共享,可以极大减少用户等待数据的时间。
qiankun 2.8 之后增加了性能加载模式,通过speedy: true
开启,在保留沙箱的模式基础上做了window和document 实例的性能优化,加载速度确实快了点。 这个优化目前是固定到特定版本,例如2.8.0,而最新的版本2.8.3则会执行报错
start({
prefetch: 'all',
sandbox: {
speedy: true,
},
});
主要集中精力在提升子应用的首屏时间、调整请求加载顺序、父子应用数据共享。例如将initGlobalState
深度应用到微应用代码中,减少数据请求等等