微前端技术原理

微前端之背景与实践

Posted by Li Yucang on August 5, 2023

微前端技术原理

微前端最早于2016年在 Micro-Frontends 被提出,并建⽴了早期的微前端模型。微前端的命名和能⼒和微服务有类似之处,微服务与微前端,都是希望将某个单⼀的单体应⽤,转化为多个可以独⽴运⾏、独⽴开发、独⽴部署、独⽴维护的服务或者应⽤的聚合,从⽽满⾜业务快速变化及分布式多团队并⾏开发的需求。

如康威定律(Conway’s Law)所⾔,设计系统的组织,其产⽣的设计和架构等价于组织间的沟通结构;微服务与微前端不仅仅是技术架构的变化,还包含了组织⽅式、沟通⽅式的变化。微服务与微前端原理和软件⼯程,⾯向对象设计中的原理同样相通,都是遵循单⼀职责(Single Responsibility)、关注分离(Separation of Concerns)、模块化(Modularity)与分⽽治之(Divide & Conquer)等基本的原则。

微前端是什么

⼀种类似于微服务的架构,是⼀种由独⽴交付的多个前端应⽤组成整体的架构⻛格,将前端应⽤分解成⼀些更⼩、更简单的能够独⽴开发、测试、部署的应⽤,⽽在⽤户看来仍然是内聚的单个产品。

微前端是一种架构理念,它将较大的前端应用拆分为若干个可以独立交付的前端应用。这样的好处是每个应用大小及复杂度相对可控。在合理拆分应用的前提下,微前端能降低应用之间的耦合度,提升每个团队的自治能力。

目前市面有各类不同的微前端方案,但没有完美的解决方案。一个完善的微前端框架应该具备哪些能力呢?

  • 子应用的加载和卸载能力

页面需要从一个子应用切换到另一个子应用,框架必须具备加载、渲染、切换的能力

  • 子应用独立运行的能力

子应用运行会污染全局的 window 对象,样式会污染其他应用,必须有效的隔离起来

  • 子应用路由状态保持能力

激活子应用后,浏览器刷新、前进、后退子应用的路由都应该可以正常工作

  • 应用间通信的能力

应用间可以方便、快捷的通信

而从技术实现角度,微前端架构解决方案大概分为两类场景:

  • 单实例:即同一时刻,只有一个子应用被展示,子应用具备一个完整的应用生命周期。通常基于 url 的变化来做子应用的切换。

  • 多实例:同一时刻可展示多个子应用。通常使用 Web Components 方案来做子应用封装,子应用更像是一个业务组件而不是应用。

微前端的价值

微前端的核心思想是让应用之间技术栈无关,足够的无关,才叫「微前端」。微前端的主要特点如下:

  • 低耦合:当下前端领域,单⻚⾯应⽤(SPA)是⾮常流⾏的项⽬形态之⼀,⽽随着时间的推移以及应⽤功能的丰富,单⻚应⽤变得不再单⼀⽽是越来越庞⼤也越来越难以维护,往往是改⼀处⽽动全身,由此带来的发版成本也越来越⾼。微前端的意义就是将这些庞⼤应⽤进⾏拆分,并随之解耦,每个部分可以单独进⾏维护和部署,提升效率。

  • 不限技术栈:在不少的业务中,或多或少会存在⼀些历史项⽬,这些项⽬⼤多以采⽤⽼框架类似(Backbone.js,Angular.js 1)的B端管理系统为主,介于⽇常运营,这些系统需要结合到新框架中来使⽤还不能抛弃,对此我们也没有理由浪费时间和精⼒重写旧的逻辑。⽽微前端可以将这些系统进⾏整合,在基本不修改来逻辑的同时来同时兼容新⽼两套系统并⾏运⾏。

如果用一句话来形容微前端的价值,我称之为:

因「无关」而「效率」

微前端解决应用在研发、维护、升级、变迁时因耦合而造成的维护性降低的问题,在超大型前端应用中尤为明显。

试想一下,如果一个巨型的 React(15) 单页应用,需要升级到 React(16) 的版本,那必定会迎来一波腥风血雨,做过此类不兼容升级工作的小伙伴一定知道心里的苦。

但如果是这个应用是微前端架构,完全可以保持基座依然是 React(15),先将 A 子应用升级到 React(16),既可以降低升级风险,还可以平稳过度。同理,这个机制在新技术实践的时候也适用,例如如果想试用 Vue3,就可以让 B 子应用使用 Vue3 开发,和 React 基座完成契合。甚至如果有一个 5 年前的项目 C 应用,突然想接入到我们系统里,按照传统的方式我们需要对这个 5 年前的项目做一次痛苦的改造,但按照微前端的模式他只需要在做一点接入相关的开发,即可与基座和 A、B 子应用融合。

微前端使用场景

前端工程化中,一个前端项目常以组件或模块的粒度进行代码拆分,然后通过 script 标签、npm 包、submodules 或者动态加载(Dynamic import)等形式将代码集成到项目中。而微前端则是以更大的粒度对代码进行上下文划分,将较庞大的应用拆分成多个技术栈独立的应用,再通过技术手段将若干应用集成在一个容器内。

如果项目中存在以下问题,可参考微前端架构进行优化:

  • 存量系统如何渐进式地拥抱新技术:存量系统的技术栈老旧,重构和开发成本高。在做新的功能开发时可以考虑采用与老项目不同的技术栈,通过微前端的方案将新的功能与老系统进行集成。同时微前端架构也给老旧系统的技术升级和平滑迁移提供保障。

  • 大型系统的开发及沟通成本上升:通过分析业务功能,将系统拆分成多个独立子系统,使每个子系统能独立开发、运行及部署。将工程复杂度拆分并限制在子系统单元内。避免随需求迭代,项目维护成本增大,跨部门沟通困难导致效率低下等问题。

举例:

  • 比如制作一个企业管理平台,把已有的采购系统和财务系统统一接入这个平台;

  • 比如有一个巨大的应用,为了降低开发和维护成本,分拆成多个小应用进行开发和部署,然后用一个平台将这些小应用集成起来;

  • 又比如一个应用使用vue框架开发,其中有一个比较独立的模块,开发者想尝试使用react框架来开发,等模块单独开发部署完,再把这个模块应用接回去

为什么不用 iframe

采用iframe的方案确实可以做到,而且优点非常明显:

  • 非常简单,使用没有任何心智负担

  • web应用隔离的非常完美,无论是js、css、dom都完全隔离开来

由于其隔离的太完美导致缺点也非常明显:

  • 路由状态丢失,刷新一下,iframe的url状态就丢失了

  • dom割裂严重,弹窗只能在iframe内部展示,无法覆盖全局

  • web应用之间通信非常困难

  • 每次打开白屏时间太长,对于SPA 应用来说无法接受

这些问题有的可以曲线解决,有的没法解决,这些没法解决的问题会直接影响交互体验,有时这些影响是不被接受的。这促使人们开始寻找新的道路,有没有一种方案可以「形如 iframe 却非 iframe」呢?

从目前的发展趋势来看,“让子应用能够像 iframe 一样简单接入,消除 iframe 的那些缺点” 是微前端架构不断在攻坚的目标之一。

微前端核心实现

应用接入原理

如果把基座看成是一个架子,子应用是架子里内容的一部分。基座和子应用都是单独部署的两个地址,那么第一个问题:

基座是如何加载子应用的?

在主流微前端框架中,加载的方式一般分为两种:

  • JS Entry - 子应用打包时将所有资源(html、css、js)全部打包成一个 js 文件。基座通过加载这个 js 加载子应用。

  • HTML Entry - 子应用按照原有的模式打包,基座通过加载应用的入口文件(index.html),然后再加载入口文件中的页面资源,最终汇总成 html、css、js 资源再加载到页面。

JS Entry 缺点:

  • 需将子应用的所有资源(包括 css、图片等资源)打成一个Entry Script,会失去 css 提取、静态资源并行加载、首屏加载(体积巨大)等优化。

  • 子应用更新打包后的 js bundle 名称会变化,主应用需要保证每次获取都是最新的 js bundle。

  • 主应用为子应用预留的容器 id 还需与子应用容器保持一致。

相比之下HTML Entry,子应用地址只需配一次,子应用的信息可以得到完整的保留。你只需要指定子应用的 html 入口即可,微前端框架在加载 html 字符串后,从中提取出 css、js 资源,运行子应用时,安装样式、执行脚本,运行脚本中提供的生命周期钩子。因此优点也很明显:

  • 无需关心应用打包后的 js 名称变化的问题。

  • 仍然可以享受 css提取、静态资源并行加载(内部使用 Promise.all 并行发出请求资源)、首屏加载等优化。

  • 请求资源时,自动补全资源路径。

那么怎么实现 HTML Entry 的呢

解析 html 字符串的流程如下:

主要做了这些事情:

1、加载 entry html (index.html) 的内容到内存。

2、将 entry html 中的 css、js、link 等标签下的内容获取出来(包含外部的和内联的),整理成网页所需的 js、css 列表。并将无用标签去掉(例如注释、ignore 等)。

3、加载所有外链 js 脚本,并将这些外链 js 和内联 js 一起整理为 script list。

4、加载所有外链 css 文件,并将其以内联(<style/>)的方式插入到 entry html 中。

5、将处理后的 entry html 和待执行的 script list 返回给调用方(基座)。

在处理完之后,基座在需要的加载子应用时候将这个 html 放到对应的 DOM 容器节点,并执行 script list,即完成子应用的加载。

不难发现该流程中没有马上执行 script 标签内的脚本,这是因为 script 标签内的脚本需要等到 JS 沙箱创建完后才执行,JS 沙箱相关内容将在后面的小节介绍。

同时为了满足丰富的实际场景,还会提供预加载和按需加载两种策略,以供不同的场景使用,开启预加载之后,基座会在浏览器空闲时加载其余子应用,反之,只会在需要显示子应用的时候加载。

到这里,你可能会有第二个问题

上文只是说如何将应用加载到页面上,并没有说怎么建立关系,基座和子应用之间是如何建立关系的呢?

微前端架构下,我们需要获取到子应用暴露出的一些钩子引用,如 bootstrap、mount、unmout 等(参考 single-spa),从而能对接入应用有一个完整的生命周期控制。而由于子应用通常又有集成部署、独立部署两种模式同时支持的需求,使得我们只能选择 umd 这种兼容性的模块格式打包我们的子应用。如何在浏览器运行时获取远程脚本中导出的模块引用也是一个需要解决的问题。

通常我们第一反应的解法,也是最简单的解法就是与子应用与主框架之间约定好一个全局变量,把导出的钩子引用挂载到这个全局变量上,然后主应用从这里面取生命周期函数。

这个方案很好用,但是最大的问题是,主应用与子应用之间存在一种强约定的打包协议。那我们是否能找出一种松耦合的解决方案呢?

很简单,我们只需要走 umd 包格式中的 global export 方式获取子应用的导出即可,大体的思路是通过给 window 变量打标记,记住每次最后添加的全局变量,这个变量一般就是应用 export 后挂载到 global 上的变量。实现方式可以参考 systemjs global import,这里不再赘述。

// main.ts
export async function bootstrap() {
  console.log("[vue] vue app bootstraped");
}

export async function mount(props: any) {
  console.log("[vue] props from main framework", props);
  render(props);
}

export async function unmount() {
  instance.$destroy();
  instance.$el.innerHTML = "";
}
// vue.config.js
configureWebpack: {
        output: {
            // 把子应用打包成 umd 库格式
            library: `${name}-[name]`,
            libraryTarget: 'umd',
            jsonpFunction:`webpackJsonp_${name}`,
        },
    },

在上文最后,我们在加载子应用时有提到“执行 script list” 的步骤,这个步骤在执行完毕之后,就会检测该模块是否导出这三个生命周期函数,如果导出了,则认为这是一个子应用。

这三个生命周期函数分别的含义是:

  • bootstrap 应用被装载。在应用首次初始化完成后触发,上文有提到 “执行 script list”,script list 执行完成后,即开始执行 bootstrap 函数。注意,此时应用只是被初始化,并不代表马上将要被显示。

  • mount 应用被挂载。在应用需要显示时触发,此时,基座已经为子应用分配了 DOM 节点,然后调用 mount 函数。子应用在 mount 函数中可以根据基座分配的 DOM 节点,将子应用的内容渲染进去。

  • unmount 应用被卸载。在子应用被关闭时触发,子应用需要在此函数中做一些清理操作。

需要注意的是,bootstrap 函数只会执行一次,而 mount 和 unmount 会根据基座对子应用的控制可能会执行多次。

qiankun 在加载子应用时,会为 window 注入标志性变量 window.__POWERED_BY_QIANKUN__,子应用通过此标志来决定在基座中的加载逻辑和独立运行的加载逻辑,从而实现子应用可以在基座内外均可独立运行。

路由系统的设计

在于现代 MVC 的设计思想,前端框架的设计思想也一直在发生变更,现代 Web 前端框架提供的最经典的能力莫过于将 MVC 中的 Constroller 变为了 Router,目前几乎主流的前端框架都支持路由驱动视图,仅提供一个 Router Map 路由表,无需关注控制任何路由状态即可完成跳转后的路由更新。

通过微前端出现的背景和意义,可以了解到微前端主要是用于解决:应用增量升级、多技术体系并存、构建大规模企业级 Web 应用而诞生的。那么在基于 SPA 的微前端架构中也可以了解到,目前微前端主要是采用应用分而治之 + 动态加载 + SPA 应用的模式来解决大规模应用带来的一系列问题。在以组件为颗粒度的 SPA 应用中组件内部是不需要关心路由的,但是在微前端中主要通过应用维度来拆分,那么拆分的应用也可能是一个独立的 SPA 应用,那么此时主应用与子应用的关系如何编排呢?

Hash 和 History 路由模式 目前主流的 SPA 前端应用基本上都支持两种路由模式,一种是:hash 模式、另一种则是 History 路由模式,两者的优劣和使用并不在本文的讨论范围之内,这里仅做在微前端这种分离式开发模式下的介绍,在微前端这种分离式 SPA 应用开发的模式下该选择哪种路由模式,以及多 SPA 应用下他们的路由应该如何编排:

假设站点地址为:http://www.lyc.com

正常路由情况

  • 主应用 history 模式、子应用 history 模式

    • 主应用(basename: /example):

      • 主应用所有路由基于:http://www.lyc.com/example

      • 例如跳转到:/appA,http://www.lyc.com/example/appA/

    • 子应用(basename: /example/appA):

      • 子应用所有路由基于:http://www.lyc.com/example/appA

      • 跳转到子应用的 /detail 页,http://www.lyc.com/example/appA/detail

    • 特点:

      • 当主子应用分别为 history 模式时,子应用的路由基于主应用基础路由并带上自己的业务路由

      • 路由同步到主应用路由上,通过 子应用 scope 命名空间隔离(子应用 A,提供 appA 的 scope)主应用和其他应用的路由冲突,并将子应用

      • 路径符合用户和开发者认知和理解

      • 支持嵌套层级使用,并继续通过 scope 的命名空间保证路由可读

  • 主应用 history 模式、子应用 hash 模式

    • 主应用(basename: /example)

      • 主应用所有路由基于:http://www.lyc.com/example

      • 例如跳转到:/appA,http://www.lyc.com/example/appA/

    • 子应用(basename: /example/appA):

      • 子应用所有路由基于:http://www.lyc.com/example/appA

      • 从主应用:http://www.lyc.com/example/appA,跳转到子应用的 /detail 页,http://www.lyc.com/example/appA#/detail

    • 特点:

      • 在一定程度上具备主子应用都为 history 模式的优势,不支持嵌套层级使用

      • 可读性尚可

异常路由情况

  • 主应用 hash 模式、子应用 history 模式

    • 主应用(basename: /example)

      • 主应用所有路由基于:http://www.lyc.com/example

      • 例如跳转到:/detail,http://www.lyc.com/example#/appA

    • 子应用(basename: /example#/appA):

      • 子应用所有路由基于:http://www.lyc.com/example#/appA

      • 跳转到子应用的 /detail 页,http://www.lyc.com/example/detail#/appA

    • 特点:

      • 「路由混乱」,不符合用户和开发者直觉

      • 目前多数框架都不支持以 hash 值作为 basename

  • 主应用 hash 模式、子应用 hash 模式

    • 主应用(basename: /example)

      • 主应用所有路由基于:http://www.lyc.com/example

      • 例如跳转到:/detail,http://www.lyc.com/example#/appA

    • 子应用(basename: /example#/appA):

      • 子应用所有路由基于:http://www.lyc.com/example#/appA

      • 跳转到子应用的 /detail 页,http://www.lyc.com/example#/detail

    • 特点:

      • 「路由混乱」,不符合用户和开发者直觉

      • 目前多数框架都不支持以 hash 值作为 basename

      • 可能与主应用或其他子应用发生路由冲突

通过上面理想的路由模式案例发现,微前端应用拆分成子应用后,子应用路由应具备自治能力,可以充分的利用应用解耦后的开发优势,但与之对应的是应用间的路由可能会发生冲突、两种路由模式下可能产生用户难以理解的路由状态、无法激活不同前端框架的下带来的视图无法更新等问题。 目前主要有以下策略:

  • 提供 Router Map,减少典型中台应用下的开发者理解成本

  • 为不同子应用提供不同的 basename 用于隔离应用间的路由抢占问题

Router Map 降低开发者理解成本

在典型的中台应用中,通常可以将应用的结构分为两块,一块是菜单另一块则是内容区域,依托于现代前端 Web 应用的设计理念的启发,通过提供路由表来自动化完成子应用的调度,将公共部分作为拆离后的子应用渲染区域。

自动计算出子应用所需的 basename 当应用处于激活状态时,根据应用的激活条件自动计算出应用所需的基础路径,并在渲染时告诉框架,以便于应用间路由不发生冲突。

隔离的原理

支持样式隔离和 JS 沙箱机制,以保证应用之间的样式或全局变量、事件等互不干扰。在应用卸载时,应当对子应用中产生的事件、全局变量、样式表等进行卸载。

JS 隔离

js 隔离的核心是在基座和子应用中使用不同的上下文 (global env),从而达成基座和子应用之间 js 运行互不影响。

简单来说,就是给子应用单独的 window,避免对基座的 window 造成污染。

LegacySandbox

legacy沙箱的主要原理是使用了ES6中的Proxy,把原来的window代理到fakeWindow上,这样就不用遍历整个window去应用和恢复环境了。

除此之外,它还在沙箱内部设置了三个变量池:addedPropsMapinSandbox用于存放子应用运行期间新增的全局变量,用于在卸载子应用的时候删除;modifiedPropsOrginalMapInSandbox用于存放子应用运行期间修改的全局变量,用于卸载时进行恢复;currentUpdatedPropsValueMap用于存放子应用运行期间所有变化的变量,这样可以在加载子应用时恢复其上一次的环境。

下面是具体实现的沙箱demo:

class legacySandBox {
            constructor() {
                this.addedPropsMapInSandbox = new Map();//记录子应用运行期间新增的key
                this.modifiedPropsOriginalValueMapInSandbox = new Map();//记录子应用运行期间修改的key
                this.currentUpdatedPropsValueMap = new Map();//记录子应用运行期间的值
                this.sandboxRunning = false;
                const _this = this;
                const fakeWindow = new Proxy(window, {
                    set(_, p, value){
                        if(_this.sandboxRunning){
                            if(!window.hasOwnProperty(p)){
                                _this.addedPropsMapInSandbox.set(p, value);
                            } else if(!_this.modifiedPropsOriginalValueMapInSandbox.has(p)){
                                const originalValue = window[p];
                                _this.modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
                            }
                            _this.currentUpdatedPropsValueMap.set(p, value);
                            window[p] = value;
                            return true;
                        }
                        return true;
                    },
                    get(_, p){
                        if(p === "top" || p === "window" || p === "self"){
                            return proxy;
                        }
                        return window[p];
                    }
                })
                return {sandbox:this,fakeWindow};
            }
            active() {//激活
                if(!this.sandboxRunning){
                    this.currentUpdatedPropsValueMap.forEach((v, p) => this.setWindowProp(p, v));
                }
                this.sandboxRunning = true;
            }
            inactive() {//卸载
                this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => this.setWindowProp(p, v));
                this.addedPropsMapInSandbox.forEach((v, p) => this.setWindowProp(p, undefined, true));
                this.sandboxRunning = false;
            }
            setWindowProp(p,v){
                window[p] = v;
            }
        }

缺点:虽然子应用之间的状态是隔离的,但是父子应用都会修改同一个 window 对象,互相污染。

ProxySandbox

LegacySandbox沙箱的实现对于单例模式来说已经比较完善了,但是不适用于多例模式,即同时有多个子应用在运行期间的时候,qiankun针对这个问题提出来proxySandbox。

proxySandbox依然是使用proxy代理window,但不同的是对于每个子应用都代理了一个fakeWindow,这样在查找变量的时候在本地的fakeWindow上查找,如果没有找到就到主应用的window上查找,而修改时只修改本地的fakeWindow,不会影响到其他的应用,在最终卸载时把fakeWindow删除即可。

SnapshotSandbox

快照沙箱,不支持 Proxy 时使用此沙箱。沙箱快照的核心思想如下:在子应用挂在前对当前主应用的全局变量保存,然后恢复之前的子应用环境,在子应用运行期间则正常get和set,在卸载时保存当前变量恢复主应用变量,整个过程类似于中断和中断恢复。

具体代码可参考这个demo,但这里也有一个比较明显的缺点就是每次切换时需要去遍历window,这种做法会有较大的时间消耗。

class SnapshotSandbox {
            constructor() {
                this.proxy = window; //window属性
                this.modifyPropsMap = {}; //记录在window上的修改
            }
            active() {//激活
                this.windowSnapshot = {}; //快照
                for (const prop in window) {
                    if (window.hasOwnProperty(prop)) {
                        this.windowSnapshot[prop] = window[prop]
                    }
                    Object.keys(this.modifyPropsMap).forEach(p => {
                        window[p] = this.modifyPropsMap[p]
                    })
                }
            }
            inactive() {//卸载
                for (const prop in window) {
                    if (window.hasOwnProperty(prop)) {
                        if (window[prop] != this.windowSnapshot[prop]) {
                            this.modifyPropsMap[prop] = window[prop];
                            window[prop] = this.windowSnapshot[prop];
                        }
                    }
                }
            }
        }

这种方式的缺点在于无法支持多个实例。

注:样式隔离、JS 隔离都在会子应用 mount 前,bootstrap 时处理。

快照沙箱和 VM 沙箱能力对比

劫持全局方法

除了 JS 沙箱,还需要劫持一些全局方法,如 计时器、 window 事件监听、window.history 事件监听、动态向 Head/Body元素添加子元素方法(如 appendChild、insertBefore)。

这里重点介绍一下 计时器劫持 和 动态添加子元素方法的劫持:

1、计时器劫持

我们知道,setInterval() 调用后会返回一个非零数值,用来标识通过setInterval()创建的计时器,这个值可以用来作为clearInterval()的参数来清除对应的计时器 。

因此,原理很简单:

1、在子应用运行时调用 setInterval(),可以把返回的定时器标识收集在一个数组中(假设数组名叫 intervals),在子应用失活时通过调用 clearInterval() 取消这些定时器;

2、在子应用运行时调用 clearInterval(某个定时器标识),从intervals中删除该定时器标识。

2、动态添加子元素方法的劫持

主要劫持了:

  • HTMLHeadElement.prototype.appendChild

  • HTMLHeadElement.prototype.removeChild

  • HTMLBodyElement.prototype.appendChild

  • HTMLBodyElement.prototype.removeChild

  • HTMLHeadElement.prototype.insertBefore

  • Document.prototype.createElement

以上劫持仅处理动态添加的 link、style、script 标签。

createElement

子应用调用 createElement,会触发 sandbox proxy 对象上的 get 拦截器,从而可以判断某个元素是否由子应用创建,如果是,这个元素才可以被 appendChild、insertBefore 的劫持处理。

appendChild、insertBefore

添加 link、style 标签:

会做 css 隔离相关处理(下一小节介绍)。如果是 link 标签,会使用 fetch 获取到样式表内容字符串,外包一层 style 标签,插入进子应用的 DOM 中。动态添加的样式标签会被收集起来 (假设收集到一个名为 dynamicStyleSheetElements 的数组中)。

添加 script 标签:

1、对于外部 script 脚本,先使用 fetch 获取到脚本内容字符串。

2、得到脚本后,指定 js 沙箱的 proxy 对象为全局对象,执行脚本内容,同时触发 load 和 error 两个事件。

3、将 script 标签以注释的形式添加到子应用容器中。

removeChild

判断如果是子应用创建的link、style、script 标签,就从子应用容器中移除它们。

CSS 隔离

要想做到子应用和基座之间的样式不会相互干扰,首先要做的就是样式隔离。

样式约定 + 工程化

样式约定 + 工程化,这种方案非常简单可靠,各个子应用都约定自己的特有前缀,然后通过前端工程化工具 Webpack,在编译期对整体项目的样式做统一前缀处理

对于新的项目,做好样式隔离的方式包括采用 CSS Module、CSS in JS 或规范使用命名空间等。对于已有项目的 CSS 隔离,可以在打包阶段利用工具(如 postcss)自动对样式添加前缀。

CSS-in-JS,Vue 的 scoped style,会生成随机的 id/class 属性,避免样式污染

主要分两类:

1、开发者自己业务代码中的样式隔离,业务代码的隔离推荐通过 CSSModule 的方式,能够自动生成 hash 后缀的样式名,基于每个不同的应用构建出来的样式,在天然上就能够做到隔离。

2、基础组件样式隔离,大多数社区的一些基础组件,在设计上都考虑到样式前缀的替换。基础组件能够支持 CSS prefix 的方式,可以为所有样式添加一个前缀,在实践过程中将框架应用的前缀和微应用前缀进行区分,来完成样式的隔离。如果有不支持 CSS prefix 的样式,我们也能够借助社区 PostCSS 的能力给组件样式加上 namespace,框架应用跟微应用通过不同的 namespace 进行样式隔离。

Dynamic Stylesheet(动态样式表)

动态的加载和卸载样式表。在应用切出/卸载后,同时卸载掉其样式表。

原理:浏览器会对所有的样式表的插入、移除做整个 CSSOM 的重构,从而保证了在一个时间点里,只有一个应用的样式表是生效的。(上面提到的 HTML Entry 方案则天生具备样式隔离的特性,因为应用卸载后会直接移除去 HTML 结构,从而自动移除了其样式表)

问题:可以确保子应用之间的样式冲突,但子应用和主应用之间的冲突是无法避免,只有通过手动的方式确保,比如给主应用所有样式添加一个前缀。(但在实践中,大多数主应用可能只提供一个头部,侧边栏的组件)

Shadow DOM

将微应用插入到创建好的 shadow Tree 中,微应用的样式(包括动态插入的样式)都会被挂载到这个 shadow Host 节点下,最终整个应用的所有 DOM 都会被绘制成一颗shadow tree。

原理:Shadow DOM内部所有节点的样式对树外面的节点是无效的,因此微应用的样式只会作用在 Shadow Tree 内部,自然就实现了样式隔离。

问题:

1、比如如果使用的基础依赖的组件库,并没有设计让Dialog 等弹出层在指定的 dom 节点中插入结构的话,弹出层都是会逃离你当前的 shadow DOM。逃离之后,它就是一个无样式的弹框。这种无样式的弹框对于业务上来说是不可以接受的,因此弹框逻辑需要去做一些兼容,更甚至需要对底层组件去做改造。

2、在 React 场景下,shadow DOM 的使用会涉及到事件机制的问题,因为React 的事件机制是代理到 document 的,但基于 shadow DOM 处理的话,它可能会阻断事件到它的 host 层,也就是你渲染 shadow DOM 的那一层。虽然说社区也有对应的包去做一些兼容处理,但它对业务上来说还是会有一些实现成本。

3、除此之外还包括其它的问题。比如 CSS @font-face,或者说一些字体属性,svg 都会有一个不兼容的场景。

Runtime css transformer

这种模式通过运行时为 css 选择器添加 [data-…] 限制,从而实现样式的隔离,这种模式可以做到应用内的样式不会影响到外部。这种方案虽然可行,但是在运行时动态的增加、修改、删除样式,会在性能上有一定的消耗。

1、创建子应用容器节点后,通过 document.querySelectorAll(‘style’) 找到所有 style 元素

2、对于普通样式规则,通过 for 循环遍历 style.sheet.cssRules,转换 css 样式。

// 假如子应用名字叫 child
// 转换前
.app-main {
    font-size: 14px;
}
// 转换后
div[data-qiankun="child"] .app-main {
    font-size: 14px;
}

3、对于@media、@supports

// 转换前
@media screen and (max-width: 300px) {
    div {
        font-size: 14px
    }
}

// 转换后
@media screen and (max-width: 300px) {
    div[data-qiankun="child"] div {
        font-size: 14px
    }
}

4、对于每个 style 元素,注册一个 MutationObserver 监听元素的变化,变化的时候对元素内容也做上述转换

const mutator = new MutationObserver((mutations) => {
  for (let i = 0; i < mutations.length; i += 1) {
    const mutation = mutations[i];
    if (mutation.type === 'childList') {
        // 转换操作
    }
  }
});
mutator.observe(styleNode, { childList: true });

注:对于通过动态 appendChild 到 head 或 body 元素 / 动态 insertBefore 到 head 元素的样式节点,也会做上述转换。

再说一点

咋眼一看,你一定会觉得,后两种方案更好,能够做到完全隔离,而动态载入 CSS 会影响基座,但为什么 qiankun 默认的是动态载入 CSS 方案呢?

当然还是因为体验的原因,我们在 UI 开发的过程中,会使用到类似「模态框」、「引导框」这样的组件,这样的组件往往需要直接挂载到 body 下,对网页整体加上蒙版什么的,如果我们使用后两种方案,那么子应用就只能在子应用内部玩,对子应用外的样式全部无效,弹框之类的也会变得很丑,还可能有 bug。如下图:

而我们希望的样式:

可见,没有遮罩的弹框显得索然无味。

消息通信

微前端通常不会限制应用采用的框架,如何在不同的应用,框架之间进行通信是一个需要仔细考量的决定。

合理划分应用,可以避免频繁的跨应用通信。同时应当避免子应用之间直接通信。

下面列出了一些常见的跨应用通信方法

自定义事件

浏览器自带有 CustomEvent ,它采用发布/订阅模式的设计,我们可以通过它创建一个自定义事件、监听事件并触发这个事件,达到应用之间的数据通讯:

// 创建事件
let event = new CustomEvent('event-a', {
  detail: 'hello'
});
// 监听事件
document.addEventListener('event-a', function(e){
  console.log(e.detail);
})
// 触发事件
document.dispatchEvent(event);

发布-订阅

通过发布-订阅模式实现通信

import { Observable } from 'windowed-observable';

const observable = new Observable('konoha');
observable.subscribe((ninja) => {
  console.log(ninja)
})

observable.publish('Uchiha Shisui');

Web Workers

通过 Web Workers 进行事件通信

import Worky from 'worky'
const worker = new Worky("some-worker.js");

worker.on("eventName", function (some, data) {
  // 处理
});
worker.emit("someEvent", and, some, data);

共享状态

主应用创建 state store,共享给子应用使用,适用于主、子应用技术栈相同的场景。

依赖管理

常见的微前端框架中,基座应用统一对子应用的状态进行管理。根据路由和子应用状态,按需触发生命周期函数,做请求加载、渲染、卸载等动作。而多个子应用间可能存在一些公共库的依赖。

为减少这类资源的重复加载,通常可以借助 webpack5 的 Module Federation 在构建时进行公共依赖的配置,实现运行时依赖共享的能力。除了使用打包工具的能力,也可以从代码层面通过实现类 external 功能对公共依赖进行管理。

子应用预加载

在第一小节介绍 Html Entry 的时候,我们知道解析 html 包含了几个步骤:通过 fetch 获取 html 字符串、将外部样式表 url 放入 styles 数组中、将内联和外部 js 放入 scritps 数组中。

因此,预加载的时候可以通过遍历需要预加载的应用对应的 styles 和 scripts 数组,在 requestIdelCallback 中使用 fetch 获取其中的外部资源,并将它们缓存下来。这样在下次获取这些资源时,就能从缓存中直接拿到。

值得一提的是,这里所说的“缓存”是指存在代码中的全局变量中,所以,即使将 Chrome Devtools 中 Network 的 Disable cache 选项打开,只要资源预加载成功、页面没有重新刷新,也是会走“缓存”。

业界方案

single-spa

single-spa是一个很好的微前端基础框架,而qiankun框架就是基于single-spa来实现的,在single-spa的基础上做了一层封装,也解决了single-spa的一些缺陷。

首先我们先来了解该如何使用single-spa来完成微前端的搭建。

Single-spa实现原理

single-spa主要实现思路:

  • 预先注册子应用(激活路由、子应用资源、生命周期函数)

  • 监听路由的变化,匹配到了激活的路由则加载子应用资源,顺序调用生命周期函数并最终渲染到容器

有了理论基础,接下来,我们来看看代码层面时如何使用的。

以下以Vue工程为例基座构建single-spa,在Vue工程入口文件main.js完成基座的配置。

基座配置

//main.js
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import { registerApplication, start } from 'single-spa'

Vue.config.productionTip = false

const mountApp = (url) => {
  return new Promise((resolve, reject) => {
    const script = document.createElement('script')
    script.src = url

    script.onload = resolve
    script.onerror = reject

    // 通过插入script标签的方式挂载子应用
    const firstScript = document.getElementsByTagName('script')[0]
    // 挂载子应用
    firstScript.parentNode.insertBefore(script, firstScript)
  })
}

const loadApp = (appRouter, appName) => {

  // 远程加载子应用
  return async () => {
    //手动挂载子应用
    await mountApp(appRouter + '/js/chunk-vendors.js')
    await mountApp(appRouter + '/js/app.js')
    // 获取子应用生命周期函数
    return window[appName]
  }
}

// 子应用列表
const appList = [
  {
    // 子应用名称
    name: 'app1',
    // 挂载子应用
    app: loadApp('http://localhost:8083', 'app1'),
    // 匹配该子路由的条件
    activeWhen: location => location.pathname.startsWith('/app1'),
    // 传递给子应用的对象
    customProps: {}
  },
  {
    name: 'app2',
    app: loadApp('http://localhost:8082', 'app2'),
    activeWhen: location => location.pathname.startsWith('/app2'),
    customProps: {}
  }
]

// 注册子应用
appList.map(item => {
  registerApplication(item)
})
 
// 注册路由并启动基座
new Vue({
  router,
  mounted() {
    start()
  },
  render: h => h(App)
}).$mount('#app')

构建基座的核心是:配置子应用信息,通过registerApplication注册子应用,在基座工程挂载阶段start启动基座。

子应用配置

import Vue from 'vue'
import App from './App.vue'
import router from './router'
import singleSpaVue from 'single-spa-vue'

Vue.config.productionTip = false

const appOptions = {
  el: '#microApp',
  router,
  render: h => h(App)
}

// 支持应用独立运行、部署,不依赖于基座应用
// 如果不是微应用环境,即启动自身挂载的方式
if (!process.env.isMicro) {
  delete appOptions.el
  new Vue(appOptions).$mount('#app')
}
// 基于基座应用,导出生命周期函数
const appLifecycle = singleSpaVue({
  Vue,
  appOptions
})

// 抛出子应用生命周期
// 启动生命周期函数
export const bootstrap = (props)  => {
  console.log('app2 bootstrap')
  return appLifecycle.bootstrap(() => { })
}
// 挂载生命周期函数
export const mount = (props) => {
  console.log('app2 mount')
  return appLifecycle.mount(() => { })
}
// 卸载生命周期函数
export const unmount = (props) => {
  console.log('app2 unmount')
  return appLifecycle.unmount(() => { })
}

配置子应用为umd打包方式

//vue.config.js
const package = require('./package.json')
module.exports = {
  // 告诉子应用在这个地址加载静态资源,否则会去基座应用的域名下加载
  publicPath: '//localhost:8082',
  // 开发服务器
  devServer: {
    port: 8082
  },
  configureWebpack: {
    // 导出umd格式的包,在全局对象上挂载属性package.name,基座应用需要通过这个
    // 全局对象获取一些信息,比如子应用导出的生命周期函数
    output: {
      // library的值在所有子应用中需要唯一
      library: package.name,
      libraryTarget: 'umd'
    }
  }

子应用配置的核心是用singleSpaVue生成子路由配置后,必须要抛出其生命周期函数。

用以上方式便可轻松实现一个简单的微前端应用了。

single-spa毕竟是第一个微前端框架,他也有一定的缺点:

  • single-spa的文档略显凌乱,概念也比较多,在初次学习时上手难度较高。

  • single-spa是通过js文件去加载子应用,当文件名是乱码名时,每次子应用更新,父应用要更新引入配置文件,更新多项目时比较麻烦。

  • single-spa本身缺少js隔离和css隔离,虽然现在已经可以引入其他的包去解决,但是并没有做到开箱即用的程度。

相比于single-spa,qiankun他解决了JS沙盒环境,不需要我们自己去进行处理。在single-spa的开发过程中,我们需要自己手动的去写调用子应用JS的方法(如上面的 createScript方法),而qiankun不需要,乾坤只需要你传入响应的apps的配置即可,会帮助我们去加载。

Qiankun

Qiankun的优势

  • 基于 single-spa 封装,提供了更加开箱即用的 API。

  • 技术栈无关,任意技术栈的应用均可 使用/接入,不论是 React/Vue/Angular/JQuery 还是其他等框架。

  • HTML Entry 接入方式,让你接入微应用像使用 iframe 一样简单。

  • 样式隔离,确保微应用之间样式互相不干扰。

  • JS 沙箱,确保微应用之间 全局变量/事件 不冲突。

  • 资源预加载,在浏览器空闲时间预加载未打开的微应用资源,加速微应用打开速度。

基座配置

import { registerMicroApps, start } from 'qiankun';


registerMicroApps([
  {
    name: 'reactApp',
    entry: '//localhost:3000',
    container: '#container',
    activeRule: '/app-react',
  },
  {
    name: 'vueApp',
    entry: '//localhost:8080',
    container: '#container',
    activeRule: '/app-vue',
  },
  {
    name: 'angularApp',
    entry: '//localhost:4200',
    container: '#container',
    activeRule: '/app-angular',
  },
]);
// 启动 qiankun
start();

子应用配置

以 create react app 生成的 react 16 项目为例,搭配 react-router-dom 5.x。

1.在 src 目录新增 public-path.js,解决子应用挂载时,访问静态资源冲突

  if (window.__POWERED_BY_QIANKUN__) {
    __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
  }

2.设置 history 模式路由的 base:

  <BrowserRouter basename={window.__POWERED_BY_QIANKUN__ ? '/app-react' : '/'}>

3.入口文件 index.js 修改,为了避免根 id #root 与其他的 DOM 冲突,需要限制查找范围。

  import './public-path';
  import React from 'react';
  import ReactDOM from 'react-dom';
  import App from './App';


  function render(props) {
    const { container } = props;
    ReactDOM.render(<App />, container ? container.querySelector('#root') : 
    document.querySelector('#root'));
  }


  if (!window.__POWERED_BY_QIANKUN__) {
    render({});
  }


  export async function bootstrap() {
    console.log('[react16] react app bootstraped');
  }


  export async function mount(props) {
    console.log('[react16] props from main framework', props);
    render(props);
  }


  export async function unmount(props) {
    const { container } = props;
    ReactDOM.unmountComponentAtNode(container ? container.querySelector('#root') :  
    document.querySelector('#root'));
  }

4.修改 webpack 配置

const { name } = require('./package');


module.exports = {
  webpack: (config) => {
    config.output.library = `${name}-[name]`;
    config.output.libraryTarget = 'umd';
    config.output.jsonpFunction = `webpackJsonp_${name}`;
    config.output.globalObject = 'window';


    return config;
  },


  devServer: (_) => {
    const config = _;


    config.headers = {
      'Access-Control-Allow-Origin': '*',
    };
    config.historyApiFallback = true;
    config.hot = false;
    config.watchContentBase = false;
    config.liveReload = false;


    return config;
  },
};

以上对Qiankun的使用可以看出,与single-spa使用过程很相似。不同的是,Qiankun的使用过程更简便了。一些内置的操作交由给Qiankun内部实现。这是一种IOC思想的实现,我们只管面向容器化开发,其他操作交给Qiankun框架管理。

总结一下方案的优缺点:

优点

  • 监听路由自动的加载、卸载当前路由对应的子应用

  • 完备的沙箱方案,js沙箱做了SnapshotSandbox、LegacySandbox、ProxySandbox三套渐进增强方案,css沙箱做了两套strictStyleIsolation、experimentalStyleIsolation两套适用不同场景的方案

  • 路由保持,浏览器刷新、前进、后退,都可以作用到子应用

  • 应用间通信简单,全局注入

缺点

  • 基于路由匹配,无法同时激活多个子应用,也不支持子应用保活

  • 改造成本较大,从 webpack、代码、路由等等都要做一系列的适配

  • css 沙箱无法绝对的隔离,js 沙箱在某些场景下执行性能下降严重

  • 无法支持 vite 等 ESM 脚本运行

无界

在乾坤的issue中一个议题非常有意思,有个开发者提出能否利用iframe来实现js沙箱能力,这个idea启发了无界方案,下面详细介绍

应用加载机制和 js 沙箱机制

将子应用的js注入主应用同域的iframe中运行,iframe是一个原生的window沙箱,内部有完整的history和location接口,子应用实例instance运行在iframe中,路由也彻底和主应用解耦,可以直接在业务组件里面启动应用。

采用这种方式我们可以获得

  • 组件方式来使用微前端

不用注册,不用改造路由,直接使用无界组件,化繁为简

  • 一个页面可以同时激活多个子应用

子应用采用 iframe 的路由,不用关心路由占用的问题

  • 天然 js 沙箱,不会污染主应用环境

不用修改主应用window任何属性,只在iframe内部进行修改

  • 应用切换没有清理成本

由于不污染主应用,子应用销毁也无需做任何清理工作

路由同步机制

在iframe内部进行history.pushState,浏览器会自动的在joint session history中添加iframe的session-history,浏览器的前进、后退在不做任何处理的情况就可以直接作用于子应用

劫持iframe的history.pushState和history.replaceState,就可以将子应用的url同步到主应用的query参数上,当刷新浏览器初始化iframe时,读回子应用的url并使用iframe的history.replaceState进行同步

采用这种方式我们可以获得

  • 浏览器刷新、前进、后退都可以作用到子应用

  • 实现成本低,无需复杂的监听来处理同步问题

  • 多应用同时激活时也能保持路由同步

iframe 连接机制和 css 沙箱机制

无界采用webcomponent来实现页面的样式隔离,无界会创建一个wujie自定义元素,然后将子应用的完整结构渲染在内部

子应用的实例instance在iframe内运行,dom在主应用容器下的webcomponent内,通过代理 iframe的document到webcomponent,可以实现两者的互联。

将document的查询类接口:getElementsByTagName、getElementsByClassName、getElementsByName、getElementById、querySelector、querySelectorAll、head、body全部代理到webcomponent,这样instance和webcomponent就精准的链接起来。

当子应用发生切换,iframe保留下来,子应用的容器可能销毁,但webcomponent依然可以选择保留,这样等应用切换回来将webcomponent再挂载回容器上,子应用可以获得类似vue的keep-alive的能力.

采用这种方式我们可以获得

  • 天然 css 沙箱

直接物理隔离,样式隔离子应用不用做任何修改

  • 天然适配弹窗问题

document.body的appendChild或者insertBefore会代理直接插入到webcomponent,子应用不用做任何改造

  • 子应用保活

子应用保留iframe和webcomponent,应用内部的state不会丢失

  • 完整的 DOM 结构

webcomponent保留了子应用完整的html结构,样式和结构完全对应,子应用不用做任何修改

通信机制

承载子应用的iframe和主应用是同域的,所以主、子应用天然就可以很好的进行通信,在无界我们提供三种通信方式

  • props 注入机制

子应用通过$wujie.props可以轻松拿到主应用注入的数据

  • window.parent 通信机制

子应用iframe沙箱和主应用同源,子应用可以直接通过window.parent和主应用通信

  • 去中心化的通信机制

无界提供了EventBus实例,注入到主应用和子应用,所有的应用可以去中心化的进行通信

优势

通过上面原理的阐述,我们可以得出无界微前端框架的几点优势:

  • 多应用同时激活在线

    • 框架具备同时激活多应用,并保持这些应用路由同步的能力
  • 组件式的使用方式

    • 无需注册,更无需路由适配,在组件内使用,跟随组件装载、卸载
  • 应用级别的 keep-alive

    • 子应用开启保活模式后,应用发生切换时整个子应用的状态可以保存下来不丢失,结合预执行模式可以获得类似ssr的打开体验
  • 纯净无污染

    • 无界利用iframe和webcomponent来搭建天然的js隔离沙箱和css隔离沙箱

    • 利用iframe的history和主应用的history在同一个top-level browsing context来搭建天然的路由同步机制

    • 副作用局限在沙箱内部,子应用切换无需任何清理工作,没有额外的切换成本

  • 性能和体积兼具

    • 子应用执行性能和原生一致,子应用实例instance运行在iframe的window上下文中,避免with(proxyWindow){code}这样指定代码执行上下文导致的性能下降,但是多了实例化iframe的一次性的开销,可以通过 preload 提前实例化

    • 体积比较轻量,借助iframe和webcomponent来实现沙箱,有效的减小了代码量

  • 开箱即用

    • 不管是样式的兼容、路由的处理、弹窗的处理、热更新的加载,子应用完成接入即可开箱即用无需额外处理,应用接入成本也极低

Micro-app

micro-app并没有沿袭single-spa的思路,而是借鉴了WebComponent的思想,通过CustomElement结合自定义的ShadowDom,将微前端封装成一个类WebComponent组件,从而实现微前端的组件化渲染。并且由于自定义ShadowDom的隔离特性,micro-app不需要像single-spa和qiankun一样要求子应用修改渲染逻辑并暴露出方法,也不需要修改webpack配置,是目前市面上接入微前端成本最低的方案。

WebComponent的概念

WebComponent是HTML5提供的一套自定义元素的接口,WebComponent是一套不同的技术,允许您创建可重用的定制元素(它们的功能封装在您的代码之外)并且在您的 web 应用中使用它们。以上是MDN社区对WebComponent的解释。

  • Custom elements(自定义元素): 一组 JavaScript API,允许您定义 custom elements 及其行为,然后可以在您的用户界面中按照需要使用它们。

  • Shadow DOM(影子 DOM) :一组 JavaScript API,用于将封装的“影子”DOM 树附加到元素(与主文档 DOM 分开呈现)并控制其关联的功能。通过这种方式,您可以保持元素的功能私有,这样它们就可以被脚本化和样式化,而不用担心与文档的其他部分发生冲突。

  • HTML templates(HTML 模板):  <template> 和 <slot> 元素使您可以编写不在呈现页面中显示的标记模板。然后它们可以作为自定义元素结构的基础被多次重用。

接下来用一个小例子更快来理解WebComponent的概念。

一个存在组件内交互的WebComponent

// 基于HTMLElement自定义组件元素
class CounterElement extends HTMLElement {

  // 在构造器中生成shadow节点
  constructor() {
    super();

    this.counter = 0;

    // 打开影子节点
    // 影子节点是为了隔离外部元素的影响
    const shadowRoot = this.attachShadow({ mode: 'open' });

    // 定义组件内嵌样式
    const styles = `
          #counter-increment {
              width: 60px;
              height: 30px;
              margin: 20px;
              background: none;
              border: 1px solid black;
          }
      `;

    // 定义组件HTMl结构
    shadowRoot.innerHTML = `
          <style>${styles}</style>
          <h3>Counter</h3>
          <slot name='counter-content'>Button</slot>
          <span id='counter-value'>; 0 </span>;
          <button id='counter-increment'> + </button>
      `;

    // 获取+号按钮及数值内容
    this.incrementButton = this.shadowRoot.querySelector('#counter-increment');
    this.counterValue = this.shadowRoot.querySelector('#counter-value');

    // 实现点击组件内事件驱动
    this.incrementButton.addEventListener("click", this.decrement.bind(this));

  }

  increment() {
    this.counter++
    this.updateValue();
  }

  // 替换counter节点内容,达到更新数值的效果
  updateValue() {
    this.counterValue.innerHTML = this.counter;
  }
}

// 在真实dom上,生成自定义组件元素
customElements.define('counter-element', CounterElement);

有了对WebComponent的理解,接下来,我们更明白了Micro-app的优势。

micro-app的优势

  • 使用简单

我们将所有功能都封装到一个类WebComponent组件中,从而实现在基座应用中嵌入一行代码即可渲染一个微前端应用。

同时micro-app还提供了js沙箱、样式隔离、元素隔离、预加载、数据通信、静态资源补全等一系列完善的功能。

  • 零依赖

micro-app没有任何依赖,这赋予它小巧的体积和更高的扩展性。

  • 兼容所有框架

为了保证各个业务之间独立开发、独立部署的能力,micro-app做了诸多兼容,在任何技术框架中都可以正常运行。

基座的简易配置

基座存在预加载子应用、父子应用通信、公共文件共享等等

// index.js
import React from "react"
import ReactDOM from "react-dom"
import App from './App'
import microApp from '@micro-zoe/micro-app'

const appName = 'my-app'

// 预加载
microApp.preFetch([
  { name: appName, url: 'xxx' }
])

// 基座向子应用数据通信
microApp.setData(appName, { type: '新的数据' })
// 获取指定子应用数据
const childData = microApp.getData(appName)

microApp.start({
  // 公共文件共享
  globalAssets: {
    js: ['js地址1', 'js地址2', ...], // js地址
    css: ['css地址1', 'css地址2', ...], // css地址
  }
})

分配一个路由给子应用

// router.js
import { BrowserRouter, Switch, Route } from 'react-router-dom'

export default function AppRoute () {
  return (
    <BrowserRouter>
      <Switch>
        <Route path='/'>
          <micro-app name='app1' url='http://localhost:3000/' baseroute='/'></micro-app>
        </Route>
      </Switch>
    </BrowserRouter>
  )
}

子应用的简易配置

// index.js
import React from "react"
import ReactDOM from "react-dom"
import App from './App'
import microApp from '@micro-zoe/micro-app'

const appName = 'my-app'

// 子应用运行时,切换静态资源访问路径
if (window.__MICRO_APP_ENVIRONMENT__) {
  __webpack_public_path__ = window.__MICRO_APP_PUBLIC_PATH__
}

// 基子应用向基座发送数据
// dispatch只接受对象作为参数
window.microApp.dispatch({ type: '子应用发送的数据' })
// 获取基座数据
const data = window.microApp.getData() // 返回基座下发的data数据

//性能优化,umd模式
// 如果子应用渲染和卸载不频繁,那么使用默认模式即可,如果子应用渲染和卸载非常频繁建议使用umd模式
// 将渲染操作放入 mount 函数 -- 必填
export function mount() {
  ReactDOM.render(<App />, document.getElementById("root"))
}

// 将卸载操作放入 unmount 函数 -- 必填
export function unmount() {
  ReactDOM.unmountComponentAtNode(document.getElementById("root"))
}

// 微前端环境下,注册mount和unmount方法
if (window.__MICRO_APP_ENVIRONMENT__) {
  window[`micro-app-${window.__MICRO_APP_NAME__}`] = { mount, unmount }
} else {
  // 非微前端环境直接渲染
  mount()
}

设置子应用路由

import { BrowserRouter, Switch, Route } from 'react-router-dom'

export default function AppRoute () {
  return (
    // 设置基础路由,子应用可以通过window.__MICRO_APP_BASE_ROUTE__获取基座下发的baseroute,
    // 如果没有设置baseroute属性,则此值默认为空字符串
    <BrowserRouter basename={window.__MICRO_APP_BASE_ROUTE__ || '/'}>
      ...
    </BrowserRouter>
  )
}

总结

微前端概念的出现是前端发展的必然阶段,PC 互联网转向移动互联网时代时,PC 的场景并未完全被消灭,反而转向了衍生出了更多沉浸感更高、体验感更强的应用,与之对应的应该是出现新的架构模式来应对这些应用规模的增长。

微前端也并非银弹,采用微前端后复杂度并未凭空消失,而是由代码转向了基础设施,对架构设计带来了更大的挑战,并且在新的架构下需要设计并提供更多的周边工具和生态来助力这一新的研发模式。

问题和挑战

框架本来就不能权衡所有问题,它是一个不断演变和发展的过程。在使用微前端框架的过程中我们发现了如下问题:

  • 多个子应用使用自己的技术栈,会导致浏览器加载很多框架和重复的代码: 由于微前端架构的设计理念是与技术栈无关,当子应用采用各种技术栈时,带来大量的框架代码和重复性代码加载,会导致性能上的影响。

  • 微前端让前端环境变得更加复杂了,开发调试过程难度提升: 微前端强大的可扩展性,可以加载各种子应用,这样一来前端环境会变得越来越复杂,系统的调试带来一定程度的影响,调试过程中需要考虑的问题越来越多。

  • 对于小型的项目,微前端优势不明显: 对于小型项目,采用微前端架构付出的代价远远超过它的优势,小团队要维护多个系统,并且处理这些复杂的关系还不如使用单一的独立系统。

当然,架构的选择需要和当前的产品形态、组织结构、团队现状以及技术方向做一些平衡,没有完美的架构,只有适合的架构。