什么是微前端
我先给微前端下一个比较精确的定义,微前端是多个小型前端应用聚合为一的应用,它们彼此项目分离但又运营聚合。
其中最核心的一点就是解耦,项目层面的解耦。
当下前端领域,单页面应用(SPA)是非常流行的项目形态之一,而随着时间的推移以及应用功能的丰富,这破玩意会越来越复杂,会变成一个所谓巨石系统,开发成本很高,对工程师的心智损耗也比较高。比如潇哥跟我说咱这个cms就有的地方不敢改,改了不知道哪就坏了,牵一发动全身。 又或者比如说这个同排期下cms同时开发两个需求,那无论是开发还是测试都会很麻烦。并且微前端的开始越早越好,等真成了巨石系统就很难拆分了。
在此基础上带来的一个功能呢,是技术栈的更替,比如cms我们使用的是vue 2.x,现在想使用新出的vue 3,如果做大规模的重构的话,成本非常高,微前端方案呢可以做到使用新技术的同时兼容老的技术栈。毕竟我们在项目层面上解耦了嘛,俩不同的项目你爱用啥用啥呗。
微前端的意义就是将这些庞大应用进行拆分,并随之解耦,每个部分可以单独进行维护和部署,提升效率。
微前端方案
大概是以下几种
| 方案 | 描述 | 优点 | 缺点 |
|---|---|---|---|
| MPA + 路由转发 | 通过Nginx配置location来实现不同路径映射到不同应用,例如www.abc.com/app1对应app1,www.abc.com/app2对应app2,这种方案本身并不属于前端层面的改造,更多的是运维的配置。 | 简单,快速,易配置 | 应用间跳转会触发浏览器刷新,影响体验应用间跳转 加载时间较长不支持 同屏多应用 |
| iframe嵌套 | 父应用单独是一个页面,每个子应用嵌套一个iframe,父子通信可采用postMessage或者contentWindow方式 | 实现简单,子应用之间自带沙箱,天然隔离,互不影响 | 组件间通讯,cookie的限制ui不同步,比如dialog无法铺满全屏前进后退键无法使用 |
| Web Components | 每个子应用需要采用纯Web Components技术编写组件,是一套全新的开发模式 | 每个子应用拥有独立的script和css,也可单独部署 | 对于历史系统改造成本高子应用通信较为复杂易踩坑 |
| 组合式应用路由分发 | 每个子应用独立构建和部署,运行时由父应用来进行路由管理,应用加载,启动,卸载,以及通信机制 | 纯前端改造,体验良好,可无感知切换,子应用相互隔离 | 侵入式开发需要解决子应用的 样式冲突,变量对象污染,通信机制等技术点 |
技术方案层面没有高低,只有优劣。
最后我们决定使用这个组合式应用路由分发,我习惯简称为基座式微前端,原因大概就是比较平衡,体验好,而且比较可控,解决方案也多。
主应用加载子应用的流程
比如我们输入如下log.feihua100.com/course-mana…
- 主应用的加载(网络请求、js执行、页面渲染)
2. 主应用根据
路由获取到子应用名:course-manage
{
name: 'course-manage',
}
- 主应用获取到
course-manage的配置
{
name: 'course-manage',
entry: process.env.NODE_ENV === 'development' ? '//localhost:7799/' : '/course-manage/',
activeRule: '/course-manage',
// 子应用挂载的div
container: '#subapp-viewport',
props: {
routerBase: '/course-manage',
store
}
}
- 主应用加载子应用
course-manage的静态资源(html、css、js)
- 子应用接管
路由、通过地址栏的路由匹配到相应的vue文件,开始渲染course-manage应用在container(通过id查找的div)中
微前端实现原理
微前端的本质是分治的处理前端应用以及应用间的关系,那么更进一步,落地一个微前端框架,就会涉及到三点核心要素:
-
子应用的加载;
-
应用间运行时隔离;
-
应用间的通讯;
我们会通过讲解qiankun这个蚂蚁金服开源的稳定微前端框架的api说明和源码讲解来进行以上三点的讲解
qiankun我们可以认为是由single-spa和import-html-entry两个库结合,并进行二次开发的产物
registerMicroApps 方法
首先是qiankun的核心api之一的registerMicroApps 方法
这个函数的作用是注册子应用,并且在子应用激活时,创建运行沙箱,在不同阶段调用不同的生命周期钩子函数。
它接收一个数组,是子应用的配置。
实际上registerMicroApps做的事儿基本上就是把数组去重,然后遍历数组分别将每个子应用的配置转交给single-spa中注册子应用的核心函数registerApplication,我们之前说过qiankun的一部分核心是single-spa嘛。
registerApplication的入参分别为
-
name- 子应用名称 -
activeRule- 子应用的激活规则 -
callback- activeRule 激活时的回调 -
props- 主应用需要传递给子应用的数据
registerMicroApps 方法很好理解,就是给主应用注册子应用的方法,接下来是qiankun另一个核心方法
start 方法
start 函数负责初始化一些全局设置,然后启动应用
通过看源码可以看到qiankun是支持一些prefetch预加载的。
然后我们可以看到全局化配置是存储在了frameworkConfiguration中的,并进行了一个polyfill操作,我们看下这个方法的详情:
从这里就可以看出来,我们的沙箱内容,是通过window.Proxy来做的的,如果没有的话就使用snapshot快照。
最后是启动应用其实也是使用的single-spa的start方法。
loadApp方法
我们在上面注册和初始化的流程中,我们知道当当前的url符合activeRule会执行callback,我们看下callback具体是什么
核心就是返回了loadApp方法的返回,这个就是运行时的核心方法了,当我们匹配到子应用时,如何加载子应用,我们来看下loadApp方法的内容
太长了一段段看
前面就是一些变量的定义和配置。266行这里是调用了importEntry方法,其实就是import-html-entry这个库的核心方法
importEntry和importHTML方法
我们来看一下首先是importEntry方法

我们可以看到当entry是一个字符串的时候是直接调用了importHtml这个核心方法,然后当entry被配置的时候返回了一大坨这块可以近似的理解为importHtml方法的拓展,所以可以近似理解为调用了importHtml方法我们来看importHtml方法。

上边这一大段都是配置和兼容哈,可以看出来fetch方法、获取publicPath,获取template的方法都可以自定义哈,这个都不管,然后直接到return,我们分步骤讲解
-
我们看到我们的
entry是直接使用fetch来请求的,这就是为什么qiankun需要子应用跨域 -
然后返回的是一个
html模板将他字符串化 -
拿到
assetPublicPath也就是静态资源地址,默认的getPublicPath其实就是取entry的上级路由,所以如果静态资源地址不在entry的上级路由要自定义一下getPublicPath方法 -
然后是
processTpl方法,这个方法就是从一个混乱的html模板中解析出所有的js脚本——包括外联地址和内联代码块,所有的link标签的地址即样式地址,这里没有内联,因为样式最后都要转换为内联样式,最后processTpl方法的返回为:
- template:经过处理的脚本,link、script 标签都被注释掉了
- scripts:[脚本的http地址 |代码块],
- styles:[样式的http地址]
- entry:入口脚本的地址,要不是标有 entry 的 script 的 src,要不就是最后一个 script 标签的 src
这个方法其实很有意思,可以学到一些link和script的写法,但是太繁琐了就不讲了。
-
使用
getEmbedHTML方法处理html模板,其实就是使用fetch远程加载所有的外部样式,然后将对应的外部样式替换为内联样式,最后返回处理过的html模板。 -
最后的返回为一个对象,属性为:
- template:处理过的html模板
- assetPublicPath:静态资源地址
- getExternalScripts:获取前面解析的脚本数组的方法
- getExternalStyleSheets:获取前面解析的样式表数组的方法
- execScripts:执行该模板文件中所有的 JS 脚本文件,并且可以指定脚本的作用域 - proxy 对象
上面这一部分呢,就可以认为是子应用的加载这一部分的关于子应用资源加载原理讲解,接下来是应用间运行时隔离的部分。
回到loadApp方法

然后是单例模式的判断,单例模式就是新的子应用挂载行为会在旧的子应用卸载之后才开始。
然后是一段沙箱模式的配置,后面会讲一下实现原理。

接下来就是将子应用的 html 通过模板和其他方法的处理之后,挂载在了主应用中我们配置的容器里,这里也可以看到,qiankun是带一个子应用的loading机制的。
沙箱机制
前面这部分我们已经将子应用的html处理过之后放到了我们主应用的容器中,而我们知道前端页面很大部分的主体都在于js的运行,而js的运行大部分要基于我们的window对象,但当我们主应用和子应用并行的时候,甚至是多个子应用并行的时候,我们的window对象很有可能会被互相污染。
这个时候qiankun使用了一种子应用状态管理方案,也就是js沙箱运行环境,有三种方式,分别是ProxySandbox、LegacySandbox 和 SnapshotSandbox。
SnapshotSandbox
首先是低版本浏览器不支持window.Proxy属性时,使用SnapshotSandbox沙箱,作为一个class我们来研究一下它的属性
| 属性 | 意义 |
|---|---|
name | 沙箱名称 |
proxy | 代理对象,此处为window 对象 |
sandboxRunning | 当前沙箱是否在运行中 |
windowSnapshot | window 状态快照 |
modifyPropsMap | 沙箱运行期间被修改过的 window 属性 |
active | 激活沙箱,在子应用挂载时启动 |
inactive | 关闭沙箱,在子应用卸载时启动 |
constructor | 构造函数,创建沙箱环境 |
SnapshotSandbox 的主要方法就两个,也是其核心功能的实现。
首先是激活沙箱的方法active,这个时候将记录一个window的快照,同时如果之前沙箱存储过状态的话还原沙箱的状态:

另一个就是关闭沙箱的方法inactive,这个时候通过快照还原 window 对象,同时记录沙箱的状态变更:

SnapshotSandbox 沙箱就是利用快照实现了对 window 对象状态隔离的管理。相比较 ProxySandbox 而言,在子应用激活期间,SnapshotSandbox 将会对 window 对象造成污染,属于一个对不支持 Proxy 属性的浏览器的向下兼容方案。
LegacySandbox
然后是LegacySandbox沙箱,作为一个class我们来研究一下它的属性
| 属性 | 意义 |
|---|---|
addedPropsMapInSandbox | 沙箱期间新增的全局变量,用于 关闭沙箱时还原全局状态 |
modifiedPropsOriginalValueMapInSandbox | 沙箱期间更新的全局变量,用于 关闭沙箱时还原全局状态 |
currentUpdatedPropsValueMap | 持续记录更新的(新增和修改的)全局变量的 map,用于在激活沙箱 时还原沙箱的独立状态 |
name | 沙箱名称 |
proxy | 代理对象,可以理解为子应用的 global/window 对象 |
sandboxRunning | 当前沙箱是否在运行中 |
active | 激活沙箱,在子应用挂载时启动 |
inactive | 关闭沙箱,在子应用卸载时启动 |
constructor | 构造函数,创建沙箱环境 |
实际上来说由于LegacySandbox服务于单例模式,其核心实现在大方面上和SnapshotSandbox是差不多的,我们来看active和inactive方法。

实际上我们可以看到也是类似于一个快照的形式。区别在于使用window.Proxy来做代理,这样就能在get和set的时候做监听来变更记录了:

通过源码我们可以看出来,其实LegacySandbox模式实际上还是直接操作了window对象,只不过是子应用之间不会造成污染,而父子应用之间是会造成window污染的。
这里的proxy对象就是上面execScripts方法使用的参数,它可以规定js执行的上下文。
最后来看一下多实例沙箱ProxySandbox
ProxySandbox
还是先看一下类中的属性:
| 属性 | 意义 |
|---|---|
updatedValueSet | 记录沙箱中更新的值,也就是每个子应用中独立的状态池 |
name | 沙箱名称 |
proxy | 代理对象,可以理解为子应用的 global/window 对象 |
sandboxRunning | 当前沙箱是否在运行中 |
active | 激活沙箱,在子应用挂载时启动 |
inactive | 关闭沙箱,在子应用卸载时启动 |
constructor | 构造函数,创建沙箱环境 |
ProxySandbox最大的特点就是为了支持多实例场景,ProxySandbox不直接操作window对象,而是复制了一个fakeWindow作为代理的对象:

同时因为不直接操作window了也不需要做还原和重置了:

而updatedValueSet会同步的记录对fakeWindow的操作:


那么我有一个问题,既然父子应用都不会互相污染window了,那么这个updatedValueSet还有意义吗?
总之ProxySandbox 是最完备的沙箱模式,完全隔离了主子应用的状态。
上面是关于应用间运行时隔离中关于window的部分,我们知道我们除了window上的东西还有一些需要相互隔离的副作用,比如事件监听、定时器等。
监听劫持

主要内容在patchAtMounting方法中

这里主要是三种监听劫持,分别为:
-
patchTimer(计时器劫持) -
patchWindowListener(window 事件监听劫持) -
patchHistoryListener(路由监听)
patchTimer
首先是计时器劫持patchTimer:

这里的思路比较简单哈,就是对计时器做了一层代理,来找个人讲一下这里的原理吧?
主要就是做了一个状态池,维护了id以便于卸载子应用的时候统一释放。
patchWindowListener和patchHistoryListener和patchTimer原理差不多就不一一讲了
这其中还有一些其他的副作用的装卸,就不一一讲了。
样式隔离
上面都是js沙箱隔离的内容,下面我们来讲一下css样式隔离的方法。
qiankun提供了两种css样式隔离方式:
-
Shadow DOM
-
CSS Scoped
我们分别来讲一下
Shadow DOM
首先是Shadow DOM,Shadow DOM 允许将隐藏的 DOM 树附加到常规的 DOM 树中——它以 shadow root 节点为起始根节点,在这个根节点的下方,可以是任意元素,和普通的 DOM 元素一样,隐藏的 DOM 样式和其余 DOM 是完全隔离的,类似于 iframe 的样式隔离效果。

具体的不讲了,没啥意思,后面我会说为啥没意思
CSS Scoped
接下来是CSS Scoped,这名我乱起的,qiankun的用名叫实验性沙箱,其实就是给子应用全部的样式增加后缀标签,比如:div[data-qiankun-microName]。
同时将css再转成这种形式的选择器,就可以做成css沙箱了。
但是这两种方式其实都解决不了一个问题,也就是弹窗的问题,弹窗我们大部分都是绑定在根节点的,也就是跳出了这个沙箱,导致弹窗的样式有问题。
这个时候我们可以将弹窗手动的绑到子应用中,解决这个问题。
如果我们不用css沙箱,用webpack能不能解决样式隔离的问题呢?
远程请求补丁
我们上面讲述的都是mount,和unmount生命周期的内容,但是其实我们有一些样式文件或者js文件是使用的远程请求的方式append到页面中的,这里面就又有一层隔离的问题了。
qiankun的解决方案和上述的一些解决方案同出一辙:增强 appendChild 和 insertBefore 方法,并添加了一些其它的逻辑,让其根据是否是子应用决定 link、style、script 元素的插入位置是在主应用还是微应用 ,并且劫持 script 标签的添加,支持远程加载脚本和设置脚本的执行上下文,也就是前面说的Proxy。代码就不讲了比较复杂。

以上我们就大概完成了应用间运行时隔离这一部分的讲解。
应用间的通讯
最后是关于应用间通讯的问题,这个可以通过qiankun的api完成,是实战的内容,可以在企业级微前端实战这篇文章中看到方案。
总结
这次分享比较长哈,大概的流程就是以下内容
-
介绍了一下
微前端是什么 -
比较了一下
微前端方案的优劣 -
讲述了一下我们使用的
微前端方案 -
讲述了我们方案中
主应用加载子应用的一个流程 -
最后通过对
qiankun这个umi组的库的解析,在源码层简单的讲述了一下原理
希望这次分享能对大家的工作产生一些帮助。