微前端
Why(为什么使用微前端?)
- 为了平稳升级旧项目,并且可在该项目工程上可持续迭代加新功能。
- 为了保证该项目有持续的生命力,不会变成一个遗产项目
- 为了维持技术的多样应,如原生 js、jq、vue、react 可接入同一工程
- 为了保障多团队开发,统一入口管理和项目代码整合度
- 为了将巨石应用拆分成各个单独的模块,能保证其独立开发、独立运行、单独部署
最终目的是要求:各个业务模块之间隔离、最好技术栈无关、具备独立开发、独立部署和可以增量扩展迁移的特点。
What(什么是微前端?)
微前端:是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将 WEB 应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。各个前端应用还可以独立运行,独立开发、独立部署。微前端不是单纯的前端框架或工具,而是一套架构体系,而这套架构体系也有很多的实现方式。
对比:普通单体应用 vs 多页应用/多个单体应用 vs 微前端应用
普通单体应用
多页应用/多个单体应用
微前端应用
Where(什么场景下适用微前端?)
理想场景下:
- 大型复杂企业级应用: 拥有多个相对独立功能模块或产品线(如大型电商后台、ERP、CRM、SaaS 平台)。
- 需要整合遗留系统: 将老旧的(可能是不同技术栈的)前端逐步替换或集成到新框架中。
- 多团队协作开发: 需要多个团队并行开发不同功能,且希望团队拥有技术选型自由。
- 需要独立部署能力: 要求不同功能模块能独立上线,不影响其他部分。
不适用或谨慎的场景:
- 小型或简单应用: 引入微前端会增加不必要的复杂度和开销。
- 对首次加载性能极其敏感的应用: 动态加载和集成可能带来额外开销(可通过优化缓解)。
- 没有明确功能边界或模块高度耦合的应用: 难以拆分。
- 团队规模小、技术栈统一且沟通顺畅: 单体应用可能更高效。
- 缺乏必要的基础设施和工程化能力。
When(何时引入微前端?)
触发时机:
- 现有单体前端应用变得难以维护、构建缓慢、团队协作效率低下时。
- 需要引入与现有技术栈不同的新功能/模块时。
- 计划对大型应用进行大规模重构或技术栈升级时(可作为过渡策略)。
- 产品需要支持不同团队独立负责不同子产品/模块,并希望快速迭代时。
实施策略:
- 增量采用: 最常见。从现有单体中逐步拆分出模块成为微应用,或在新功能中使用微前端。
- 绿地开发: 全新项目,从一开始就按微前端架构设计(需评估风险)。
- 评估成本收益: 明确引入微前端要解决的具体问题,评估带来的复杂度增加是否值得。
Who(谁来负责/参与?)
架构师/技术负责人:
- 设计整体架构(集成方式、通信机制、路由方案、共享策略)。
- 制定技术规范和标准(如微应用接口、通信协议)。
- 选择合适的技术方案和工具链。
开发团队:
- 基座/容器团队: 负责开发维护主应用,提供核心服务(路由、加载、通信总线、共享库管理)。
- 微应用团队: 独立负责各自微应用的开发、测试、构建、部署(端到端职责)。
- 遵守约定的接口和规范,确保集成兼容性。
运维/平台团队:
- 搭建支持独立部署的 CI/CD 流水线。
- 提供微应用注册、发现、监控的平台或基础设施。
- 管理共享依赖的部署和版本控制。
测试团队:
- 制定集成测试策略(微应用间、微应用与基座间)。
- 进行端到端测试,确保整体用户体验一致。
- 支持微应用的独立测试。
How(如何实现微前端?)
iframe 方案
在正常的项目开发中,如果一个项目中嵌入另外一个项目的页面,普遍的思路都是 iframe,而且 iframe 本身具有天然的隔离特性,不需要考虑各个项目之间 css、js 的冲突问题,但是正因为它的隔离性无法突破,从而导致应用之间上下文无法被共享,造成开发、产品体验的问题:
- URL 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用;
- UI 不同步,DOM 结构不共享,iframe 内部弹窗浮层不能展示在页面中心、遮罩只能遮住 iframe;
- 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。iframe 内部错误也无法监控
- 页面加载速度慢;每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程
基于 Nginx 路由分发
方案:通过路由将不同的业务分发到不同的、独立的前端应用上。通常使用 HTTP 服务器的反向代理来实现。
例如www.abc.com/app1对应app1,www.abc.com/app2对应app2,这种方案本身并不属于前端层面的改造,更多的是运维的配置,当然这么做优点十分明显了,简单,快速,易配置。缺点其实也和iframe差不多,iframe有的缺点他都有,比如在切换应用时触发发页面刷新,项目之间通信不易
基于 NPM 的集成
将子应用封装成 npm 包,通过组件的方式引入,在性能和兼容性上是最优的方案,但却有一个致命的问题就是版本更新,每次版本发布需要通知接入方同步更新,管理非常困难。而且也不可能成为一种长期维护的架构选择,随着时间的推移,系统越来越臃肿,不同 npm 之间的管理会越来越困难,特别是如果还有关键人员的变动的话。
基于 webpack5 的 模块联邦技术
webpack 提出 Module Federation,模块联邦技术,这个技术允许在多个 webpack 编译产物之间共享模块、依赖、页面甚至应用。每个构建都充当一个容器,也可将其他构建作为容器。通过这种方式,每个构建都能够通过从对应容器中加载模块来访问其他容器暴露出来的模块。简单来说就是:可以使用别人共享的模块,自己也可以共享模块给别人,这样可以真正的让项目达到微模块的级别。不过模块联邦缺陷也非常明显,并不是现在主流的微前端框架,一是对 webpack5 强依赖,老旧项目不友好,二是也没有有效的 css 沙箱和 js 沙箱,需要靠用户自觉
微前端框架
single-spa
2018 年,第一个微前端工具 single-spa 在 github 上开源,single-spa 是一个用于前端微服务化的 JavaScript 前端解决方案。single-spa 的核心就是定义了一套协议。协议包含主应用的配置信息和子应用的生命周期,通过这套协议,主应用可以方便的知道在什么情况下激活哪个子应用。简单来说:子应用之间完全独立,互不依赖。统一由基座工程(主应用)进行管理,按照 DOM 节点的注册、挂载、卸载来完成。
当前流行的大量框架都是 single-spa 的上层封装,但是如果作为生产选型,single-spa 提供的是较为基础的 api,应用在实际项目中需要进行大量封装且入侵性强,使用起来不太方便。
目前市面上常见的微前端框架
- 阿里的 qiankun
- 京东的 Micro-app
- 腾讯的无界
具体实现细节虽然各自有差异,但是总体架构基本都是主应用(基座应用)---子应用这种方式
常见微前端框架基础组成
微前端框架需要处理的两个问题
微前端框架都面临两大共性问题,如果已经解决,那就说明基本可用了:
- 应用的加载与切换。包括路由的处理、应用加载的处理和应用入口的选择。
- 应用的隔离与通信。这是应用已经加载之后面临的问题,它们包括 JS 的隔离(也就是副作用的隔离)、样式的隔离、也包括父子应用和子子应用之间的通信问题。
解释:简单来说,之前使用 iframe,我们不用考虑太多,直接主应用中,通过 iframe 内嵌子应用就行了。那现在使用的这些微前端框架,在不使用 iframe 的情况下,需要想一种办法,通过主应用加载到子应用,并且把子应用的内容加载到主应用的页面进行显示。而且在实现这些的同时,还不能造成主应用和子应用之间的冲突,也就是所谓的 js 隔离和样式隔离。而且最好还能够实现应用之间的通信,这样就可以弥补使用 iframe 所带来的缺点。
所以如何实现将子应用加载到主应用,并且把子应用的内容加载到主页面进行的显示的呢?那无非就是:
- 需要一个地址加载到子应用,也就是所谓的路由
- 将 URL 地址读取的子应用内容,渲染加载出来
加载子应用
- 基于 single-span 实现
通过注册微应用方式(registerMicroApps),把路由 path 和子应用关联起来,跳转对应路由时,微应用就会被插入到指定的 container(dom)中。同时一次调用微应用暴露出的生命周期钩子。微应用里可以在定义子路由。
import { registerMicroApps, start } from 'qiankun'
registerMicroApps([
{
name: 'app-vue2-demo', //子应用名称,唯一
entry: '//localhost:4001', //子应用地址
container: '#subapp-container', // 子应用挂载的div
activeRule: '/app-vue2-demo' //子应用激活规则
}
])
// 启动 qiankun
start()
- 基于 Web Components
先在主应用里自己定义路由,然后在路由组件里使用 Web Components,子应用里可以再定义子路由。
路由配置:
// 路由配置
{
//路由路径最好是非严格匹配
path: "/app-vue2-demo*",
name: "Vue2DemoPage",
component: () => import("@/views/Vue2DemoPage.vue")
}
对应页面:
<template>
<div>
<!--
name(必传):应用名称
url(必传):应用地址
baseroute(可选):基座应用分配给子应用的基础路由
-->
<micro-app
name='app-vue2-demo'
url='http://localhost:4001/'
baseroute='/app-vue2-demo'
>
</micro-app>
</div>
</template>
渲染
最开始single-spa给出的加载方案是JS Entry,简单来说,就是将子应用资源打成一个entry script,但是这个方案限制很多,比如子应用的所有资源打包到一个 js bundle 里,包括 css、图片等资源。这样除了打出来的包可能体积庞大之外的问题之外,资源的并行加载等特性也无法利用上。
qiankun在此基础上封装了一个应用加载方案,即 HTML Entry ,HTML Entry 则更加灵活,直接将子应用打出来 HTML作为入口,主框架可以通过 fetch html 的方式获取子应用的静态资源,同时将 HTML document 作为子节点塞到主框架的容器中。这样不仅可以极大的减少主应用的接入成本,子应用的开发方式及打包方式基本上也不需要调整
简单来理解:实际上就是通过上一步路由加载,找到对应的子应用HTML,然后ajax fetch读取...然后解析,然后将需要的内容再放到主应用上渲染
JS沙箱隔离
当然内容加载进来之后,肯定是会有冲突的,这种情况类似于:基座应用(主应用)向window上添加一个全局变量:window.globalStr = "hello 基座";,如果此时子应用也有一个相同的全局变量:globalStr='hello 子应用',此时就产生了变量冲突,基座应用的变量会被覆盖。
所以,JS 沙箱做的事情可以用两句话概括:
- 为每一个子应用创建一个专属的 “window 对象” (不是真的 window 对象);
- 执行子应用时,将新建的 “window 对象” 作为子应用脚本的全局变量,子应用对全局变量的读写操作都作用到这个 “window 对象”中。
实现思路:
- 快照沙箱:简单来说,就是在应用沙箱挂载/卸载时记录快照,依据快照恢复环境,基本实现思路是:直接用 windows diff。把当前的环境和原来的环境做一个比较,我们跑两个循环,把两个环境作一次比较,然后去全量地恢复回原来的环境。不过缺点非常明显,就是不支持多实例
// 快照沙箱的简易实现
// SnapshotSandbox 能够还原 window 和记录自己以前的状态,那么就需要两个对象来存储这些信息
// 1. windowSnapshot 用来存储沙箱激活前的 window
// 2. modifyPropsMap 用来存储沙箱激活期间,在 window 上修改过的属性
// 沙箱需要两个方法及作用
// 1. sanbox.active() // 激活沙箱
// - 保存 window 的快照
// - 再次激活时,将 window 还原到上次 active 的状态
// 2. sanbox.inactive() // 失活沙箱
// - 记录当前在 window 上修改了的 prop
// - 还原 window 到 active 之前的状态
class SnapshotSandbox {
constructor() {
this.windowSnapshot = {}
this.modifyPropsMap = {}
}
active() {
// 1. 保存 window 的快照
for (let prop in window) {
if (window.hasOwnProperty(prop)) {
this.windowSnapshot[prop] = window[prop]
}
}
// 2. 再次激活时,将 window 还原到上次 active 的状态,modifyPropsMap 存储了上次 active 时在 widow 上修改了哪些属性
Object.keys(this.modifyPropsMap).forEach(prop => {
window[prop] = this.modifyPropsMap[prop]
})
}
inactive() {
for(let prop in window) {
if (!window.hasOwnProperty(prop)) continue;
if (window[prop] !== this.windowSnapshot[prop]) {
this.modifyPropsMap[prop] = window[prop]; // 保存修改后的结果
window[prop] = this.windowSnapshot[prop]; // 还原window
}
}
}
}
测试:
window.name = 'jack'
const ss = new SnapshotSandbox()
console.log('window.name', window.name) // jack
ss.active() // 激活
window.name = 'rose'
console.log('active: window.name ---', window.name) // rose
ss.inactive()
console.log('inactive: window.name ---', window.name) // jack
ss.active() // 再次激活
console.log('active: window.name ---', window.name) // rose
- Proxy代理沙箱: ES6 提供了新的 Proxy 在这里刚好可以用来解决这个问题,通过 Proxy 我们代理全局window。当然,这个Proxy代理沙箱机制还有很多变种,比如一般都要和with语句块结合,防止变量逃逸等状况
class ProxySandbox {
constructor() {
const rawWindow = window;
const fakeWindow = {}
this.proxy = new Proxy(fakeWindow, {
set(target, p, value) {
target[p] = value;
return true
},
get(target, p) {
return target[p] || rawWindow[p];
}
});
}
}
测试:
const sandbox = new ProxySandbox();
window.name = "jack";
console.log(window.name); // jack
((window) => {
window.name = 'rose';
console.log(window.name) // rose
})(sandbox.proxy);
console.log(window.name); // jack
css环境隔离
在微前端框架里所面临的样式冲突器就两种:一种是主子应用样式冲突,你的主应用和你的子应用两者之间样式会发生冲突,另一种是子应用之间样式冲突,当你挂载了应用 A 又挂载了应用 B 的时候,这两个应用是平级的,它们之间样式也会发生冲突。
常见的样式隔离方案:
说明 | 优点 | 缺点 | |
---|---|---|---|
BEM | 不同的项目用不同的命名/前缀避开冲突 | 简单 | 依赖约定,容易出现耦合 |
CSS Module | 通过编译生成不冲突的选择器名 | 可靠易用,避免人工冲突 | 只能在构建阶段使用,依赖预处理器与打包工具 |
CSS in JS | CSS与JS编码在一起,生成不冲突的选择器 | 基本避免冲突 | 样式是通过 JavaScript 运行时动态生成的,CSS能力较薄弱 |
样式隔离方案:shadow DOM
严格样式隔离代表 Shadow DOM。Shadow DOM 是可以真正的做到 CSS 之间完全隔离的,在 Shadow Boundary 这个阴影边界阻隔之下,主应用的样式和子应用的样式可以完完全全的切分开来。但是绝大部分情况下,还是不能无脑的开启严格样式隔离的。比如说在使用一些弹窗组件的时候(弹窗很多情况下都是默认添加到了 document.body )这个时候它就跳过了阴影边界,跑到了主应用里面,样式就丢了。又比方说你子应用使用的是 React 技术栈,而 React 事件代理其实是挂在 document 上的,它也会出一些问题。所以实践里当你开启 Shadow DOM 之后,当你的子应用可能会遇到一些奇怪的错误,这些错误需要你一一的去手动修复,这是比较累的一个过程。
虽然微前端框架都有Shadow DOM隔离这个选项,但是知道有这个东西即可。
应用通信
- 基于URL
其最朴素的通讯方案,就是基于 URL。前端有一种设计叫做 URL 中心设计,就是说你的 URL 完全决定了你页面展示出来是什么样子。
假如应用里有一个列表,有一个分页,当点下一页的时候,是不是就产生了一个在第二页的 query 参数?你可能会把这个参数同步到路由上,这样你把这个链接分享给别人的时候,别人就能看到跟你一样的页面。
把这种路由翻译成看作是一个函数调用,比如说这里的路由 b/function-change,query 参数 data 是 aaa ,我们可以把这个路由 URL 理解为我在调用 B 应用的 change 函数,这就像一次函数调用一样。当我们从应用 A 跳去应用 B,对应路由发生变化的时候,就是触发了一次函数调用,触发了一次通信。所以路由实际上也有通信的功能。这种通信方式是完全解耦的,但是缺点就是比较弱。
- 发布/订阅模型
应用间通信的模型就是我们可以挂一个事件总线。应用之间不直接相互交互,都统一去事件总线上注册事件、监听事件,通过这么一个发布订阅模型来做到应用之间的相互通信。
而且,window 的 CustomEvent 。我们可以在 window 上监听一个自定义事件,然后在任意地方派发一个自定义事件,我们可以天然的通过自定义事件来做到应用之间相互通信。
基于 props
在qiankun中,主应用是可以传递一些 props 给子应用。把 state 和 onGlobalStateChange (就是监听函数),还有onChange (就是 setGlobalState )三个都传给子应用。基于 props 也就可以实现一个简单的主子应用之间通信。
当这样实现了主子应用之间通信之后,子应用与子应用之间通信怎么做?让大家都跟主应用通信就行了。子应用和子应用之间就不要再多加一条通信链路了,大家都基于 props 和主应用通信,这样也能解通信问题。
几种应用通信方式的总结:
优点 | 缺点 | |
---|---|---|
基于URL | 完全解耦、应用之间独立 | 传递消息能力弱、子应用需要按照URL中心规范定义 |
基于CustomEvent | 弱耦合、浏览器原生支持 | 容易出现全局命名冲突、缺乏管控、复杂情况下通信零碎散乱 |
基于Props | 通信能力完全自定义 | 主子应用耦合较强、子应用之间不能直接通信 |
全局变量、全局Redux | 强耦合、缺乏管控 |
微前端库的技术对比
框架 | single-spa | qiankun | wujie | micro-app |
---|---|---|---|---|
开源时间 | 2015年11月 | 2019年6月 | 2021年10月 | 2020年12月 |
简介 | 开源社区,微前端鼻祖 | 蚂蚁,基于single-spa封装 | 腾讯,基于webComponent和iframe | 京东,基于WebComponent |
微应用加载 | 核心是一种运行时协议,定义了主应用如何配置微应用,从而感知微应用的加载和卸载时机。 | 基于但区别与spa加载微应用方式,它采用import-html-entry 方式加载微应用 | 基于WebComponent容器和iframe沙箱来实现微前端组件式加载。 | 借鉴WebComponent的思想,通过CustomElement结合自定义的shadow dom,将微前端封装成一个组件,来加载微应用。 |
DOM隔离 | 无 | 基于shadow dom | iframe通过proxy的方法将dom劫持到shadow dom上 | 基于shadow dom实现 |
CSS隔离 | 无 | 支持shadow dom和scoped css | 基于iframe劫持和shadow dom | 默认基于scoped css,也支持shadow dom,但官方提示对React支持不好,慎用 |
JS隔离 | 无 | 支持快照和proxy沙箱 | 基于iframe | 基于proxy |
状态管理与通信 | 无 | 提供了actions全局状态管理与基于props注入通信。 | props注入基于iframe同域下的window去中心化的eventBus | window.microApp上挂在dispatch、getData、setData等丰富api用于通信 |
内置生命周期 | 无 | 完整的生命周期beforeLoad、beforeMount、afterMount、beforeUnmount、afterUnmount | 更完整的生命周期beforeLoad、beforeMount、afterMount、beforeUnmount、afterUnmount、activated、deactived、loadError | 较丰富的生命周期created、beforemount、mounted、unmount、error |
接入成本 | 高、框架过于原始,什么都要自行封装 | 高,生命周期、路由、静态资源路径配置、webpack配置都需适配。 | 低 | 低 |
优点 | 自定义程度高 | 使用最广,社区活跃,很多踩坑解决方案 | 虽然CSS是基于shadow dom,但iframe通过proxy的方法将dom劫持到shadow dom上,达到彻底隔离vite支持友好 | 使用简单,代码无入侵,不依赖其他三方库 |
坑点 | 框架过于原始,什么都要自行封装 | css沙箱隔离不完全,严格模式基于shadow dom,第三方组件的弹窗默认挂到body下面,这样弹窗中的自定义样式会失效,需要手动设置挂载阶段样式隔离基于scoped css,对于同名样式依然存在问题不支持vite | 内存开销较大,承载js的iframe是隐藏在主应用的body下面,常驻内存不同技术栈需接入不同的包版本适配 | 样式隔离基于scoped css,对于同名样式依然存在问题浏览器兼容,WebComponentvite适配成本高 |