开始写这篇文章的起因是公司的大前端部门开始实现公司自己的微前端框架
在和大前端部门的合作中,对微前端相关的知识和技术点、难点的总结
微前端是什么
微前端的思想概念来源于微服务架构。是一种由独立交付的多个前端应用组成整体的架构风格。
具体的,将前端应用分解成一些更小、更简单的能够独立开发、测试、部署的小块,而在用户看来仍然是内聚的单个产品
为什么要有微前端
我们正常的一个单体应用,主要负责一个完整的业务,所以也被称为独石应用(一个建筑完全由一个石头雕塑成)
但是随着版本迭代会出现很多痛点:
- 增量更新慢
- 项目文件越多,每次打包编译需要的时间也越长
- 每次上线,未修改的文件都需要重新编译(chunkhash 和 dll 并不能从根本上解决问题)
- 高耦合
- 修改代码带来的关联影响大
- 项目庞大导致增加新人熟悉项目的难度和时间
- 无法独立部署:无关的功能模块没有拆分,无法各自独立部署
- 无法团队自治:如果将模块拆分给各个小团队,无法实现团队自我维护
从公司和用户层面来看,不利于效率提升
一个公司的 OA、CRM、ERP、PMS 等后台,没有统一的入口,不方便使用,降低工作效率
从用户层面来看,不利于用户体验和流量管理
一个被更多赋能的产品或者应用,更容易获得用户的青睐,获得流量
因此,在借鉴微服务架构的基础上,诞生了微前端架构
微前端作为一种大型应用的解决方案,目的就是解决上面提到的痛点,做到以下几点:
- 技术选型独立:每个开发团队自行选择技术栈(
Vue
、React
、Angular
、Jquery
),不受其他团队影响 - 业务独立:每个交付产物既可以独立使用,也可以融合成一个大型应用使用
- 样式隔离:父子应用之间、子应用之间不会有样式冲突、覆盖
技术方案
当前主流的方案
- 大仓库拆分成独立的模块文件夹,通过
webpack
统一去构建。本质上没有变化,只是在项目结构和编译分包上的优化。 - 大仓库拆成小仓库。互相之间通过
location.href
切换。比较适合后台类型的应用 - 大仓库拆成小仓库,发包到
npm
上,然后集成。较上者更进了一步,主要针对header
、footer
、siderBar
等公共部分组件。 - 大仓库拆成小仓库,不通过页面跳转,通过注入的方式集成到主应用
- iframe(天然的微前端方案,但是弊端很多)
- single-spa
- web components(最适合但是兼容性最差)
从趋势上看,最终都是向注入集成的技术方案靠拢
iframe 的优缺点
iframe
的优点
- 浏览器原生的硬隔离方案,改造成本低
- 天然支持
CSS
隔离、JS
隔离
iframe
的问题
- URL 不同步
-
iframe
内部页面跳转,url
不会更新 - 浏览器刷新导致
iframe url
状态丢失、后退前进按钮无法使用。
-
- UI 不同步
-
DOM
结构不共享。iframe
里的弹窗遮罩无法在整个父应用上覆盖
-
- 全局上下文完全隔离,内存变量不共享。
iframe
内外系统的通信、数据同步等需求,主应用的cookie
要透传到根域名都不同的子应用中实现免登效果。 - 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。
- 双滚动条
综合考量之下,iframe
不适合作为微前端的方案,最多只能作为过渡阶段的方案来使用
技术点
Entry 方式
Entry 用于父应用引入子应用相应的资源文件(包括 JS
、CSS
),主要分为两种方式:
- JS Entry
- HTML Entry
JS Entry 方式
JS Entry 的原理是:
- 把
CSS
打包进JS
,生成一个manifest.json
配置文件 -
manifest.json
中标识了子应用资源文件的相对路径地址 - 主应用通过插入
script
标签src
属性的方式加载子应用资源文件(子应用域名 +manifest.json
中的相对路径地址)
基于这样的原理,因此 JS Entry 有缺陷:
- 打包时,需要额外对工程化代码做修改,生成一份资源配置文件
manifest.json
给主应用加载 - 打包时,需要额外对样式打包做修改,需要把
CSS
打包进JS
中,也增加了编译后的包体积 - 打包时,不能在
html
中插入行内script
代码。因为manifest.json
中只能存放地址路径。因此要禁止webpack
把配置代码直接打入html
中
// vue-cli 3.x vue.config.js
config.optimization.runtimeChunk('single') // 不能使用
- 父子应用域名不一致,父应用加载子应用
manifest.json
会发生跨域,需要额外处理
HTML Entry 方式
HTML Entry 是利用 import-html-entry
直接获取子应用 html
文件,解析 html
文件中的资源加载入主应用
第一步,解析远程 html 文件,得到一个对象
// 使用
import importHTML from 'import-html-entry'
importHTML(url, opts = {})
// 获取到的对象
{
template: 经过处理的脚本,link、script 标签都被注释掉了,
scripts: [脚本的http地址 或者 { async: true, src: xx } 或者 代码块],
styles: [样式的http地址],
entry: 入口脚本的地址,是标有 entry 的 script 的 src,或者是最后一个 script 标签的 src
}
第二步,处理这个对象,向外暴露一个 Promise 对象,这个对象回传的值就是下面这个对象
// import-html-entry 源码中对获取到的对象的处理
{
// template 是 link 替换为 style 后的 template
template: embedHTML,
// 静态资源地址
assetPublicPath,
// 获取外部脚本,最终得到所有脚本的代码内容
getExternalScripts: () => getExternalScripts(scripts, fetch),
// 获取外部样式文件的内容
getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch),
// 脚本执行器,让 JS 代码(scripts)在指定 上下文 中运行
execScripts: (proxy, strictGlobal) => {
if (!scripts.length) {
return Promise.resolve();
}
return execScripts(entry, scripts, proxy, { fetch, strictGlobal });
}
}
getExternalStyleSheets 做了哪些事?
getExternalStyleSheets
会做两件事
- 将子应用中的
link
标签转为style
标签 - 把对应的
href
远程文件内容通过fetch get
的方式放进style
标签中- 如果是
inline style
,通过substring
的方式获取行内style
代码字符串 - 如果是
远程 style
,通过fetch get
方式获取href
地址对应的代码字符串
- 如果是
// import-html-entry 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()));
}
}))
}
getExternalScripts 做了哪些事?
getExternalScripts
同样做了两件事
- 按顺序获取子应用
html
中的script
,并拼成一个scripts
数组 - 使用
fetch get
的方式循环加载scripts
数组- 如果是
inline script
,通过substring
的方式获取行内JS
代码字符串 - 如果是
远程 script
,通过fetch get
方式获取src
地址对应的代码字符串
- 如果是
最后返回一个 scriptsText
数组,数组里每个元素都是子应用 scripts
数组中的可执行代码的字符串
这个数组就是 execScripts
真正使用的参数
这里会遇到一些问题:
跨域
父应用fetch
子应用第三方库的cdn
文件,大部分cdn
站点支持CORS
跨域
但是少部分cdn
站点不支持,因此导致跨域fetch
文件失败重复加载
一些通用的cdn
文件,父子应用都进行了加载,当父应用加载子应用时,会因为重复加载执行这部分cdn
的JS
代码,导致错误
解决方案:
直接硬编码把需要加载的 cdn script
写进父应用的 html
中
父应用直接加载父子应用需要的全部 cdn
子应用通过是否通过微前端方式加载的标识判断是否独立运行,自行独立加载这部分 cdn
文件
这个方案的优点是:父应用不需要做重复加载的逻辑判断,交给子应用自己判断
相对应的缺点是:A子应用不需要用到的B子应用的 cdn
也在第一时间加载,徒耗性能
execScripts 做了哪些事?
execScripts
是真正执行子应用 JS
文件的函数
- 先调用
getExternalScripts
获取可执行的JS
代码数组 - 最终使用
eval
在当前上下文中执行JS
代码。 -
proxy
参数支持传入一个上下文对象,从而保证了 JS沙盒 的可行性
HTML Entry 优于 JS Entry 的地方
- 不用生成额外的
manifest.json
- 不用把
css
打包进js
中 - 全局
css
独立打包,不会冗余 - 不使用生成
script
的方式插入子应用JS
代码,不会生成额外的DOM
节点
JS 沙盒
JS 沙盒的目的是隔离两个子应用,避免互相影响
JS 沙盒的实现有两种方式
- 代理沙盒:利用
proxy API
,可以实现多应用沙箱,把不同的应用对应不同的代理 - 快照沙盒:将不同沙盒之间的区别保存起来,只能两个,多了会混乱
代理沙盒
- 获取属性:
proxyObj[key] || window[key]
- 设置属性:
proxyObj[key] = value
利用函数作用域的形参 window(实参 proxyObj),来代替全局对象 window
// proxy 的 demo
class ProxySandbox {
constructor() {
const rawWindow = window;
const fakeWindow = {}
const proxy = new Proxy(fakeWindow, {
set(target, p, value) {
target[p] = value;
return true
},
get(target, p) {
return target[p] || rawWindow[p];
}
})
this.proxy = proxy
}
}
let sandbox1 = new ProxySandbox();
let sandbox2 = new ProxySandbox();
window.a = 1;
((window) => {
window.a = 'hello';
console.log(window.a)
})(sandbox1.proxy);
((window) => {
window.a = 'world';
console.log(window.a)
})(sandbox2.proxy);
快照沙盒
沙箱失活时,把记录在 window
上的修改记录赋值到 modifyPropsMap
上,等待下次激活
沙箱激活时,先生成一个当前 window
的快照 windowSnapshot
,把记录在沙箱上的 window
修改对象 modifyPropsMap
赋值到 window
沙箱实际使用的还是全局 window
对象
// snapshot 的 demo
class SnapshotSandbox {
constructor() {
this.windowSnapshot = {}; // window 状态快照
this.modifyPropsMap = {}; // 沙箱运行时被修改的 window 属性
this.active();
}
// 激活
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() {
// 记录本次的修改
// 还原 window 到激活之前的状态
this.modifyPropsMap = {};
for (const prop in window) {
if (window.hasOwnProperty(prop) && this.windowSnapshot[prop] !== window[prop]) {
this.modifyPropsMap[prop] = window[prop]; // 保存变化
window[prop] = this.windowSnapshot[prop] // 变回原来
}
}
}
}
let sandbox = new SnapshotSandbox();
((window) => {
window.a = 1
window.b = 2
console.log(window.a) //1
sandbox.inactive() //失活
console.log(window.a) //undefined
sandbox.active() //激活
console.log(window.a) //1
})(sandbox.proxy);
//sandbox.proxy就是window
目前主流方法是优先代理沙箱,如果不支持 proxy API
,则使用快照沙箱
CSS 沙盒
子应用样式
子应用通过 BEM
+ css module
的方式隔离
保证A子应用的样式不会在B子应用的 DOM
上生效
子应用切换
子应用失活,样式 style
不需要删除,因为已经做了隔离
已加载的子应用重新激活,也不需要重新插入 style
标签,避免重复加载
父子应用通信
父子应用通信主要分为:数据和事件
数据
- url
- localStorage
- sessionStorage
- cookie
- eventBus
事件
- 子应用 main.js export 到父应用的 window 对象
- 父应用 自定义事件
- 父应用 window.eventBus
- H5 api sharedWorker
- H5 api BroadcastChannel
目前用的较多的方案是 eventBus 和 自定义事件
应用监控
每个项目都有对自己的应用监控
- 用户行为监控
- 错误监控
- 性能监控
如果使用代理沙箱
因为 proxy API
只能代理对象的 get
set
,无法代理事件的监听和移除,子应用的监控在代理对象上无法执行
所以只能直接在父应用上监听父子应用的事件
如果使用快照沙箱
因为同时只有一个子应用被激活,只有一个子应用的JS在执行,同时又是直接操作 window 对象
可以考虑直接使用子应用自己的监控,因为都是对 window 的事件监听,所以可以同时监听到父子两个应用的事件
下面列举 single-spa
和 qiankun
的监控方案
// single-spa 的异常捕获
export { addErrorHandler, removeErrorHandler } from 'single-spa';
// qiankun 的异常捕获
// 监听了 error 和 unhandlerejection 事件
export function addGlobalUncaughtErrorHandler(errorHandler: OnErrorEventHandlerNonNull): void {
window.addEventListener('error', errorHandler);
window.addEventListener('unhandledrejection', errorHandler);
}
// 移除 error 和 unhandlerejection 事件监听
export function removeGlobalUncaughtErrorHandler(errorHandler: (...args: any[]) => any) {
window.removeEventListener('error', errorHandler);
window.removeEventListener('unhandledrejection', errorHandler);
}
现有框架对比
参考上图
single-spa
比较基础的微前端框架,也是我公司大前端部门搭建自有框架的选择方案
需要自己定制的部分较多,包括
- Entry 方式
- JS 沙盒
- CSS 沙盒
- 父子应用通信方式
- 应用监控事件处理
官网:https://zh-hans.single-spa.js.org/
github:https://github.com/single-spa/single-spa
icestark
icestark 是阿里的微前端框架,现在的不限制主应用所使用的框架了
针对 React 主应用 和 不限框架的主应用 有两种不同的接入方式
PS:通过下面的引用描述来看,目前应该不支持多个子应用共存(待确认)
一般情况下不存在多个微应用同时运行的场景
页面运行时同时只会存在一个微应用,因此多个微应用不存在样式相互污染的问题
在 Entry 方式上
- 通过
fetch
+创建 script 标签
的方式注入。有一点 JS Entry 和 HTML Entry 中间过渡的意思 - 不需要子应用生成配置文件,但是会生成
script
的DOM
节点
在 JS 沙盒上
- 如果是不可控的子应用,官方建议使用 iframe 的方案嵌入
- 如果是可控的子应用,使用代理沙盒(还未研究过对应的源码,但快照沙盒作为降级策略,应该也有被使用,待确认)
在 CSS 沙盒上
- 主要方案是
BEM
+CSS Modules
- 实验性方案是
Shadow DOM
- 全局样式库,例如
normalize.css
、reset.css
统一由主应用引入
在应用通信上
- 使用了
eventBus
的方案来处理数据和事件
在应用监控上
- 统一由主应用来监控
官网:https://micro-frontends.ice.work/
github:https://github.com/ice-lab/icestark
qiankun
同样是阿里的微前端框架,qiankun 是对 single-spa
的一层封装
核心做了构建层面的一些约束以及沙箱能力,支持多子应用并存
但是接入的修改成本较高
总的来说算是目前比较优选的微前端框架
在 Entry 方式上
- 已经支持
HTML Entry
的方式,在框架内部也是依赖的import-html-entry
在 JS 沙盒上
- 使用三种沙盒
- legacySandBox:支持
proxy API
且只有单子应用并存 - proxySandBox:支持
proxy API
且多子应用并存 - snapshotSandBox:不支持
proxy API
的快照沙盒
- legacySandBox:支持
- legacySandBox 其实是 proxySandBox 与 snapshotSandBox 的结合,既想要
proxy
的代理能力,又想在一定程度上有直接操作window
对象的能力
在 CSS 沙盒上
- 主要方案是
BEM
-
BEM
不需要子应用自己处理,在子应用接入qiankun
框架时可以通过配置统一增加prefix
- 全局样式库,例如
normalize.css
、reset.css
统一由主应用引入
在应用通信上
-
Actions
方案:适用于通信较少- 数据上:使用一个 store 来存储数据,使用观察者模式来监听
- 事件上:利用观察者派发事件的触发事件通信
-
Shared
方案:适用于通信较多- 主应用基于
redux
维护一个状态池,通过shared
实例暴露一些方法给子应用使用 - 子应用需要单独维护一份
shared
实例,保证在使用和表现上的一致性- 独立运行时使用自身的
shared
实例 - 在嵌入主应用时使用主应用的
shared
实例
- 独立运行时使用自身的
- 数据和事件都可以通过
redux
来通信
- 主应用基于
在应用监控上
- 统一由主应用来监控
官网:https://qiankun.umijs.org/zh
github:https://github.com/umijs/qiankun
Garfish
从开发者大会上看到的方案,来自于字节跳动,有希望成为最优的方案
- 支持多子应用并存
- 支持
HTML Entry
、JS Entry
-
JS
沙盒直接使用快照沙盒 - 通过HTML整体快照,来实现
CSS
沙盒 - 通信
- 数据:同样通过一个
store
来保存数据 - 事件:通过自定义事件
- 数据:同样通过一个
- 监控
- 保留
window
addEventListener
removeEventListener
的副本 - 在沙盒
document
对象上监听监控
- 保留
最大的特点是,能够快照子应用的 DOM 节点,保持 DOM 树
加上 JS 沙盒 、 CSS 沙盒,能够保持整个子应用的完整状态
官网:https://garfish.dev/
github:https://github.com/bytedance/garfish