qiankun微前端架构设计

1. 概述

1.1 系统简介

通过本文档中设计并实现的微前端框架,可以对运检管控类型的巨型项目进行拆分,独立开发,独立部署;拆分出来的子应用可由不同的开发团队,不同的前端框架开发实现;拆分出来的子应用可以自由组合成不同的产品,有效提升代码的复用率,降低代码、业务、开发人员之间的耦合性。

1.2 设计原则

先进性:

在产品设计上,整个系统符合高新技术的潮流,微前端的引入,大型系统的拆分,子应用的加载与切换,子应用的动态接入,多子应用在同一页面同时加载等技术都处于国际领先的技术水平。在满足现期功能的前提下,系统设计具有前瞻性,在今后较长时间内保持一定的技术先进性。


安全性:

系统采取全面的安全保护措施,具有权限管理子应用,共享数据读写访问权限控制,具有高度的安全性和保密性。对接入系统的子应用和用户,进行严格的接入认证,以保证接入的安全性,确保系统长期正常运行。


经济性:

在满足系统功能及性能要求的前提下,尽量降低系统建设成本,合理利用与迭代升级现有项目,利用现有业务实现,现有系统实现,自由组合成新产品。


规范性:

系统中采用的页面路由技术,数据共享技术不限前端框架vue,react。系统具有良好的兼容性和互联互通性。


可维护性:

系统操作简单,实用性高,具有独立开发,独立部署,独立运行的特点,方便各个业务团队分工合作,可维护性高。


可扩展性:

系统具备良好的子应用接入能力,子应用数据接入能力。


开放性:

系统设计遵循开放性原则,能够支持多种前端框架开发的子应用接入。各子应用采用标准数据接口,具有与其他子应用进行数据交换和数据共享的能力。

1.3 设计目标

说明微前端框架达到的目标。

1) 第一点

拆分前端巨型应用,实现子应用的独立开发,独立部署。

2) 第二点

设计实现子应用与基座应用,其他子应用的数据通信机制。

3) 第三点

实现在运行期根据后端子应用信息列表接口动态接入新的子应用。

4) 第四点

设计实现同一页面同时接入多个子应用。

1.4 术语及缩略语

1.4.1 术语解释

基座应用:通过基座应用把不同的子应用集成起来,提供了子应用的加载与切换的能力,实现了不同子应用间的js隔离,样式隔离及通信需求,可管理动态接入的子应用。

子应用:实现bootstap、mount、unmount等生命周期函数供基座应用调用的可以独立开发、测试和部署的应用,称作子应用。然后由一个基座应用根据路由进行应用切换。

微前端:微前端(Micro-Frontends)是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将 Web 应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。各个前端应用还可以独立运行、独立开发、独立部署。微前端不是单纯的前端框架或者工具,而是一套架构体系。

qiankun:一个基于 single-spa 的微前端实现库,封装了应用加载方案,解决了js隔离、css样式隔离和应用间通信,预加载等问题,旨在帮助大家能更简单、无痛的构建一个生产可用微前端架构系统。孵化自蚂蚁金融科技基于微前端架构的云产品统一接入平台。

2. 微前端探索

2.1. 基本原理

在正式介绍qiankun之前,我们需要知道,它是基于另一个微前端框架:single-spa 搭建的。qiankun在它的基础上进行了封装和增强,使其更加易用。本文我们会先从single-spa入手,一步步介绍qiankun的实现原理。在讲解两者之前,我们先来了解一下何为微前端。

微前端的概念借鉴自后端的微服务,主要是为了解决大型工程在变更、维护、扩展等方面的困难而提出的。目前主流的微前端方案包括以下几个:

1. frame

2. 基座模式,主要基于路由分发,qiankun和single-spa就是基于这种模式

3. 组合式集成,即单独构建组件,按需加载,类似npm包的形式

4.EMP,主要基于Webpack5 Module Federation

5. Web Components

严格来讲,这些方案都不算是完整的微前端解决方案,它们只是用于解决微前端中运行时容器的相关问题。除了运行时容器,一套完整的微前端方案还需要解决版本管理、质量管控、配置下发、线上监控、灰度发布、安全监测等与工程和平台相关的问题,而这些问题中的大部分工作目前仍处于探索阶段。


图1. 微前端要解决的问题

iframe:是传统的微前端解决方案,基于iframe标签实现,技术难度低,隔离性和兼容性很好,但是性能和使用体验比较差,多用于集成第三方系统;

基座模式:主要基于路由分发,即由一个基座应用来监听路由,并按照路由规则来加载不同的应用,以实现应用间解耦;

组合式集成:把组件单独打包和发布,然后在构建或运行时组合;

EMP:基于Webpack5 Module Federation,一种去中心化的微前端实现方案,它不仅能很好地隔离应用,还可以轻松实现应用间的资源共享和通信;

Web Components:是官方提出的组件化方案,它通过对组件进行更高程度的封装,来实现微前端,但是目前兼容性不够好,尚未普及。

总的来说,iframe主要用于简单并且性能要求不高的第三方系统;组合式集成目前主要用于前端组件化,而不是微前端;基座模式、EMPWeb Components是目前主流的微前端方案。

本文我们主要对qiankun所基于的基座模式进行介绍。它的主要思路是将一个大型应用拆分成若干个更小、更简单,可以独立开发、测试和部署的子应用,然后由一个基座应用根据路由进行应用切换。

如果以前端组件的概念作类比,我们可以把每个被拆分出的子应用看作是一个应用级组件,每个应用级组件专门实现某个特定的业务功能(如商品管理、订单管理等)。这里实际上谈到了微前端拆分的原则:即以业务功能为基本单元。 经过拆分后,整个系统的结构也发生了变化:


图2. 紧耦合VS 松耦合

左侧是传统大型单页应用的前端架构,所有模块都在一个应用内,由应用本身负责路由管理,是应用分发路由的方式;而右侧是基座模式下的系统架构,各个子应用互不相关,单独运行在不同的服务上,由基座应用根据路由选择加载哪个应用到页面内,是路由分发应用的方式。这种方式使得各个模块的耦合性大大降低,而微前端需要解决的主要问题就是如何拆分和组织这些子应用。

为了让这些拆分出的子应用在一个单页面内协同工作,我们需要一个“管理者”应用,这就是我们上面说的基座应用,也叫主应用。基座应用一般是用户最终访问的应用,它会根据定义的规则,将不同的应用加载到页面内供用户使用。当然,这种架构下的每个子应用也具备单独访问的能力。

为了配合基座应用,子应用必须经过一些改造,向外暴露出相应的生命周期钩子,以便基座应用加载和卸载。实际上,一个典型的基于vue-router的Vue应用与这种架构存在着很大的相似性:


图3.Vue-router VS 微前端

在典型的Vue应用中,各个组件当然都必须基于Vue编写;但是在微前端架构中,各个子应用可以基于不同的技术框架,这也是它最大的优势之一。这是因为各个子应用是独立编译和部署的,而基座应用是在运行时动态加载的子应用,由于在启动子应用时已经经历过编译阶段,所以基座应用加载的都是原生JavaScript代码,自然与子应用所用的技术框架无关(qiankun甚至能加载jQuery编写的页面)。

概念性地讲,在微前端架构中,各个子应用将一些特定的业务功能封装在一个业务黑箱中,只对外暴露少量生命周期方法;基座应用根据路由地址变化,动态地加载对应的业务黑箱,并将其渲染到指定的占位DOM元素上。与Vue应用一样,微前端也可以一次加载多个业务黑箱,这称为多实例模式(类似于vue-router的命名视图)。


2.2. 微前端的主要优势

1.技术兼容性好,各个子应用可以基于不同的技术架构

2.代码库更小、内聚性更强

3. 便于独立编译、测试和部署,可靠性更高

4. 耦合性更低,各个团队可以独立开发,互不干扰

5. 可维护性和扩展性更好,便于局部升级和增量升级

关于技术兼容性,由于在被基座应用加载前,所有子应用已经编译成原生代码输出,所以基座应用可以加载各类技术栈编写的应用;由于拆分后应用体积明显变小,并且每个应用只实现一个业务模块,因此其内聚性更强;另外子应用本身也是完整的应用,所以它可以独立编译、测试和部署;关于耦合性,由于各个子应用只负责各自的业务模块,所以耦合性很低,非常便于独立开发;关于可维护性和扩展性,由于拆分出的应用都是完整的应用,因此专门升级某个功能模块就成为了可能,并且当需要增加模块时,只需要创建一个新应用,并修改基座应用的路由规则即可。

不过这种微前端方案仍然存在缺点。

2.3.当前微前端方案的一些缺点

1. 子应用间的资源共享能力较差,使得项目总体积变大

2. 需要对现有代码进行改造(指的是未按照微前端形式编写的旧工程)

首先,子应用之间保持较高的独立性,反而使一些公共资源不便于共享。虽然大型第三方库可以通过externals的方式上传到cdn,但像一些工具函数,通用业务组件等则不易共享,这就使得项目整体体积反而变大。由于改造成本不高,代码改造通常算不上很严重的问题,但仍存在一定的代价。

介绍完微前端的基本概念,我们就来看一下qiankun和single-spa的核心实现原理。

2.4.qiankun与single-spa实现原理

既然qiankun是基于single-spa的,那么我们就来看qiankun和single-spa在架构中分别扮演了什么角色。

一般来说,微前端需要解决的问题分为两大类:

1. 应用的加载与切换

2.应用的隔离与通信

应用的加载与切换需要解决的问题包括:路由问题、应用入口、应用加载;应用的隔离与通信需要解决的问题包括:js隔离、css样式隔离、应用间通信

single-spa很好地解决了路由应用入口两个问题,但并没有解决应用加载问题,而是将该问题暴露出来由使用者实现(一般可以用system.js或原生script标签来实现);qiankun在此基础上封装了一个应用加载方案(即import-html-entry),并给出了js隔离css样式隔离应用间通信三个问题的解决方案,同时提供了预加载功能

借助single-spa提供的能力,我们只能把不同的应用加载到一个页面内,但是很难保证这些应用不会互相干扰。而qiankun为我们解决了这些后顾之忧,使得它成为一个更加完整的微前端运行时容器。


图4.微前端框架要解决的问题

接下来我们借助部分源码,分别来看single-spa和qiankun是如何一步步实现运行时容器的。

2.4.1. single-spa实现原理

我们已经知道,single-spa解决的是应用的加载与切换相关的问题,下面就来看完整的实现过程。

2.4.1.1. 路由问题

single-spa是通过监听hashChangepopState这两个原生事件来检测路由变化的,它会根据路由的变化来加载对应的应用,相关的代码可以在single-spa的 src/navigation/navigation-events.js 中找到:

// 139行

if (isInBrowser) {

  // We will trigger an app change for any routing events.

  window.addEventListener("hashchange", urlReroute);

  window.addEventListener("popstate", urlReroute);

...

// 174行,劫持pushState和replaceState

  window.history.pushState = patchedUpdateState(

    window.history.pushState,

    "pushState"

  );

  window.history.replaceState = patchedUpdateState(

    window.history.replaceState,

    "replaceState"

  );

我们看到,single-spa在检测到发生hashChange或popState事件时,会执行urlReroute函数,这里封装了它对路由问题的解决方案。另外,它还劫持了原生的pushState和replaceState事件,关于为什么劫持这两个事件,我们后面会介绍,我们先来看urlReroute函数做了什么:

function urlReroute() {

  reroute([], arguments);

}

这个函数只是调用了reroute函数,而reroute函数就是single-spa解决路由问题的核心逻辑,下面我们来分析一下它的实现,由于该函数较长,我们截取其中体现核心思路的代码进行分析:

src/navigation/reroute.js

export function reroute(pendingPromises = [], eventArguments) {

  ...

  // getAppChanges会根据路由改变应用的状态,状态包含4类

  // 待清除、待卸载、待加载、待挂载

  const {

    appsToUnload,

    appsToUnmount,

    appsToLoad,

    appsToMount,

  } = getAppChanges();

  ...

  // 如果应用已启动,则调用performAppChanges加载和挂载应用

  // 否则,只加载未加载的应用

  if (isStarted()) {

    appChangeUnderway = true;

    appsThatChanged = appsToUnload.concat(

      appsToLoad,

      appsToUnmount,

      appsToMount

    );

    return performAppChanges();

  } else {

    appsThatChanged = appsToLoad;

    return loadApps();

  }

  ...

  function performAppChanges() {

    return Promise.resolve().then(() => {

      // 1. 派发应用更新前的自定义事件

      // 2. 执行应用暴露出的生命周期函数

      // appsToUnload -> unload生命周期钩子

      // appsToLoad -> 执行加载方法

      // appsToUnmount -> 卸载应用,并执行对应生命周期钩子

      // appsToMount -> 尝试引导和挂载应用

    })

  }

  ...

}

这里就是single-spa解决路由问题的主要逻辑。主要是以下几步:

1. 根据传入的参数activeWhen判断哪个应用需要加载,哪个应用需要卸载或清除,并将其push到对应的数组

2. 如果应用已经启动,则进行应用加载或切换。针对应用的不同状态,直接执行应用自身暴露出的生命周期钩子函数即可。

3. 如果应用未启动,则只去下载appsToLoad中的应用。

总的来看,当路由发生变化时,hashChange或popState会触发,这时single-spa会监听到,并触发urlReroute;接着它会调用reroute,该函数正确设置各个应用的状态后,直接通过调用应用所暴露出的生命周期钩子函数即可。当某个应用被推送到appsToMount后,它的mount函数会被调用,该应用就会被挂载;而推送到appsToUnmount中的应用则会调用其unmount钩子进行卸载。


图5.Single-spa的路由过程


上面我们还提到,single-spa除了监听hashChange或popState两个事件外,还劫持了原生的pushState和 replaceState两个方法,这是为什么呢?

这是因为像scroll-restorer这样的第三方组件可能会在页面滚动时,通过调用pushState或replaceState,将滚动位置记录在state中,而页面的url实际上没有变化。这种情况下,single-spa理论上不应该去重新加载应用,但是由于这种行为会触发页面的hashChange事件,所以根据上面的逻辑,single-spa会发生意外重载。

为了解决这个问题,single-spa允许开发者手动设置是否只对url值的变化监听,而不是只要发生hashChange或popState就去重新加载应用,我们可以像下面一样在启动single-spa时添加urlRerouteOnly参数:

singleSpa.start({

  urlRerouteOnly: true,

});

这样除非url发生了变化,否则pushState和popState不会导致应用重载。

2.4.1.2. 应用入口

single-spa采用的是协议入口,即只要实现了single-spa的入口协议规范,它就是可加载的应用。single-spa的规范要求应用入口必须暴露出以下三个生命周期钩子函数,且必须返回Promise,以保证single-spa可以注册回调函数:

1. bootstrap

2. mount

3. unmount


图6.符合single-spa规范的生命周期函数

bootstrap用于应用引导,基座应用会在子应用挂载前调用它。举个应用场景,假如某个子应用要挂载到基座应用内id为app的节点上:

new Vue({

  el: '#app',

  ...

})

但是基座应用中当前没有id为app的节点,我们就可以在子应用的bootstrap钩子内手动创建这样一个节点并插入到基座应用,子应用就可以正常挂载了。所以它的作用就是做一些挂载前的准备工作。

mount用于应用挂载,就是一般应用中用于渲染的逻辑,即上述的new Vue语句。我们通常会把它封装到一个函数里,在mount钩子函数中调用。

unmount用于应用卸载,我们可以在这里调用实例的destroy方法手动卸载应用,或清除某些内存占用等。

除了以上三个必须实现的钩子外,single-spa还支持非必须的load、unload、update等,分别用于加载、卸载和更新应用。

那么只使用single-spa如何进行子应用加载呢?

2.4.1.3. 应用加载

实际上single-spa并没有提供自己的解决方案,而是将它开放出来,由开发者提供。

我们看一下基于system.js如何启动single-spa:

<script type="systemjs-importmap">

  {

    "imports": {

      "app1": "http://localhost:8080/app1.js",

      "app2": "http://localhost:8081/app2.js",

      "single-spa": "https://cdnjs.cloudflare.com/ajax/libs/single-spa/4.3.7/system/single-spa.min.js"

    }

  }

</script>

... // system.js的相关依赖文件

<script>

(function(){

  // 加载single-spa

  System.import('single-spa').then((res)=>{

    var singleSpa = res;

    // 注册子应用

    singleSpa.registerApplication('app1',

      () => System.import('app1'),

      location => location.hash.startsWith(`#/app1`);

    );

    singleSpa.registerApplication('app2',

      () => System.import('app2'),

      location => location.hash.startsWith(`#/app2`);

    );

    // 启动single-spa

    singleSpa.start();

  })

})()

</script>

我们在调用singleSpa.registerApplication注册应用时提供的第二个参数就是加载这个子应用的方法。如果需要加载多个js,可以使用多个System.import连续导入。single-spa会调用这个函数,下载子应用代码并分别调用其bootstrap和mount方法进行引导和挂载。

从这里我们也可以看到single-spa的弊端。首先我们必须手动实现应用加载逻辑,挨个罗列子应用需要加载的资源,这在大型项目里是十分困难的(特别是使用了文件名hash时);另外它只能以js文件为入口,无法直接以html为入口,这使得嵌入子应用变得很困难,也正因此, single-spa不能直接加载jQuery应用。

single-spa的start方法也很简单:

export function start(opts) {

  started = true;

  if (opts && opts.urlRerouteOnly) {

    setUrlRerouteOnly(opts.urlRerouteOnly);

  }

  if (isInBrowser) {

    reroute();

  }

}

先是设置started状态,然后设置我们上面说到的urlRerouteOnly属性,接着调用reroute,开始首次加载子应用。加载完第一个应用后,single-spa就时刻等待着hashChange或popState事件的触发,并执行应用的切换。

以上就是single-spa的核心原理,从上面的介绍中不难看出,single-spa只是负责把应用加载到一个页面中,至于应用能否协同工作,是很难保证的。而qiankun所要解决的,就是协同工作的问题。

2.4.2. qiankun实现原理

2.4.2.1.应用加载

上面我们说到了,single-spa提供的应用加载方案是开放式的。针对上面我们谈到的几个弊端,qiankun进行了一次封装,给出了一个更完整的应用加载方案,qiankun的作者将其封装成了npm插件import-html-entry。

该方案的主要思路是允许以html文件为应用入口,然后通过一个html解析器从文件中提取js和css依赖,并通过fetch下载依赖,于是在qiankun中你可以这样配置入口:

const MicroApps = [{

  name: 'app1',

  entry: 'http://localhost:8080',

  container: '#app',

  activeRule: '/app1'

}]

qiankun会通过import-html-entry请求http://localhost:8080,得到对应的html文件,解析内部的所有script和style标签,依次下载和执行它们,这使得应用加载变得更易用。我们看一下这具体是怎么实现的。

import-html-entry暴露出的核心接口是importHTML,用于加载html文件,它支持两个参数:

1. url,要加载的文件地址,一般是服务中html的地址

2. opts,配置参数

url不必多说。opts如果是一个函数,则会替换默认的fetch作为下载文件的方法,此时其返回值应当是Promise;如果是一个对象,那么它最多支持四个属性:fetch、getPublicPath、getDomain、getTemplate,用于替换默认的方法,这里暂不详述。

我们截取该函数的主要逻辑:

export default function importHTML(url, opts = {}) {

  ...

  // 如果已经加载过,则从缓存返回,否则fetch回来并保存到缓存中

  return embedHTMLCache[url] || (embedHTMLCache[url] = fetch(url)

        .then(response => readResAsString(response, autoDecodeResponse))

        .then(html => {

          // 对html字符串进行初步处理

          const { template, scripts, entry, styles } =

            processTpl(getTemplate(html), assetPublicPath);

          // 先将外部样式处理成内联样式

          // 然后返回几个核心的脚本及样式处理方法

          return getEmbedHTML(template, styles, { fetch }).then(embedHTML => ({

                template: embedHTML,

                assetPublicPath,

                getExternalScripts: () => getExternalScripts(scripts, fetch),

                getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch),

                execScripts: (proxy, strictGlobal, execScriptsHooks = {}) => {

                    if (!scripts.length) {

                        return Promise.resolve();

                    }

                    return execScripts(entry, scripts, proxy, {

                        fetch,

                        strictGlobal,

                        beforeExec: execScriptsHooks.beforeExec,

                        afterExec: execScriptsHooks.afterExec,

                    });

                },

            }));

        });

}

省略的部分主要是一些参数预处理,我们从return语句开始看,具体过程如下:

1. 检查是否有缓存,如果有,直接从缓存中返回

2. 如果没有,则通过fetch下载,并字符串化

3. 调用processTpl进行一次模板解析,主要任务是扫描出外联脚本和外联样式,保存在scripts和styles中

4. 调用getEmbedHTML,将外联样式下载下来,并替换到模板内,使其变成内部样式

5. 返回一个对象,该对象包含处理后的模板,以及getExternalScripts、getExternalStyleSheets、execScripts等几个核心方法


图7. qiankun 应用加载流程

processTpl主要基于正则表达式对模板字符串进行解析,这里不进行详述。我们来看getExternalScripts、getExternalStyleSheets、execScripts这三个方法:

getExternalStyleSheets

export function getExternalStyleSheets(styles, fetch = defaultFetch) {

  return Promise.all(styles.map(styleLink => {

    if (isInlineCode(styleLink)) {

      // if it is inline style

      return getInlineCode(styleLink);

    } else {

      // external styles

      return styleCache[styleLink] ||

      (styleCache[styleLink] = fetch(styleLink).then(response => response.text()));

    }

  ));

}

遍历styles数组,如果是内联样式,则直接返回;否则判断缓存中是否存在,如果没有,则通过fetch去下载,并进行缓存。

getExternalScripts与上述过程类似。

execScripts是实现js隔离的核心方法,我们放在下一部分js隔离里讲解。

通过调用importHTML方法,qiankun可以直接加载html文件,同时将外联样式处理成内部样式表,并且解析出JavaScript依赖。更重要的是,它获得了一个可以在隔离环境下执行应用脚本的方法execScripts。

2.4.2.2.js隔离

上面我们说到,qiankun通过import-html-entry,可以对html入口进行解析,并获得一个可以执行脚本的方法execScripts。qiankun引入该接口后,首先为该应用生成一个window的代理对象,然后将代理对象作为参数传入接口,以保证应用内的js不会对全局window造成影响。 由于IE11不支持proxy,所以qiankun通过快照策略来隔离js,缺点是无法支持多实例场景。

我们先来看基于proxy的js隔离是如何实现的。首先看import-html-entry暴露出的接口,照例我们只截取核心代码:

execScripts

export function execScripts(entry, scripts, proxy = window, opts = {}) {

  ... // 初始化参数

  return getExternalScripts(scripts, fetch, error)

    .then(scriptsText => {

      // 在proxy对象下执行脚本的方法

      const geval = (scriptSrc, inlineScript) => {

        const rawCode = beforeExec(inlineScript, scriptSrc) || inlineScript;

        const code = getExecutableScript(scriptSrc, rawCode, proxy, strictGlobal);

        (0, eval)(code);

        afterExec(inlineScript, scriptSrc);

      };

      // 执行单个脚本的方法

      function exec (scriptSrc, inlineScript, resolve) { ... }

      // 排期函数,负责逐个执行脚本

      function schedule(i, resolvePromise) { ... }

      // 启动排期函数,执行脚本

      return new Promise(resolve => schedule(0, success || resolve));

    });

});

这个函数的关键是定义了三个函数:geval、exec、schedule,其中实现js隔离的是geval函数内调用的getExecutableScript函数。我们看到,在调这个函数时,我们把外部传入的proxy作为参数传入了进去,而它返回的是一串新的脚本字符串,这段新的字符串内的window已经被proxy替代,具体实现逻辑如下:

function getExecutableScript(scriptSrc, scriptText, proxy, strictGlobal) {

    const sourceUrl = isInlineCode(scriptSrc) ? '' : `//# sourceURL=${scriptSrc}\n`;

    // 通过这种方式获取全局 window,因为 script 也是在全局作用域下运行的,所以我们通过 window.proxy 绑定时也必须确保绑定到全局 window 上

    // 否则在嵌套场景下, window.proxy 设置的是内层应用的 window,而代码其实是在全局作用域运行的,会导致闭包里的 window.proxy 取的是最外层的微应用的 proxy

    const globalWindow = (0, eval)('window');

    globalWindow.proxy = proxy;

    // TODO 通过 strictGlobal 方式切换切换 with 闭包,待 with 方式坑趟平后再合并

    return strictGlobal

        ? `;(function(window, self, globalThis){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`

        : `;(function(window, self, globalThis){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`;

}


图8. qiankun js隔离关键代码

核心代码就是由两个矩形框起来的部分,它把解析出的scriptText(即脚本字符串)用with(window){}包裹起来,然后把window.proxy作为函数的第一个参数传进来,所以with语法内的window实际上是window.proxy。

这样,当在执行这段代码时,所有类似var name = '张三'这样的语句添加的全局变量name,实际上是被挂载到了window.proxy上,而不是真正的全局window上。当应用被卸载时,对应的proxy会被清除,因此不会导致js污染。而当你配置webpack的打包类型为lib时,你得到的接口大概如下:

var jquery = (function(){})();

如果你的应用内使用了jquery,那么这个jquery对象就会被挂载到window.proxy上。不过如果你在代码内直接写window.name = '张三'来生成全局变量,那么qiankun就无法隔离js污染了。

import-html-entry实现了上述能力后,qiankun要做的就很简单了,只需要在加载一个应用时为其初始化一个proxy传递进来即可:

proxySandbox.ts

export default class ProxySandbox implements SandBox {

  ...

  constructor(name: string) {

    ...

    const proxy = new Proxy(fakeWindow, {

      set () { ... },

      get () { ... }

    }

  }

}

每次加载一个应用,qiankun就初始化这样一个proxySandbox,传入上述execScripts函数中。

在IE下,由于proxy不被支持,并且没有可用的polyfill,所以qiankun退而求其次,采用快照策略实现js隔离。它的大致思路是,在加载应用前,将window上的所有属性保存起来(即拍摄快照);等应用被卸载时,再恢复window上的所有属性,这样也可以防止全局污染。但是当页面同时存在多个应用实例时,qiankun无法将其隔离开,所以IE下的快照策略无法支持多实例模式。

关于快照模式我们就不详细介绍了,接下来看一下qiankun如何实现css样式隔离。

2.4.2.3. css隔离

目前qiankun主要提供了两种样式隔离方案,一种是基于shadowDom的;另一种则是实验性的,思路类似于Vue中的scoped属性,给每个子应用的根节点添加一个特殊属性,用作对所有css选择器的约束。

开启样式隔离的语法如下:

registerMicroApps({

  name: 'app1',

  ...

  sandbox: {

    strictStyleIsolation: true

    // 实验性方案,scoped方式

    // experimentalStyleIsolation: true

  },

})

当启用strictStyleIsolation时,qiankun将采用shadowDom的方式进行样式隔离,即为子应用的根节点创建一个shadow root。最终整个应用的所有DOM将形成一棵shadow tree。我们知道,shadowDom的特点是,它内部所有节点的样式对树外面的节点无效,因此自然就实现了样式隔离。

但是这种方案是存在缺陷的。因为某些UI框架可能会生成一些弹出框直接挂载到document.body下,此时由于脱离了shadow tree,所以它的样式仍然会对全局造成污染。

此外qiankun也在探索类似于scoped属性的样式隔离方案,可以通过experimentalStyleIsolation来开启。这种方案的策略是为子应用的根节点添加一个特定的随机属性,如:

<div

  data-qiankun-asiw732sde

  id="__qiankun_microapp_wrapper__"

  data-name="module-app1"

>

然后为所有样式前面都加上这样的约束:

.app-main {

字体大小:14 px ;

}

// ->

div[data-qiankun-asiw732sde] .app-main {  

字体大小:14 px ;

}

经过上述替换,这个样式就只能在当前子应用内生效了。虽然该方案已经提出很久了,但仍然是实验性的,因为它不支持@ keyframes,@ font-face,@ import,@ page(即不会被重写)。

2.4.2.4. 应用通信

一般来说,微前端中各个应用之前的通信应该是尽量少的,而这依赖于应用的合理拆分。反过来说,如果你发现两个应用间存在极其频繁的通信,那么一般是拆分不合理造成的,这时往往需要将它们合并成一个应用。

当然了,应用间存在少量的通信是难免的。qiankun官方提供了一个简要的方案,思路是基于一个全局的globalState对象。这个对象由基座应用负责创建,内部包含一组用于通信的变量,以及两个分别用于修改变量值和监听变量变化的方法:setGlobalState和onGlobalStateChange。

以下代码用于在基座应用中初始化它:

import { initGlobalState, MicroAppStateActions } from 'qiankun';

const initialState = {};

const actions: MicroAppStateActions = initGlobalState(initialState);

export default actions;

这里的actions对象就是我们说的globalState,即全局状态。基座应用可以在加载子应用时通过props将actions传递到子应用内,而子应用通过以下语句即可监听全局状态变化:

actions.onGlobalStateChange (globalState, oldGlobalState) {

  ...

}

同样的,子应用也可以修改全局状态:

actions.setGlobalState(...);


图9. qiankun 应用通信

此外,基座应用和其他子应用也可以进行这两个操作,从而实现对全局状态的共享,这样各个应用之间就可以通信了。这种方案与Redux和Vuex都有相似之处,只是由于微前端中的通信问题较为简单,所以官方只提供了这样一个精简方案。关于其实现原理这里不再赘述,感兴趣的可以去看一下源码。

关于qiankun的核心原理到这里就介绍完了,下面我们看一下如果使用qankun搭建一个微前端项目。

3. 总体设计

3.1. 需求说明

3.1.1. 功能性需求说明

1) 数据共享

    各子应用可以基于基座应用共享数据,其他子应用可以主动获取此数据或者通过注册监听的方式获取关注数据的变化。

2) 动态加载

    基座应用可以根据项目的子应用配置数据动态加载子应用。

3) 同时加载多个子应用

    支持一个页面基于子应用容器模板同时加载多个子应用。

4) 应用管理

    以独立服务的方式管理项目、基座、子应用的组合关系。

 

3.1.2. 非功能性需求说明

1) 技术兼容性需求

关于技术兼容性,由于在被基座应用加载前,所有子应用已经编译成原生代码输出,所以基座应用可以加载各类技术栈编写的应用,对于传统html+jquery的项目的接入需要配置相应的服务代理,数据通讯不可用;推荐接入vue,react子应用;由于拆分后应用体积明显变小,并且每个应用只实现一个业务模块,因此其内聚性更强;另外子应用本身也是完整的应用,所以它可以独立编译、测试和部署。

2) 耦合性需求

关于耦合性,由于各个子应用只负责各自的业务模块,所以耦合性很低,非常便于独立开发。

3) 扩展性需求

关于可维护性和扩展性,由于拆分出的应用都是完整的应用,因此专门升级某个功能模块就成为了可能,并且当需要增加模块时,只需要创建一个新应用,并修改基座应用的路由规则即可。

3.2. 技术路线

在微前端框架的设计中,采用的核心技术如下:

1) 采用qiankun实现应用的加载与切换。

2) 采用proxy 实现 js 隔离。

3) shadowDom的方式进行样式隔离。

4) 封装实现了数据总线方案。

5) 实现了动态加载子应用。

6) 实现了同时加载多个子应用。

3.3. 逻辑架构


图10. 微前端系统逻辑架构图

1) 子应用

项目按照拆分规则,拆分成多个子应用,按照qiankun的规范实现相应的生命周期函数,供基座应用调用实现子应用的加载与子应用的卸载切换;子应用信息列表接口包含标识子应用的systemId并传递此标识,结合子应用挂载的dom元素标识container组合成widgetId(systemId+container),实现共享数据模型数据的存储setState和提取getState方法。

2) 基座应用

基座应用提供整个项目的访问入口,实现了各个子应用的注册,管理子应用的加载、切换及数据通信。通过封装qiankun的数据通信方法:onGlobalStateChange、setGlobalState实现的getState和setState供子应用完成共享数据的提取与设置,实现onAppStateChange方法触发专属子应用的数据响应。根据后端接口或者配置文件提供的子应用列表信息,封装实现subRegister完成子应用的注册,最终基座应用根据浏览器访问的路径调用qiankun实现的loadMicroApp方法动态加载对应的子应用。

3) Qiankun

qiankun实现了应用的加载与切换,提供了路由问题、应用入口、应用加载的解决方案与调用方法。给出了js隔离css样式隔离应用间通信三个问题的解决方案,同时提供了预加载功能

4) 浏览器

为微前端系统提供运行环境、展示容器、数据存储载体。


3.4. 数据架构


图11. 微前端系统数据架构图

整个微前端系统关注的数据主要是:

1. 基座应用和子应用间的共享数据。

2. 子应用信息列表,包含子应用注册的相关信息。

3.4.1. 共享数据模型


图12.微前端系统共享数据模型图

子应用和基座应用采用的是共享数据模型,由基座应用在浏览器通过qiankun创建一个JS对象称 state,再由qiankun将state.msg传递给子应用,供子应用进行子应用自身的数据存取。子应用通过基座应用实现的setState(widgetId,key,value,isRW)来存储数据,通过基座应用实现的getState(widgetId,key)来读取数据。

3.4.2. 子应用数据模型

3.4.2.1. 子应用配置数据模型

应用管理服务通过可视化方式配置各项目的子应用。基座应用通过接口动态加载子应用配置数据。数据模型如下:


示例:

[

...

{

"name": "vue-app-1",

"entry": "//localhost:9091",

    "systemId": "10001",

"container": "#container1",

"activeRule": "/", // 激活规则

"defaultRegister":  "0",  // 是否为默认子应用

"routerBase": "/", // 路由前缀

"templateCode": "",  // 对应模板名

"title": "菜单子应用",

},

{

"name": "vue-app-2",

"entry": "//localhost:9092",

"systemId": "10002",

"container": "#container2",

"activeRule": "/app-vue", // 激活规则

"defaultRegister":  "0",  // 是否为默认子应用

"routerBase": "/app-vue", // 路由前缀

"templateCode": "MultipleApps",  // 对应模板名

"title": "多子应用场景-子应用1",

},

{

"name": "vue-app-3",

"entry": "//localhost:9093",

"systemId": "10003",

"container": "#container3",

"activeRule": "/app-vue", // 激活规则

"defaultRegister": "0",  // 是否为默认子应用

"routerBase": "/app-vue", // 路由前缀

"templateCode": "MultipleApps",  // 对应模板名

"title": "多子应用场景-子应用2",

},

{

"name": "vue-app-4",

"entry": "//localhost:9094",

"systemId": "10002",

"container": "#subApp",

"activeRule": "/subapp", // 激活规则

"defaultRegister":  "1",  // 是否为默认子应用

"routerBase": "/subapp", // 路由前缀

"templateCode": "Default",  // 对应模板名

"title": "默认子应用",

},

...

]

3.4.2.2. 子应用注册数据模型

微前端框架对Qiankun的子应用注册模型进行了封装,信息如下:

示例:

[

...

{

"name": "vue-app-1",

"entry": "//localhost:9091",

"container": "#container1",

"activeRule": "/",

"props":

{

            delOnAppStateChange, // 关闭监听全局数据对象变化的方法,

"routerBase": "/", //路由前缀

setState,  getState,

onAppStateChange // 供子应用监听并响应所属数据,

            "systemId": item.systemId

}

},

{

"name": "vue-app-2",

"entry": "//localhost:9092",

"container": "#container2",

"activeRule": "/app-vue",

"props":

{

            delOnAppStateChange, // 关闭监听全局数据对象变化的方法,

"routerBase": "/app-vue" //路由前缀,

setState,  getState,

onAppStateChange // 供子应用监听并响应所属数据,

            "systemId": item.systemId

}

},

{

"name": "vue-app-3",

"entry": "//localhost:9093",

"container": "#container3",

"activeRule": "/app-vue",

"props":

{

              delOnAppStateChange, // 默认传递的关闭监听全局数据对象变化的方法,

"routerBase": "/app-vue" //路由前缀,

setState,  getState,

onAppStateChange,

              "systemId": item.systemId

}

},

{

"name": "vue-app-4",

"entry": "//localhost:9094",

"container": "#container4",

"activeRule": "/subapp",

"props":

{

              delOnAppStateChange, // 默认传递的关闭监听全局数据对象变化的方法,

"routerBase": "/subapp" //路由前缀,

setState,  getState,

onAppStateChange,

              "systemId": item.systemId

}

},

...

]


4. 框架详细设计

微前端框架应用于公司各个项目时,通过基座应用实现项目的整体入口,业务菜单项展示,各个业务系统的切换。通过子应用的方式实现业务系统的独立开发,独立部署。微前端框架实现了同一页面加载多个子应用、在运行过程中通过后端接口返回的子应用信息列表动态加载子应用、子应用与基座应用间数据通信等关键特性。

4.1. 功能设计

4.1.1. 基座应用

1) 动态加载子应用

    基座应用获取子应用配置数据动态加载并注册子应用。

2) 数据通信

    封装setState(widgetId, key, value, isRW)函数供子应用数据存储。

    封装getState(widgetId, key)函数供子应用数据读取。

    封装onAppStateChange函数供子应用实现数据响应。

3) 模板管理

    基座应用根据子应用配置数据为每个页面加载指定的子应用容器模板。

    支持自定义模板。


4.1.2. 子应用

1) 生命周期函数

    实现bootstap、mount、unmount等生命周期函数供基座应用调用完成子应用的加载与切换。

2) 数据通信

    封装数据通信组件,包括setState(key, value)、getState(key)函数实现数据通信。

    通过调用onAppStateChange函数实现响应式数据通信。

3) 重构Render函数,增加微前端环境判断,添加路由前缀。

4) 子应用可以基于页面模板中子应用占据的容器分辨率,渲染不同的主题。


4.1.3. 应用管理服务

1) 项目管理

    实现项目的新增、修改、查询、删除(只有系统管理员具备删除权限)。

    实现项目子应用配置,对项目所需的子应用以子应用或页面为单位进行组合。

    实现按项目导出项目及其所有子应用信息,并支持导入,导入时根据主键ID覆盖数据并提示。

    实现根据项目ID获取子应用列表接口。

2) 应用管理

    实现子应用的新增、修改、查询、删除(只有系统管理员具备删除权限)。

    实现子应用列表导出、导入,导入时根据主键ID覆盖数据并提示。

3) 模板管理

    实现页面模板的新增、修改、查询、删除(只有系统管理员具备删除权限)。


4.1.4.应用管理服务使用指南

4.1.4.1. 第一步新建子应用容器模板

在基座应用中创建子应用容器模板,或者使用默认模板Deault.vue;模板文件路径是whayer-micro-main/-/tree/master/src/views 

4.1.4.2. 第二步配置子应用

4.1.4.3. 第三步创建项目

新建项目,配置基座应用路由,配置相应的子应用容器模板,关联具体已配置的子应用。

4.2. 关键特性设计

4.2.1. 数据总线

4.2.1.1. 共享数据存储流程

基座应用通过子应用信息列表接口获取到子应用systemId,再由基座应用传递给子应用;基座应用中容器模板挂载点的dom元素ID定位为container;由此2个数据项进行数据存储唯一标识: widgetId=systemId+container。

数据存储内容为:{widgetId,key, value, isRW} ,其中isRW值为0时表示不可读,不可写;值为1时表示可读,不可写;值为2时表示可读,可写。


    子应用调用基座应用对外暴露的setState(widgetId,  key,  value,  isRW) 方法来进行共享数据的存储;

    setState方法首先判断要存储数据的key值是否已经存在,若不存在,则直接新增该key值;

    如果共享数据对象state.msg中已存在该key,则判断本次setState调用方子应用widgetId是否与key值中的对应属性相等,如果相等,即表示该key归属于widgetId 对应的子应用,可以直接更新key对应的值;

    如果不相等,即表示在修改其他子应用的key,需要判断该key是否可以被其他子应用更新,如果key包含属性isRW值为2,表示该属性可被其他子应用修改,则更新key对应的值;

    如果key包含属性isRW值为0或1,表示该属性不能被其他子应用修改,本次setState报错:该key值不可被其他系统更新【无权限更新】。


图13. 共享数据存储流程

4.2.1.2. 共享数据读取流程

    子应用获取当前系统widgetId ,调用基座应用对外暴露的getState(widgetId ,  key) 方法来进行共享数据的读取;

    getState方法首先判断要存储数据的key值是否已经存在,若不存在,则直接报错:该key值不存在;

    如果共享数据对象state.msg中存在该key,则判断本次getState调用方子应用widgetId 是否与key值中的对应属性相等,如果相等,即表示该key归属于widgetId 对应的子应用,可以直接返回key对应的值;

    如果不相等,即表示在读取其他子应用的key,需要判断该key是否可以被其他子应用读取,如果key包含属性isRW值为1或2,表示该属性可被其他子应用读取,则返回key对应的值;

    如果key包含属性isRW值为,表示该属性不能被其他子应用读取,本次getState报错:该key值不可被其他系统访问【无权限访问】。


图14. 共享数据读取流程

4.2.1.3. 共享数据响应式流程

共享数据key对应的值发生变化,会触发基座应用中的onGlobalStateChange函数,在其回调函数中查找key的归属子应用及能访问key的子应用,找到对应响应的子应用。触发相应子应用注册的onAppStateChange函数,在各子应用的callback函数中进行相应的数据及视图的更新。


图15. 共享数据响应式流程

4.2.2. 动态加载

动态加载子应用指的是,微前端系统在运行过程中,基座应用根据后端“子应用列表接口”返回的数据,动态接入接口返回数据中包含的子应用。

微前端系统运行时,根据后端接口返回的数据进行子应用注册。

基座应用实现子应用注册方法,遍历子应用注册信息列表,通过templateCode判断本地是否存在对应templateCode的子应用模板,不存在则直接报错。若项目中所有子应用模板容器都存在,则调用qiankun的loadMicroApp(config)实现子应用加载。



图16. 子应用动态加载流程


4.2.3. 一个页面多个子应用

同时加载多个子应用指的是通过浏览器访问某个路径,在该路径对应的页面上同时展示多个子应用的内容。

注册微应用时,多个微应用需要满足相同的activeRule,分别挂载到多个不同的挂载容器 container。基座应用根据浏览器地址栏url匹配容器模板templateCode,根据templateCode匹配模板文件名,最终确定子应用容器模板。

子应用容器模板包括占位元素div所在的*.vue文件,样式写在该vue文件内。使用templateCode作为文件名,如:MultipleApps.vue,是同时包含菜单子应用和其它2个子应用的模板。


图17. 子应用容器不同模板代码结构

layout.sass样式不得涉及document等全局属性。

使用不同的子应用容器模板templateCode,所表示访问的url不一样,展示也不一样。

通过子应用容器的分辨率来确定在不同的容器模板中子应用的布局。

其中,菜单子应用的激活规则为’/’,表示始终激活的状态。


图18. 同时加载多个子应用

4.2.4. 对接应用管理服务的子应用信息列表接口


图19. 应用管理服务子应用信息列表接口地址与项目Id

其中,projectedId是在应用管理服务中创建项目并完成相应子应用配置只用生成的项目标识。url_get_applylist为部署应用管理服务的子应用信息列表接口的访问地址。


4.2.5. 子应用主题

子应用根据当前页面环境下子应用对应容器的分辨率渲染不同主题。


子应用根据常见接入的场景,实现不同分辨率下的展示效果;在接入微前端系统后,根据子应用容器分辨率的大小进行相应的渲染展示。


子应用根据不同场景,按照子应用容器宽高进行子应用主题设计,分别保存在各个主题的CSS文件中。


主题切换原理

基座应用加载子应用时,会确定子应用容器的宽高;子应用挂载时,根据所确定的宽高加载相应适配的主题,即加载对应主题的CSS文件。

// 主题适配

console.log("根据挂载节点宽高,确定主题");

console.log(this.$el.offsetHeight);

console.log(this.$el.offsetWidth);

// 匹配关系

if (this.$el.offsetWidth < 400) {

  this.addTheme(200, 200);

} else {

  this.addTheme(400, 200);

}

// 添加xxx主题

addTheme(width, height) {   

  var link = document.createElement("link"); 

  link.type = "text/css";   

  //link.id = `Theme_${width}x${height}`;   

  link.rel = "stylesheet";   

  link.href = `${   systemId ? systemId : location.origin   }/theme/css/Theme_${width}x${height}.css`;   

  document.getElementsByTagName("head")[0].appendChild(link);

}

子应用容器div的weight和height属于某个满足某个主题的条件,就调用相应的主题进行渲染。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

  • """1.个性化消息: 将用户的姓名存到一个变量中,并向该用户显示一条消息。显示的消息应非常简单,如“Hello ...
    她即我命阅读 8,940评论 0 6
  • 为了让我有一个更快速、更精彩、更辉煌的成长,我将开始这段刻骨铭心的自我蜕变之旅!从今天开始,我将每天坚持阅...
    李薇帆阅读 6,338评论 1 4
  • 似乎最近一直都在路上,每次出来走的时候感受都会很不一样。 1、感恩一直遇到好心人,很幸运。在路上总是...
    时间里的花Lily阅读 5,412评论 1 3
  • 1、expected an indented block 冒号后面是要写上一定的内容的(新手容易遗忘这一点); 缩...
    庵下桃花仙阅读 3,818评论 0 2
  • 一、工具箱(多种工具共用一个快捷键的可同时按【Shift】加此快捷键选取)矩形、椭圆选框工具 【M】移动工具 【V...
    墨雅丫阅读 3,890评论 0 0

友情链接更多精彩内容