什么是微前端
微前端的概念是由ThoughtWorks在2016年提出来的,它借鉴了服务端微服务的架构理念,核心在于将一个庞大的前端应用拆分成多个独立灵活的小型应用。核心思想
- 技术栈无关,每个微应用都可以选择适合自己团队的框架,做到不同微应用技术框架都能够整合到主应用。
- 子应用间状态隔离,互不影响。包括:CSS隔离,避免全局样式污染;js运行环境隔离,避免冲突,同时还需要有可靠的通信机制来进行应用间的通信。
- 模块间应该做到高内聚低耦合,每个模块专注于自己特定领域业务,然后共同组成一个整体展现给用户,降低用户使用系统时的切换成本。
- 应用间独立开发、部署,每个应用可以由不同团队迭代维护,提升开发和部署效率。
为什么不直接用iframe
如果不考虑体验问题,iframe几乎是完美的微前端解决方案,天然的js、css、dom隔离。但也正因为它的隔离性难以突破,极大增加了开发难度。
- UI完全隔离,DOM结构不共享。例如子应用难以实现类似全局弹窗的功能。
- 宿主环境与iframe环境全局上下文完全隔离,内存变量不共享。造成宿主与iframe间通信复杂。
- 性能问题,iframe的每次加载都会触发浏览器重加载整个页面所需资源,用户体验差。
- url不同步,浏览器刷新后状态丢失,前进后退能力不起作用,需额外处理。
由于存在以上问题,所以微前端架构大多没有直接采用iframe方案,但是依然有部分框架会利用iframe的特性做沙箱隔离。
微前端关键技术
微前端的实施主要包含:路由管理、全局状态管理及通信、DOM隔离、沙箱隔离。
路由管理
从形式上看,微前端由主应用负责整个应用框架的渲染,随着路由切换,会触发不同微应用的加载,这就涉及到了路由管理。
中心化路由
即在主应用维护全局路由表和菜单,将微应用的路由信息合并到全局路由表中。由主应用决定加载某个具体微应用。中心化路由便于主应用集中管理路由,减少应用间潜在的路由冲突,但是也导致了微应用在新增路由时需要变更主应用。
动态路由
由微应用维护自己的路由,然后动态传递给主应用。可以是通过主应用定制接口规范,以请求方式获取各微应用路由表,也可以在各自的入口文件暴露路由信息给主应用,具体还得看框架的支持情况。
全局状态管理及通信
由于微前端概念将一块整体业务分成多个子业务模块,所以全局状态管理和通信就很有必要了。
一般来说,成熟的微前端框架本身就提供了桩体管理和通信机制,比如qiankun提供了基于actions和props状态管理和通信机制、wujie提供了基于window、eventBus的通信机制。
除此以外,对于React微前端应用,可以采用通用的React状态管理库Redux,Vue微前端可以采用Vuex来做状态管理。
DOM隔离
DOM隔离分为CSS隔离和节点隔离。
CSS同名样式间是会互相影响的,各微应用间、微应用与主应用间如果不采取策略的话很容易在命名上冲突,导致相互影响。新项目可以通过命名规范约定的方式来避免命名冲突。对于旧有项目整合,要确保应用之间样式互不影响,就需要对应用间的样式进行隔离。
shadow DOM方案
Shadow DOM允许将Shadow DOM树附加到常规的DOM树中,可以像操作正常节点一样来操作Shadow DOM,包括为整个Shadow DOM添加自己的样式,且Shadow DOM内部的元素始终不会影响到外部元素。
概念
Shadow host
:一个常规的DOM节点,Shadow DOM会附加到这个节点上。Shadow tree
:Shadow DOM内部的DOM树结构。Shadow boundary
:Shadow DOM影响范围。Shadow root
:Shadow tree根节点。
用法
// 创建一个shadow host
const host = document.querySelector("#host");
// 创建一个shadow dom
// mode: open/closed,控制能否通过ele.shadowRoot方式获取节点
const shadow = host.attachShadow({ mode: "open" });
const span = document.createElement("span");
span.textContent = "I'm in the shadow DOM";
// 为shadow dom添加shadow tree
shadow.appendChild(span);
// 添加样式
const style = document.createElement('style');
style.textContent = '.title{color:red}';
shadowRoot.appendChild(style);
类似以上示例,只需要将每个微应用都包裹到各自的shadow dom中,即可保证主、微应用间节点和样式不会相互影响。
CSS Module
通过在样式后缀添加hash方式,来实现主应用和微应用间的样式隔离,需要我们在微应用工程开启css module能力。改造已有项目,工程量较大。
Scoped CSS方案
通过样式作用域与权重来达到样式隔离。具体做法是:在挂在每个子应用处,命名唯一选择器作为标识,然后给所有的子应用样式添加对应的scope。
// 主应用
.title {color: red};
// 微应用
.micro-app-1 .title {color: blue};
<!-- 主应用挂载微应用处 -->
<div class="micro-app-1">
<!-- 微应用节点 -->
</div>
通过编译的时候遍历每个微应用的样式属性,给每个样式选择器前添加微应用专属的scope,这样微应用的样式权重就会提升,且不会对主应用样式造成影响。
目前看来Shadow DOM方案更被广泛用来做样式隔离的主流方案,主要是因为实现隔离比较彻底,改造成本比较小。
JS沙箱
JS沙箱隔离目的是尽可能避免微应用运行对全局环境造成影响。
JS代码执行
控制JS执行时机
常规的js加载执行都是在script标签内完成的,我们没有办法精准控制其执行时机。
要实现沙箱,需要控制沙箱的开启和关闭,就需要精确掌握脚本的执行时机,所以就需要寻找一种合适的能手动执行代码的方法。常规的有:eval()
、new Function()
。这两种在不同的框架都有使用,不过后者在安全性和性能方面占优。
创建运行环境
比较通用的做法是创建个立即执行函数,如qiankun底层引用import-html-entry包完成可执行js的拼接并将其包裹在立即执行函数内部,同时指定具体运行的上下文环境。
wujie则利用iframe天然沙箱隔离特性,将子应用实例输入到iframe内运行。
创建沙箱方案
先看一下qiankun创建沙箱环境的代码片段,运行环境支持proxy就优先创建基于proxy的沙箱,不支持就创建快照沙箱。
快照沙箱SnapshotSandbox
快照沙箱核心是
- 维护两个变量
windowSnapshot
保存微应用激活时对应的window属性快照。modifyPropsMap
保存微应用卸载时的已变更属性。
- 在active阶段遍历window的变量,保存为
windowSnapshot
。 - 在inactive(deactive)阶段再次遍历window上的变量,并和
windowSnapshot
做对比,将不同的属性存到modifyPropsMap
变量中,并将window已变更的属性恢复到windowSnapshot
的属性状态。 - 当再次切换微应用时,把
modifyPropsMap
的变量恢复回window上,实现沙箱的切换。
快照沙箱的实现机制决定,如果同时运行多个微应用,则很有可能造成window属性混乱,因此基于快照沙箱不能用于需要同时运行多个微应用的业务场景。
Proxy沙箱
ES6新增Proxy API后,可以方便地创建一个对象的代理,而不用像快照沙箱那样遍历所有window属性。
proxy方案又可以细分为
- LegacySandbox:基于proxy方案的快照沙箱的平替方案,不能支持需同时运行多个微应用场景。
- ProxySandbox:支持同时运行多个微应用的最终解决方案。
qiankun源码对LegacySandbox态度也是有下线计划的,因此该方案就不再讲述了。
ProxySandbox沙箱源码是比较复杂的,全面处理了各种边界情况,其核心思想即创建一个对window对象的代理:proxyWindow。然后将该对象作为上下文环境传入到微应用中运行。
可以发现:和快照沙箱相比,并没有记录属性变化和状态恢复的操作。那是因为实际运行时,所有的操作都是基于proxyWindow变量来的,并不会对window变量造成影响,自然也就没必要恢复变更操作。
都是基于proxy实现的方案,ProxySandbox基本可以替代LegacySandbox。
但是由于proxy没办法被完全polyfill,SnapshotSandbox可以替代在低版本浏览器使用。
VM沙箱
目前只有garfish使用该策略,VM沙箱使用类似于node的vm模块,通过创建一个沙箱,然后传入需要执行的代码。
iframe沙箱
目前只有wujie利用iframe来实现js的沙箱隔离。无界将子应用的 js 放置在 iframe(js-iframe)中运行,实现了应用之间 window、document、location、history的完全解耦和隔离。
同时,无界在底层采用 proxy + Object.defineproperty 的方式将 js-iframe 中对dom操作劫持代理到webcomponent shadowRoot 容器中,影响收敛在各自应用内。
主流微前端框架对比
对比
框架 | single-spa | qiankun | wujie | micro-app | garfish |
---|---|---|---|---|---|
简介 | 开源社区,微前端鼻祖 | 蚂蚁,基于single-spa封装 | 腾讯,基于webComponent和iframe | 京东,基于WebComponent | 字节 |
微应用加载 | 核心是一种运行时协议,定义了主应用如何配置微应用,从而感知微应用的加载和卸载时机。 | 基于但区别与spa加载微应用方式,它采用import-html-entry 方式加载微应用 |
基于WebComponent容器和iframe沙箱来实现微前端组件式加载。 | 借鉴WebComponent的思想,通过CustomElement结合自定义的shadow dom,将微前端封装成一个组件,来加载微应用。 | Garfish.loadApp控制子应用的加载与销毁。 |
DOM隔离 | 无 | 基于shadow dom |
iframe通过proxy的方法将dom劫持到shadow dom上 | 基于shadow dom实现 | 基于VM沙箱,通过劫持document.createElement和appendChild方法实现。 |
CSS隔离 | 无 | 支持shadow dom和scoped css | 基于iframe劫持和shadow dom | 默认基于scoped css,也支持shadow dom,但官方提示对React支持不好,慎用 | 基于shadow dom |
JS隔离 | 无 | 支持快照和proxy沙箱 | 基于iframe | 基于proxy | 支持快照和proxy沙箱 |
状态管理与通信 | 无 | 提供了actions全局状态管理与基于props注入通信。 | + props注入 + 基于iframe同域下的window + 去中心化的eventBus |
window.microApp上挂在dispatch、getData、setData等丰富api用于通信 | 提供Garfish.channel,基于EventEmitter2实现应用间通信 |
内置生命周期 | 无 | 完整的生命周期 beforeLoad、beforeMount、afterMount、beforeUnmount、afterUnmount |
更完整的生命周期 beforeLoad、beforeMount、afterMount、beforeUnmount、afterUnmount、activated、deactived、loadError |
较丰富的生命周期 created、beforemount、mounted、unmount、error |
比较实用的生命周期 mount、unmount、show、hide |
接入成本 | 高、框架过于原始,什么都要自行封装 | 高,生命周期、路由、静态资源路径配置、weibpack配置都需适配。 | 低 | 低 | 高 |
优点 | 自定义程度高 | 使用最广,社区活跃,很多踩坑解决方案 | + 虽然CSS是基于shadow dom,但iframe通过proxy的方法将dom劫持到shadow dom上,达到彻底隔离 + vite支持友好 |
使用简单,代码无入侵,不依赖其他三方库 | 无 |
坑点 | 框架过于原始,什么都要自行封装 | + css沙箱隔离不完全,严格模式基于shadow dom,第三方组件的弹窗默认挂到body下面,这样弹窗中的自定义样式会失效,需要手动设置挂载阶段 + 样式隔离基于scoped css,对于同名样式依然存在问题 + 不支持vite |
+ 内存开销较大,承载js的iframe是隐藏在主应用的body下面,常驻内存 + 不同技术栈需接入不同的包版本适配 |
+ 样式隔离基于scoped css,对于同名样式依然存在问题 + 浏览器兼容,WebComponent + vite适配成本高 |
+ 不同技术栈需接入不同的包版本适配 |
选型建议
- 微前端主要用来解决大型项目的管理、迭代开发、部署问题,简单项目不需要
- 对于简单嵌入进应用的场景,iframe就够了
- 基于上面对各个微前端框架的对比
- single-spa不建议采用
- 基于上手和接入成本,wujie和micro-app推荐
- qiankun上手成本高,但社区活跃,遇到问题更容易解决