前端进阶(九)-微应用框架、Svelte

微前端

微前端的概念是由ThoughtWorks在2016年提出的,它借鉴了微服务的架构理念,核心在于将一个庞大的前端应用拆分成多个独立灵活的小型应用,每个应用都可以独立开发、独立运行、独立部署,再将这些小型应用融合为一个完整的应用,或者将原本运行已久、没有关联的几个应用融合为一个应用。微前端既可以将多个项目融合为一,又可以减少项目之间的耦合,提升项目扩展性,相比一整块的前端仓库,微前端架构下的前端仓库倾向于更小更灵活。

微前端架构具备以下几个核心价值:

  • 技术栈无关 主框架不限制接入应用的技术栈,微应用具备完全自主权
  • 独立开发、独立部署 微应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新
  • 增量升级

    在面对各种复杂场景时,我们通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略

  • 独立运行时 每个微应用之间状态隔离,运行时状态不共享

微前端架构旨在解决单体应用在一个相对长的时间跨度下,由于参与的人员、团队的增多、变迁,从一个普通应用演变成一个巨石应用(Frontend Monolith)后,随之而来的应用不可维护的问题。这类问题在企业级 Web 应用中尤其常见。

微前端是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将前端应用由单一的单体应用转变为多个小型前端应用聚合的应用。各个前端应用还可以独立开发、独立部署。同时,他们也可以进行并行开发,这些组件可以通过 npm,git 等工具进行代码管理。

微前端的特点

  • 应用自治

    • 遵从统一的接口规或者框架,以便于系统集成在一起,因此,相互之间是不存在依赖关系的。
  • 单一职责

    • 不同模块之间理应满足单一职责原则。
  • 技术栈无关

    • 选择合适的语言和框架开发适合的服务,服务之间通过 api 进行通信。

为什么需要微前端

  • 遗留的系统迁移

    • 不用花费大量时间维护遗留系统,不用学习过时的旧技术。
    • 抽出人力使用新的技术栈来开发新的业务。
  • 聚合前端应用

    • 使用不同技术栈开发不同模块,解耦模块之间的依赖。
    • 前端将每个模块聚合成一个大项目,用户只会感觉是一个产品。
  • 热闹驱动开发

    • 因为目前看来,微前端是一种前端架构的趋势,整个社区都比较热闹。
    • 有些时候,我们在选择技术栈时,是因为这个技术栈比较流行,而不是考虑是否实用。
    • 在微前端架构下,可以在一些小模块中运用新的技术栈,对其他模块没有影响。

实现微前端架构,就是要将一个大项目,拆分成若干个独立的小项目,常用的拆分方式如下:

  • 路由分发式
  • 前端微服务化
  • 微应用
  • 微件化
  • 前端容器化
  • 应用组件化

路由分发式

路由分发式是通过 http 服务器的反向代理功能,将请求路由到对应的而应用上。

这种方式看上去更像是多个前端应用的聚合,即我们只是将不同的前端应用拼凑在一起,使他们看起来像一个完整的整体。但他们并非是一个整体,每当用户从 A 应用转换到 B 应用的时候,往往需要刷新一个页面、重新加载资源文件。

注意事项:

  • 需要考虑应用之间的数据传递方式。
  • 缺少了对应用状态的管理,需要用户重新登录,用户体验不好。
  • 一个页面只有唯一的一个应用。

划分模式

与微服务类似,要划分前端边界并不是一件容易的事。就当前而言,有以下几种方案:

  • 按照业务划分
  • 按照权限划分
  • 按照变更的频率划分
  • 按照组织结构划分
  • 跟随后端微服务划分

按照业务划分

在大型前端应用里,往往包含了多个业务,这些业务在某种程度上存在关联,但并非强关联,我们可以通过这些业务来进行拆分。

例如:一个电商网站包含电子商务系统、物流系统、库存系统等,我们就可以拆分出 3 个模块。

权限划分

对于一个同时存在多种角色以及多种不同权限的网站来说,最适合不过了。尤其是这些权限在功能上是分开的,没有必要集中在一个前端应用中。

例如:一个教学网站,老师端和学生端所拥有的权限是不一样的,他们所看到的页面也不一样,我们可以根据权限,拆分出 2 个模块。

频率划分

在一个前端应用中,并非所有模块和业务代码都在不断地修改、添加新的功能。不同的业务模块拥有不同的变更频率。有些功能可能在上线之后,因为用户少而几乎不修改。而有些功能是用户最常用的,所以在不断迭代和优化中。

因此,可以按照变更频率来拆分前端应用,这样可以保持稳定的模块一直稳定下去。

按组织结构划分

对于后端来说,按照组织结构拆分服务,几乎是一个默认的做法。团队之间使用 api 文档和契约,就可以轻松的进行协作,对于前端应用来说,同样可以采用这种方式来进行。

例如:根据公司组织结构,电子商务部门,物流部门,库存部门,可以分别进行开发对应模块。

按后端微服务划分

对于微服务的实施,后端在很久之前早就开始进行架构了,即后端拥有现成的拆分好的模块,前端也可以追随后端的拆分方式来进行拆分。

然而,后端采用的拆分方式,并不都适合于前端应用,可能多数时候并不适合,所以这种方式多数情况只能作为参考。

微前端架构模式

从微前端应用间的关系来看,可以分为两种:

  • 基座模式

    • 通过一个主应用来管理其他应用。设计难度小,方便实践,但是通用度低。
  • 自组织模式

    • 应用之间平等,不存在相互管理的模式。设计难度大,不方便实施,但通用度高。

就当前来看,基座模式实施起来比较方便,方案也很多。

不管哪一种方式,都需要体用一个查找应用的机制,在微前端中称为服务的注册表模式。它主要做以下一些事情:

  • 应用发现。让主应用可以寻找到其他应用。
  • 应用注册。即提供新的微前端应用,向应用注册表注册的功能。
  • 第三方应用注册。即让第三方应用接入系统中。
  • 访问权限等相关配置。

微前端设计理念

在实践微前端时,应该考虑以下几点:

  • 中心化:应用注册表。
  • 标识化应用。
  • 应用生命周期管理。
  • 高内聚,低耦合。

#中心化:应用注册表

对于一个服务注册中心:服务提供方要注册通告服务地址,服务的调用方要能发现目标服务。

以路由形式的注册表为例,当我们添加了一个新的应用时,相当于在网页上添加了一个菜单链接,用户就能知道哪个页面是可以使用的,也就能访问到这个新的应用。从代码上来说,就是我们需要有一个地方来管理应用,目前存在哪些应用,哪个应用使用哪个路由。

#标识化应用

标识化应用是指,建立某种规则来区分不同的应用,类似于唯一标识符,即 id。我们通过这个 id 来表示不同的应用,以便在安装和卸载的时候能寻找到指定的应用。

如果存在大量的不需要审核的应用,那么可以由系统后台来生成唯一的标识符。

#应用生命周期管理

当用户单机某个链接时,系统需要加载对应的应用,在这个过程中,还需要加载动画来响应用户的行为,并创建应用所需要的 dom 节点,将应用挂载到响应的 dom 节点上,然后运行应用。当用户不需要这个应用的时候,我们可以选择卸载应用,或者继续保留应用。这几个步骤所做的事情,就体现了应用的生命周期。

生命周期包括如下 3 个部分:

  • 加载应用
  • 运行应用
  • 卸载应用

具体的生命周期案例:

  • load,决定绑定哪个应用,并绑定生命周期。
  • bootstrap,获取静态资源。
  • mount,安装应用,如创建 dom 节点。
  • unload,删除应用的生命周期。
  • unmount,卸载应用,如删除 dom 节点,取消事件绑定。

js隔离

如何确保各个子应用之间的全局变量不会互相干扰,从而保证每个子应用之间的软隔离?

这个问题比样式隔离的问题更棘手,社区的普遍玩法是给一些全局副作用加各种前缀从而避免冲突。但其实我们都明白,这种通过团队间的”口头“约定的方式往往低效且易碎,所有依赖人为约束的方案都很难避免由于人的疏忽导致的线上 bug。那么我们是否有可能打造出一个好用的且完全无约束的 JS 隔离方案呢?

即在应用的 bootstrap 及 mount 两个生命周期开始之前分别给全局状态打下快照,然后当应用切出/卸载时,将状态回滚至 bootstrap 开始之前的阶段,确保应用对全局状态的污染全部清零。而当应用二次进入时则再恢复至 mount 前的状态的,从而确保应用在 remount 时拥有跟第一次 mount 时一致的全局上下文。

css隔离

社区通常的实践是通过约定 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 作为子节点塞到主框架的容器中。这样不仅可以极大的减少主应用的接入成本,子应用的开发方式及打包方式基本上也不需要调整,而且可以天然的解决子应用之间样式隔离的问题(后面提到)。

父子应用通信

在微前端架构中,我们应该按业务划分出对应的子应用,而不是通过功能模块划分子应用。这么做的原因有两个:

  1. 在微前端架构中,子应用并不是一个模块,而是一个独立的应用,我们将子应用按业务划分可以拥有更好的可维护性和解耦性。
  2. 子应用应该具备独立运行的能力,应用间频繁的通信会增加应用的复杂度和耦合度。

与其他对比

为什么不用iframe

iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。但他的最大问题也在于他的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题。

  1. url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。
  2. UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中..
  3. 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。
  4. 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。

它主要解决了两个问题:

  • 1、随着项目迭代应用越来越庞大,难以维护。
  • 2、跨团队或跨部门协作开发项目导致效率低下的问题。

微前端性能优化

微前端导入主应用之后,主要优化指标:

FCP 400ms

微应用 FCP(First Contentful Paint)首次内容绘制: 400ms以内。 主应用 FCP 时间不受影响

TTI 2000ms

微应用 TTI(Time to Interactive)可交互时间 压缩到 2s以内(预加载子应用全局数据)。 主应用 TTI 要保证不受微应用的 http 的影响。

主要性能可以优化的地方:

  • 1、提前加载时机,进行预加载和按需加载。
  • 2、资源共享,实现微前端应用之间的前端资源共享。
  • 3、优化qiankun执行性能。
  • 4、优化微应用的首屏,进行传统网页优化。

提前加载

<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/g6antd, react, jquery 等大型仓库,就可以将这些依赖通过外部 CDN 的方式引入,避免应用之间 JS 文件的重复请求。

但是有些细节需要注意,假如你将子应用中的公共 js 文件请求通过上文代码中getTemplate进行过滤,节省一次 Http 请求,你需要将qiankun的沙箱模式关闭,不然会引入不可控的bug

这里实际不将公共的 JS 文件过滤也是可以的,性能影响经过测试,发现实际不大,因为资源是网络地址是完全一样的,因此浏览器会缓存它,最大的性能开销-http请求已经被解决了。

其次是使用 CDN 加载第三方库的数量不宜过多,过多的CDN请求,会占据浏览器并发请求数,导致父应用加载速度变慢,得不偿失了。

目前的方案也是官方推荐的方案,官方承诺在 qiankun2.0给出更加智能方式使其自动化,让我们拭目以待吧。

shared关键字

请求数据共享

影响微应用尽快让用户体验页面的最后一个流程是数据请求阶段,页面需要后台数据到达之后才能渲染对用户有意义的页面。 那第一个思考方向是将父应用和子应用,或者子应用之间的数据进行跨应用共享,每个数据只需要请求一次,然后所有应用共享,可以极大减少用户等待数据的时间。

优化qiankun执行性能

qiankun 2.8 之后增加了性能加载模式,通过speedy: true开启,在保留沙箱的模式基础上做了window和document 实例的性能优化,加载速度确实快了点。 这个优化目前是固定到特定版本,例如2.8.0,而最新的版本2.8.3则会执行报错

start({
    prefetch: 'all',
    sandbox: {
      speedy: true,
    },
});

首屏优化

主要集中精力在提升子应用的首屏时间、调整请求加载顺序、父子应用数据共享。例如将initGlobalState深度应用到微应用代码中,减少数据请求等等

https://juejin.cn/post/7155754068764262414#heading-4

如果你觉得我的文章对你有帮助的话,希望可以推荐和交流一下。欢迎關注和 Star 本博客或者关注我的 Github