如何在应用架构中设计微前端方案 - icestark
作者: 发布于:

微前端在 2016 年 ThoughtWorks 的一个技术雷达上面提出后,不断有团队尝试将单体的前端 web 应用按不同维度进行拆分或者组合,再聚合到一个整体的应用架构下面。
无论从系统体验优化还是技术架构升级的角度,都对微前端的方案提出了各种高要求。本文将围绕 icestark 对于不同场景的思考和设计,来尝试给出解决方案。

场景分析

在聚焦希望引入微前端技术架构的场景上,不难发现,以下的两类场景的诉求会相对强烈:
1
. 工作台的场景,基于产品体验的纬度
2
. 大型单体应用,这种场景更侧重于想从技术维度进行优化,能系统可持续的迭代发展

工作台场景

在工作台场景,从产品侧的诉求来看,希望跨系统的操作能够更加简易,能够带来系统操作和体验的一致性;而从技术架构的角度,各个独立的系统缺乏统一的管控手段,许多能力都在重复建设。

大型单体应用

面向大型单体应用的场景,也就是我们常见的巨石应用,随着业务需求的迭代,系统的复杂度直线上升。从直观的感受来讲,这个应用构建的速度越来越慢,生成的 bundle 大小越来越大。日常的调试开发体验也收到一定程度的影响。
从另一方面来看,伴随着业务功能体量的提升,也导致了开发和协作成本的上升,常见的问题就是局部技术架构升级会变得非常困难,业务的扩展和其他业务能力的接入都会对单体 SPA 架构带来要求。

技术选型

对于应用架构的设计上,除了微前端的技术架构外,还有几种场景的技术选型。

SPA/MPA

巨石应用也就是 SPA/MPA 的技术架构。这里想单独提一下,针对产品体验的场景,一个简单的 SPA 应用、肯定是能够给到一个整体的系统体验,并且能够管控系统的技术复杂度。但如果系统越来越臃肿,就会遇到上面到的技术纬度的问题。
SPA/MPA 是前端技术架构中最为常见的,对于一个独立的系统它能在整体体验和技术复杂度上做到很好的管控。但是为了防止这个项目后续发展成一个上古项目,无论是在功能迭代还是公用模块的管理上都会提出很高的要求。不然随着系统的臃肿,系统健壮性和可持续迭代的复杂度问题都会随之而来。

iframe

iframe 在微前端方案流行前,它其实是一个比较好的解决方案。不管是一些二方或是三方的接入,它都能够很好地满足需求。但它存在一个致命的问题,就是用户体验。举个常见的问题,iframe 如果不去做一些特殊处理,嵌入的页面双滚动条、路由无法同步、页面内部存在弹出遮罩交互等问题都是体验纬度上的硬伤。

框架组件

框架组件,简单来讲就是跨系统复用的公共组件。通常将一些通用的头部或吊顶中常用的逻辑封装成一个组件,然后以 npm 的形式进行维护,通过这种方式能够非常方便的将复用逻辑 / UI 提供出去。但本质上没有解决去解决技术架构持续升级和多系统用户体验优化的问题。

微前端

微前端技术架构虽然会映入一些技术的复杂度,但基于上述两种核心场景能够从体验和效率维护去寻找一个平衡点,让前端的协作模式发生改变,功能模块拆分后独立开发,独立部署,最终再集成到一个系统当中。

微前端架构

在应用架构中接入微前端,核心需要处理框架应用和微应用之前的关系。
icestark 将微前端方案中需要处理的技术细节进行屏蔽。框架应用去不用关心路由处理逻辑或者接入微应用的处理,只需要完成微应用的配置及其应用上的一些业务逻辑,比如鉴权、应用埋点等业务逻辑即可。
接下来也将针对 icestark 内部技术架构的设计进行分享。

核心概念


icestark 里面引入的核心概念,主要两个点:框架应用和微应用。

  • 框架应用就负责整体的 Layout 跟微应用配置与注册渲染。框架应用通常会有的一个通用的头部 Header,侧边栏SiderBar,除了 Layout 之外,还需要配置微应用的信息,配置中会包含微应用的核心信息,比如资源 url 和基准路由。
  • 微应用它其实就是按业务维度拆分开来的一些应用,通常来讲它可能就是一个 SPA 应用,并且会包含至少一到多个页面或路由。

微应用注册

框架应用通过 icestark 提供的 AppRouter 组件可以快速地完成微应用的挂载

 <code class="  language-jsx">
     <AppRouter>
        <AppRoute
          path={['/', '/message', '/about']}
          title="通用页面"
          url={['//unpkg.com/icestark-child-common/build/js/index.js', '//unpkg.com/icestark-child-common/build/css/index.css']}
         />
        <AppRoute
          path={['/', '/message', '/about']}
          title="通用页面"
          entry="https://ice.alicdn.com/icestark/child-common-angular/index.html"
        />
  </AppRouter>

</code>

核心配置信息中 path 代表基准路由,声明了访问路由地址是对应微应用将会被加载;url信息代表了应用的 bundle 资源;除此之外,通过 entry 的方式可以把 html 整体引入,不再需要关心页面中加载的 js 资源和 css 样式。

工作流程

引入微前端架构后的工作流程可以从两个方面发生变化

  • 微应用的开发模式。微应用开发有独立的仓库,独立的开发、测试、布署流程。开发测试部署完之后,将应用的发布产物统一注册到框架应用里面,这些产物可能是 JS bundle 或 html 资源。
  • 框架应用的整体流程,框架应用会维护微应用的注册信息。用户在访问系统的时候,根据它之前注册的路由信息,它能够精确地匹配到当前需要加载的应用信息,根据相应的信息去加载应用的资源并最终渲染应用。用户点击触发跳转的时候,如果路由变化触发的是一个内部应用跳转,那应用将会直接根据应用内部的路由逻辑渲染页面。如果涉及到一些跨应用的跳转,则又重新回到了上面路由的查找流程当中。

路由规则

微应用能按照 SPA 体验根据路由的变化进行加载,取决为 icestark 内部路由管理的设计。

icestark 里面的路由规则非常简单,接触过 react-router 的开发者不难发现两者在配置上其实是有很多相似的地方,比如 path、exact 的配置规则。
当访问框架应用页面时,icestark 内部会去做一个路由的分发。如上图中注册的三个微应用配置:

  • 访问 /seller 路由时,匹配到了第一个注册信息
  • 访问 /data 或者 /message 时,匹配到第二个注册信息
  • 访问 /seller/a 的时候,匹配到的是第三个路由

第一个注册配置中设置了 excat 属性。只有在精准匹配 /seller 路由的时候才会匹配到第一份注册信息

兜底路由

如果在微应用架构里面去设置了 path 为 / 的一个微应用,那它将整个系统的一个兜底路由,所有不匹配已注册的路由配置都会由兜底路由进行渲染。
兜底路由一般情况都会用来渲染通用页面,比如跟框架应用有比较强的耦合页面,比如登陆页面, 404 页面或者说退出登录的页面。所以实践上面我们也将兜底路由作为框架应用自身路由的渲染。

路由劫持

为了能够让 icestark 响应页面路由的变化,并对相应的微应用进行加载,icestark 对两类路由事件进行了劫持:

  • history API 中的 popstate 和 hashChange
  • window 上的路由事件 pushState 和 replaceState(通常在浏览器上进行前进后退操作的时候会触发)。

一旦应用间发生跳转,通过上述事件的劫持能够拿到对应的路由信息,再根据路由的匹配规则来决定哪个微应用进行挂载。
一个微应用可能会有多个路由设置,如果在没有发生应用间跳转的情况下,由于匹配到的是当前的微应用,所以不会再次加载资源,内部路由跳转逻辑则根据微应用自身路由配置决定渲染。

路由劫持发生的时机在整个微前端配置初始化阶段,即 AppRouter 的挂载,一旦 AppRouter 卸载对应的劫持也将会移除

应用通信

从微前端的设计原则上来说,并不希望微应用太多地去依赖框架应用或者其它微应用提供的能力。这样在微应用独立开发的时候需要额外创建一个框架应用环境,不利于技术架构的结偶和维护。
但基于一些轻量的应用场景,比如通过通信机制让框架应用和微应用的多语言设置保持一致,一旦多语言设置发生切换,微应用能够监听到这个变化。
icestark 提供了一个应用通信机制,在实际开发过程中推荐轻量的去使用,不要耦合过多的业务逻辑

@ice/stark-data 中提供了应用通信的能力,核心的实现是一个 EventBus 的机制,框架应用跟微应用之间的通讯,以 window 这样一个全局变量作为桥梁。这样不管是微应用添加的事件或数据,还是框架应用添加的事件或数据都可以访问到。

微前端隔离

icestark 在设计隔离的方案时候,有两个基础原则:

  • 首先认为研发体验是高于隔离的。假如说我引入一个完美的隔离方案,但它需要让我去做很多额外的处理逻辑,那这个方案肯定是不被接受的。无论是改造成本还是开发体验效果都会受到非常大的影响。
  • 其次是二方的场景要高于三方的场景。因为大多数微前端的应用场景,都是二方场景,一个统一的独立的系统,很少碰到要去接一个完全不受控的三方产品。而二方场景的接入其逻辑和安全性都是可控的。

基于上述的两个因素,在实践过程中并不一定说是要实现了一个完美的隔离方案之后,然后才在微前端技术架构中去使用。如果当前方案能够满足基础的业务的诉求,那就让这个方案在业务中使用。

通常微前端中的隔离场涉及两个方面:一个就是 CSS 的隔离,另一个就是 JS 的隔离。

样式隔离

样式隔离上面,推荐的方案是基于一些约定的隔离,用低成本的隔离方式,让样式之间不会相互影响。

样式隔离主要分两类:

  • 开发者自己业务代码中的样式隔离,业务代码的隔离推荐通过 CSSModule 的方式,能够自动生成 hash 后缀的样式名,基于每个不同的应用构建出来的样式,在天然上就能够做到隔离。
  • 基础组件样式隔离,大多数社区的一些基础组件,在设计上都考虑到样式前缀的替换。基础组件能够支持 CSS prefix 的方式,可以为所有样式添加一个前缀,在实践过程中将框架应用的前缀和微应用前缀进行区分,来完成样式的隔离。如果有不支持 CSS prefix 的样式,我们也能够借助社区 PostCSS 的能力给组件样式加上 namespace,框架应用跟微应用通过不同的 namespace 进行样式隔离。
shadow DOM 的方案

为什么我们没有直接去使用 shadow DOM,本质的原因还是 shadow DOM 的方案还不够开箱即用。
目前 shadow DOM 对于业务上的改造还是有一定成本和问题:

  • 比如如果使用的基础依赖的组件库,并没有设计让Dialog 等弹出层在指定的 dom 节点中插入结构的话,弹出层都是会逃离你当前的 shadow DOM。逃离之后,它就是一个无样式的弹框。这种无样式的弹框对于业务上来说是不可以接受的,因此弹框逻辑需要去做一些兼容,更甚至需要对底层组件去做改造。
  • 在 React 场景下,shadow DOM 的使用会涉及到事件机制的问题,因为React 的事件机制是代理到 document 的,但基于 shadow DOM 处理的话,它可能会阻断事件到它的 host 层,也就是你渲染 shadow DOM 的那一层。虽然说社区也有对应的包去做一些兼容处理,但它对业务上来说还是会有一些实现成本。
  • 除此之外还包括其它的问题。比如 CSS @font-face,或者说一些字体属性,svg 都会有一个不兼容的场景。

虽然 shadow DOM 问题还是比较多,但是接下来 icestark 也会这方面继续探索、逐渐完善,争取提供出一个开箱即用的方案,达到启用 shadow DOM 能够没有太大改造的成本。

脚本隔离

多个应用的 bundle 多次执行的时候很容易对全局变量造成污染,特别是代码中出现对于 window 全局变量的依赖。
icestark 中通过 proxy 的沙箱机制实现了脚本的隔离。
Prxoy 沙箱的基本原理是通过 with + new Function 的形式阻断代码中对于 window 的直接访问,并通过 Proxy 的方式拦截对于 window 变量的访问和写入,沙箱的隔离使代码不能直接访问到 window 对象,通过ES6 的新特性 Proxy 可以定制 get/set 的逻辑,这样就能对 window 上的一些全局变量变化进行快照记录,以便微应用切换的时候进行恢复。
另外像一些应用初始化时,会在 window 上面设置 setTimeout、setInterval,如果在卸载阶段没有很好的处理,将会影响到下一个挂载微应用的执行。所以在沙箱中针对这类方法进行了特殊处理,在沙箱挂载前对相应的方法进行劫持,在卸载的时候,再对它进行恢复。

三方隔离

对于不授信的三方最简单最安全的隔离方式是 iframe。在 icestark 中可以简单定义好基准路由 path ,再通过自定义渲染的方法 render 将 iframe 相关的内容渲染出来。

微模块能力

微模块的能力其实是对微前端方案的一个补充,通常一个微模块并不会耦合路由,在一个页面中可以随意组合和挂载。它的应用场景主要有以下两种模式:

  • 模块共存问题:常见为微前端的技术体系下面去实现一个多 tab 方案。设想一下,最低成本的一个解决方案会是什么样的,是不是让一个模块能够在不同的位置正常渲染就行了?
  • 模块动态组合:一个页面里面会有信息模块,表单模块,以及列表模块。在一些对外输出复用的场景中,如果直接接入整个页面,其通用性并不是特别强,但如果各个模块能够进行自由组合,就可以按需组合出不同需求的页面

微模块架构

icestark 对于微模块的应用场景上会有一个明确的定义,微模块其实是不会再去耦合路由的。之前提到的微应用的内部基本上是一个 SPA 它至少有一个路由或者是一个页面,但是微模块的使用上我们希望尽量简单,因为一旦多个模块都大量耦合路由的话,这会使路由处理变得复杂。
在模块的标准上面,微模块是以 UMD 的方式直接打包,通过这种标准模式打包,即便是以 npm 包的形式也可以正常使用。在微模块内部除了默认导出模块方法外,还需要定义挂载(mount)和卸载(unmount)的生命周期。
微模块的应用场景其实是对微应用的一个补充,它更适用于更加细粒度的功能拆分和动态搭建的场景。

微模块挂载

根据模块资源在执行的位置渲染模块:

 <code class="  language-jsx">
   
   import { MicroModule } from '@ice/stark-module';

   const App = () => {
        const moduleInfo = {
           name: 'moduleName',
      		url: 'https://localhost/module.js',
        }
        return <MicroModule moduleInfo={moduleInfo} />;
  }
</code>

通过组件方式的挂载,将微模块渲染至指定位置,而上层的切换展示逻辑均由业务进行控制。

总结

微前端的技术架构,它给大型单体应用场景和工作台场景带来技术架构优化的方案,通过引入微前端的技术架构去解决当前系统遇到的问题和瓶颈,也能给上古代码找到一个重新焕发生机的机会。
同时结合微模块的方案,让基于标准化模块的上层方案有更大的想象空间,未来面向的场景和方案也将更加丰富。

如果对于飞冰的前端架构方案和微前端的技术架构有兴趣的,如果项目对你有帮助,欢迎关注 star ICE 技术架构:
https://github.com/ice-lab/icestark
https://github.com/alibaba/ice