一、what
微前端的概念是由ThoughtWorks在2016年提出的,它借鉴了微服务的架构理念,核心在于将一个庞大的前端应用拆分成多个独立且灵活的小型应用,并且每个应用都可以独立开发、独立运行、独立部署,再将这些小型应用融合为一个完整的应用,或者将原本运行已久、没有关联的几个应用融合为一个应用。可以将多个项目融合为一,又可以减少项目之间的耦合,提升项目扩展性。
通俗来说,就是在一个web应用中可以独立的运行另一个web应用。
🌰 举个栗子哈!!
一个后端管理系统
权限(团队)
客户
产品
前端页面配置
...
此时呢?将不同的功能,按照不同的维度拆分成多个子应用,通过主应用来加载这些子应用
所以呢~~~
微前端的核心就在于合,或者拆完之后再合
不是一种技术,一种思想
微前端不是指具体的库,不是指具体的框架,不是指具体的工具,而是一种架构模式。
二、 why
场景:
- 不同团队,开发同一个应用,但是技术栈不同怎么破?
- 希望每个团队都可以独立开发,独立部署怎么破?
- 项目中还需要兼容到老的应用代码又要怎么破?
我们是不是可以将一个应用划分成若干个子应用,将子应用打包成一个个的lib。当路径切换时加载不同的子应用。这样每个子应用都是独立的,技术栈也不用做限制了,从而解决了前端协同开发问题.
!!! 微前端的核心三大原则就是:
- 独立开发
- 独立部署
- 独立运行
微前端应该有如下特点:
- 技术栈无关 主框架不限制接入应用的技术栈,微应用具备完全自主权
- 增量升级 在面对各种复杂场景时,我们通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略
- 独立开发、独立部署 微应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新
- 独立运行时 每个微应用之间状态隔离,运行时状态不共享
三、how
满足这些的最佳人选就是 “iframe”!!!
前端开发中我们对iframe已经非常熟悉了,那么iframe的作用是什么?简单归纳来说就是,在一个web应用中可以独立的运行另一个web应用
这个概念和微前端不谋而合,相对于目前配置复杂、高适配成本的微前端方案来说,采用iframe方案具有一些显著的优点:
优点:
- 使用非常简单,几乎没有学习成本
- web应用隔离的非常完美,无论是js、css、dom都完全隔离开来
- 多应用激活,页面上可以摆放多个iframe来组合业务
缺点 :
- DOM 结构不共享,弹窗及遮罩层问题
- 浏览器前进/后退问题,iframe和主页面共用一个浏览历史
- 不同源系统之间通信需要通过postMessage,存在一定的安全性
- 路由状态丢失,刷新一下,iframe的url状态就丢失了
- 白屏时间太长,对于SPA 应用应用来说无法接受
- 页面加载问题,影响主页面加载,阻塞onload事件,每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程
现有市面框架
- single-spa 将多个单页面应用聚合为一个整体应用的JavaScript 微前端框架
- qiankun 蚂蚁金服,在 single-spa 的基础上封装
- MicroApp 京东,一款基于WebComponent的思想,轻量、高效、功能强大的微前端框架
- 无界 腾讯 无界微前端方案基于 WebComponent 容器 + iframe 沙箱
- EMP YY语音,基于Webpack5 Module Federation 除了具备微前端的能力外,还实现了跨应用状态共享、跨框架组件调用的能力
- icestark 阿里出品,是一个面向大型系统的微前端解决方案
- garfish 字节跳动
- magic-microservices 一款基于 Web Components 的轻量级的微前端工厂函数
基础原理分析
single-spa
single-spa是一个目前主流的微前端技术方案,其主要实现思路:
- 预先注册子应用(激活路由、子应用资源、生命周期函数 registerApplication注册子应用和reroute 更改app.status和执行生命周期函数)
- 监听路由的变化,匹配到了激活的路由则加载子应用资源,顺序调用生命周期函数并最终渲染到容器
flowchart LR
注册微应用 --> 监听url变化
监听url变化 --> 加载/卸载对应微应用,执行生命周期函数
每次切换路由前,将应用分为4大类,首次加载时执行loadApp,后续的路由切换执行performAppChange,为四大类的应用分别执行相应的操作,比如更改app.status,执行生命周期函数,所以,从这里也可以看出来,single-spa就是一个维护应用的状态机
它要求子应用的入口文件导出 bootstrap、mount、unmount 的生命周期函数,也就是在加载完成、挂载前、卸载前执行的逻辑。
比如 react 的子应用:
import React from 'react';
import ReactDOM from 'react-dom';
import App from './index.tsx'
export const bootstrap = () => {}
export const mount = () => {
ReactDOM.render(<App/>, document.getElementById('root'));
}
export const unmount = () => {}
single-spa 提供了和 react、vue、angular 等集成的包,可以直接用
import React from 'react';
import ReactDOM from 'react-dom';
import App from './index.tsx';
import singleSpaReact from 'single-spa-react';
const reactLifecycles = singleSpaReact({
React,
ReactDOM,
rootComponent: App
});
export const bootstrap = reactLifecycles.bootstrap;
export const mount = reactLifecycles.mount;
export const unmount = reactLifecycles.unmount;
监听路由变化
window.addEventListener("hashchange", urlReroute);
window.addEventListener("popstate", urlReroute);
总结
single-spa 就做了两件事,加载微应用(加载方法还是用户自己提供的)、维护微应用状态(初始化、挂载、卸载)。
single-spa 虽好,但是却存在一些比较严重的问题:
- 子应用资源修改
- JS 隔离
- 样式隔离问题
- 资源预加载
- 应用间通信
乾坤 demo
qiankun 是基于 single-spa 做了二次封装的微前端框架,通过解决了 single-spa 的一些弊端和不足,来帮助大家实现更简单、无痛的构建一个生产可用的微前端架构系统。
主要的完善点:
- 子应用资源由 js 列表修改进为一个url,大大减轻注册子应用的复杂度
- 实现应用隔离,完成js隔离方案 (window工厂) 和css隔离方案 (类vue的scoped)
- 增加资源预加载能力,预先子应用html、js、css资源缓存下来,加快子应用的打开速度
- ...
1. Entry
通常有两种 JS Entry 和 Html Entry。
调用 single-spa 的注册子应用函数时这么写:
singleSpa.registerApplication({
name: 'myApp', // 子应用名 an
app: () => {
loadScripts('./chunk-a.js');
loadScripts('./chunk-b.js');
return loadScripts('./entry.js')
},// System.import 如何加载你的子应用
//app: () => import('src/myApp/main.js'),
activeWhen: ['/myApp', (location) => location.pathname.startsWith('/some/other/path')], // url匹配规则,表示啥时候开始走这个子应用的生命周期
customProps: (name, location) => ({ // 自定义 props,从子应用的 bootstrap, mount, unmount 回调可以拿到
some: 'value',
}),
});
singleSpa.start() // 启动主应用
Js Entry 的缺点是:
- 子应用一发布,入口 JS 文件名肯定又要改了,导致主应用引入的 JS url 又得改了
期望: 操作起来应该和在 <iframe/> 中插入 src 是一样。这种通过提供 HTML 入口来接入子应用的方式就叫 HTML Entry。
HTML Entry
qiankun 的一大亮点就是提供了 HTML Entry,使用 html entry,你只需要指定子应用的 html 入口即可,微前端框架在加载 html 后,从中提取出 css、js资源,运行子应用时,安装样式、执行脚本,运行脚本中提供的生命周期钩子。
在调用 qiankun 的注册子应用函数时这么写:
registerMicroApps([
{
name: 'react app', // 子应用名
entry: '//localhost:3100', // 子应用 html 或网址
container: '#ontainer', // 挂载容器选择器
activeRule: '/activeRule', // 激活路由
},
]);
start();
优点也很明显:
- 无需关心应用打包后的 js 名称变化的问题
当然HTML Entry 并不是给个 HTML 的 url 就可以直接接入整个子应用这么简单了。子应用的 HTML 文件就是一堆乱七八糟的标签文本。, , 得处理,要写正则表达式。。。 所以 qiankun 的作者写了一个专门处理 HTML Entry 这种需求的 NPM 包:import-html-entry,qiankun 已经将 import-html-entry 与子应用加载函数完美地结合起来,大家只需要知道这个库是用来获取 HTML 模板内容,Style 样式和 JS 脚本内容就可以了
2. 运行时沙箱
qiankun 的运行时沙箱分为 JS 沙箱和 样式沙箱
Js隔离机制
乾坤有三种Js隔离机制:
- SnapshotSandbox
- LegacySandbox
- ProxySandbox
if (window.Proxy) {
sandbox = useLooseSandbox
//基于 Proxy 实现的沙箱
//LegacySandbox 为了兼容性 singular 模式下依旧使用该沙箱,等新沙箱稳定之后再切换
? new LegacySandbox(appName, globalContext)
: new ProxySandbox(appName, globalContext, { speedy: !!speedySandBox });
} else {
// 基于 diff 方式实现的沙箱,用于不支持 Proxy 的低版本浏览器
sandbox = new SnapshotSandbox(appName);
}
1、SnapshotSandbox(快照沙箱)
激活(active):
1)记录window当时的状态
2)恢复上一次沙箱失活时记录的沙箱运行过程中对window做的状态改变
active() {
// 记录当前快照
this.windowSnapshot = {} as Window;
iter(window, (prop) => {
this.windowSnapshot[prop] = window[prop];
});
// 恢复之前的变更
Object.keys(this.modifyPropsMap).forEach((p: any) => {
window[p] = this.modifyPropsMap[p];
});
this.sandboxRunning = true;
}
失活:
1)记录window上有哪些状态发生了变化(沙箱自激活开始,到失活的这段时间);
2)清除沙箱在激活之后在window上改变的状态,从代码可以看出,就是让window此时的属性状态和刚激活时候的window的属性状态进行对比,不同的属性状态就以快照为准,恢复到未改变之前的状态。
inactive() {
this.modifyPropsMap = {};
iter(window, (prop) => {
if (window[prop] !== this.windowSnapshot[prop]) {
// 记录变更,恢复环境
this.modifyPropsMap[prop] = window[prop];
window[prop] = this.windowSnapshot[prop];
}
});
this.sandboxRunning = false;
}
从上面可以看出,快照沙箱存在两个重要的问题:
-
会改变全局window的属性,如果同时运行多个微应用,多个应用同时改写window上的属性,势必会出现状态混乱,这也就是为什么快照沙箱无法支持多个微应用同时运行的原因。
-
会通过for(prop in window){}的方式来遍历window上的所有属性,window属性众多,这其实是一件很耗费性能的事情。
2、LegacySandBox(支持单应用的代理沙箱)
单例模式直接代理了原生 window 对象,通过监听对 window 的修改来直接记录 Diff 内容,其实现的功能和快照沙箱是一模一样的,不同的是,通过三个变量来记住沙箱激活后window发生变化过的所有属性,这样在后续的状态还原时候就不再需要遍历window的所有属性来进行对比,提升了程序运行的性能。
因为只要对 window 属性进行设置,那么就会有两种情况:
- 如果是沙箱期间新增的全局变量,那么存到 addedPropsMapInSandbox 里
- 如果是沙箱期间更新全局变量,那么把原来的键值存到modifiedPropsOriginalValueMapInSandbox,把新的键值存到 currentUpdatedPropsValueMap
通过 addedPropsMapInSandbox, modifiedPropsOriginalValueMapInSandbox 和 currentUpdatedPropsValueMap 这三个变量就能推出微应用以及原来环境的变化,qiankun 也能以此恢复环境。
前面两种沙箱都是 单例模式下使用的沙箱。也即一个页面中只能同时展示一个微应用,而且无论是 set 还是 get 依然是直接操作 window 对象。 在这样单例模式下,当微应用修改全局变量时依然会在原来的 window 上做修改,因此如果在同一个路由页面下展示多个微应用时,依然会有环境变量污染的问题。
3、 ProxySandBox(支持多应用的代理沙箱)
拷贝全局对象window上所有不可配置属性到 fakeWindow 对象
,所有的更改都是基于这个 fakeWindow 对象,之后给每个微应用分配一个 fakeWindow,从而保证多个实例之间属性互不影响。
ProxySandbox,完全不存在状态恢复的逻辑,同时也不需要记录属性值的变化,因为每个微应用都有自己一个环境,当在 active 时就给这个微应用分配一个 fakeWindow,当 inactive 时就把这个 fakeWindow 存起来,以便之后再利用,所有的变化都是沙箱内部的变化,和window没有关系,window上的属性至始至终都没有受到过影响。
样式隔离
qiankun 实现了两种样式隔离
- 严格的样式隔离模式,为每个微应用的容器包裹上一个 shadow dom 节点,从而确保微应用的样式不会对全局造成影响
- 实验性的方式,通过动态改写 css 选择器来实现,可以理解为 css scoped 的方式
注意:两种方式不可共存
- 严格样式隔离
在 qiankun 中的严格样式隔离,就是在这个 createElement 方法中做的,通过 shadow dom 来实现, shadow dom 是浏览器原生提供的一种能力。shadow root 下的 dom 的样式是不会影响其他 dom 的。具体内容可查看 shadow DOM
/**
* 做了两件事
* 1、将 appContent 由字符串模版转换成 html dom 元素
* 2、如果需要开启严格样式隔离,则将 appContent 的子元素即微应用的入口模版用 shadow dom 包裹起来,达到样式严格隔离的目的
* @param appContent = `<div id="__qiankun_microapp_wrapper_for_${appInstanceId}__" data-name="${appName}">${template}</div>`
* @param strictStyleIsolation 是否开启严格样式隔离
*/
function createElement(appContent: string, strictStyleIsolation: boolean): HTMLElement {
// 创建一个 div 元素
const containerElement = document.createElement('div');
// 将字符串模版 appContent 设置为 div 的子元素
containerElement.innerHTML = appContent;
// appContent always wrapped with a singular div,appContent 由模版字符串变成了 DOM 元素
const appElement = containerElement.firstChild as HTMLElement;
// 如果开启了严格的样式隔离,则将 appContent 的子元素(微应用的入口模版)用 shadow dom 包裹,以达到微应用之间样式严格隔离的目的
if (strictStyleIsolation) {
if (!supportShadowDOM) {
console.warn(
'[qiankun]: As current browser not support shadow dom, your strictStyleIsolation configuration will be ignored!',
);
} else {
const { innerHTML } = appElement;
appElement.innerHTML = '';
let shadow: ShadowRoot;
if (appElement.attachShadow) {
shadow = appElement.attachShadow({ mode: 'open' });
} else {
// createShadowRoot was proposed in initial spec, which has then been deprecated
shadow = (appElement as any).createShadowRoot();
}
shadow.innerHTML = innerHTML;
}
}
return appElement;
}
- 实验性样式隔离
实验性样式的隔离方式其实就是 scoped css,qiankun 会通过动态改写一个特殊的选择器约束来限制 css 的生效范围,应用的样式会按照如下模式改写
// 假设应用名是 rtest
.app-main {
font-size: 14px;
}
div[data-qiankun-rtest] .app-main {
font-size: 14px;
}
不过这种还是实现性的,需要手动开启:
export function isEnableScopedCSS(sandbox: FrameworkConfiguration['sandbox']) {
if (typeof sandbox !== 'object') {
return false;
}
if (sandbox.strictStyleIsolation) {
return false;
}
return !!sandbox.experimentalStyleIsolation;
}
在源码里可以看到这两种方式:
if (strictStyleIsolation) {
if (!supportShadowDOM) {
} else {
const { innerHTML } = appElement;
appElement.innerHTML = '';
let shadow: ShadowRoot;
if (appElement.attachShadow) {
shadow = appElement.attachShadow({ mode: 'open' });
} else {
// createShadowRoot was proposed in initial spec, which has then been deprecated
shadow = (appElement as any).createShadowRoot();
}
shadow.innerHTML = innerHTML;
}
}
if (scopedCSS) {
const attr = appElement.getAttribute(css.QiankunCSSRewriteAttr);
if (!attr) {
appElement.setAttribute(css.QiankunCSSRewriteAttr, appInstanceId);
}
const styleNodes = appElement.querySelectorAll('style') || [];
forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => {
css.process(appElement!, stylesheetElement, appInstanceId);
});
}
资源预加载
qiankun 实现预加载的思路有两种
- 一种是当主应用执行 start 方法启动 qiankun 以后立即去预加载微应用的静态资源
- 另一种是在第一个微应用挂载以后预加载其它微应用的静态资源,这个是利用 single-spa 提供的 single-spa:first-mount 事件来实现的。
/**
* 执行预加载策略,qiankun 支持四种
* @param apps 所有的微应用
* @param prefetchStrategy 预加载策略,四种 =》
* 1、true,第一个微应用挂载以后加载其它微应用的静态资源,利用的是 single-spa 提供的 single-spa:first-mount 事件来实现的
* 2、string[],微应用名称数组,在第一个微应用挂载以后加载指定的微应用的静态资源
* 3、all,主应用执行 start 以后就直接开始预加载所有微应用的静态资源
* 4、自定义函数,返回两个微应用组成的数组,一个是关键微应用组成的数组,需要马上就执行预加载的微应用,一个是普通的微应用组成的数组,在第一个微应用挂载以后预加载这些微应用的静态资源
* @param importEntryOpts = { fetch, getPublicPath, getTemplate }
*/
export function doPrefetchStrategy(
apps: AppMetadata[],
prefetchStrategy: PrefetchStrategy,
importEntryOpts?: ImportEntryOpts,
)
在空闲的时候,使用浏览器提供的 requestIdleCallback干一些事,乾坤实现:
// 通过时间切片的方式去加载静态资源,在浏览器空闲时去执行回调函数,避免浏览器卡顿
requestIdleCallback(async () => {
// 得到加载静态资源的函数
const { getExternalScripts, getExternalStyleSheets } = await importEntry(entry, opts);
// 样式
requestIdleCallback(getExternalStyleSheets);
// js 脚本
requestIdleCallback(getExternalScripts);
});
}
应用间通信
多个子应用、子应用和主应用之间自然有一些状态管理的需求,qiankun 也实现了这个功能。
qiankun 通过发布订阅模式来实现应用间通信,状态由框架来统一维护,每个应用在初始化时由框架生成一套通信方法,应用通过这些方法来更改全局状态和注册回调函数,全局状态发生改变时触发各个应用注册的回调函数执行,将新旧状态传递到所有应用。
使用起来是这样的:
主应用里做全局状态的初始化,定义子应用获取全局状态的方法 getGlobalState 和全局状态变化时的处理函数 onGlobalStateChange:
import { initGlobalState } from 'qiankun'
const initialState = {
user: {
name: 'guang'
}
}
const actions = initGlobalState(initialState)
actions.onGlobalStateChange((newState, prev) => {
for (const key in newState) {
initialState[key] = newState[key]
}
})
actions.getGlobalState = (key) => {
return key ? initialState[key] : initialState
}
export default actions
子应用里可以通过参数拿到 global state 的 get、set 方法:
export async function mount(props) {
const globalState = props.getGlobalState();
props.setGlobalState({user: {name: 'dong'}})
}
提供全局错误处理
总结一下qiankun的优缺点:
优点
- 监听路由自动的加载、卸载当前路由对应的子应用
- 完备的沙箱方案,js沙箱做了SnapshotSandbox、LegacySandbox、ProxySandbox三套渐进增强方案,css沙箱做了两套strictStyleIsolation、experimentalStyleIsolation两套适用不同场景的方案
- 路由保持,浏览器刷新、前进、后退,都可以作用到子应用
- 应用间通信简单,全局注入
缺点
- 基于路由匹配,无法同时激活多个子应用,也不支持子应用保活
- 改造成本较大,从 webpack、代码、路由等等都要做一系列的适配
- css 沙箱无法绝对的隔离,js 沙箱在某些场景下执行性能下降严重
- 无法支持 vite 等 esmodule 脚本运行
micro-app
micro-app 是基于 webcomponent + qiankun sandbox 的微前端方案。
- 使用 webcomponet 加载子应用相比 single-spa 这种注册监听方案更加优雅
- 支持子应用保活
- 降低子应用改造的成本
- 支持 vite 运行,但必须使用 plugin 改造子应用,且 js 代码没办法做沙箱隔离;
- 对于不支持 webcompnent 的浏览器没有做降级处理
无界方案
无界微前端方案基于 webcomponent 容器 + iframe 沙箱,能够完善的解决适配成本、样式隔离、运行性能、页面白屏、子应用通信、子应用保活、多应用激活、vite 框架支持、应用共享等用户的核心诉求。
- 应用加载机制和 js 沙箱机制
- iframe 连接机制和 css 沙箱机制
来看无界如何一步一步的解决iframe的问题,假设我们有A应用,想要加载B应用:
在应用A中构造一个shadow和iframe,然后将应用B的html写入shadow中,js运行在iframe中,注意iframe的url保持和主应用同域但是保留子应用的路径信息,这样子应用的js可以运行在iframe的location和history中保持路由正确。然后在iframe中拦截document对象,统一将dom指向shadowRoot,此时比如新建元素、弹窗或者冒泡组件就可以正常约束在shadowRoot内部。
接下来的三步分别解决iframe的三个缺点:
- dom割裂严重的问题 主应用提供一个容器shadowRoot,shadowRoot内部的弹窗也就可以覆盖到整个应用A
- 路由状态丢失的问题 浏览器的前进后退可以天然的作用到iframe上,此时监听iframe的路由变化并同步到主应用,如果刷新浏览器,就可以从url读回保存的路由
- 通信非常困难的问题 iframe和主应用是同域的,天然的共享内存通信,而且无界提供了一个去中心化的事件机制
通信机制
在无界提供三种通信方式
-
props 注入机制
//主应用 <WujieReact props={props} /> //子应用 $wujie.props -
通过 window.parent 方法拿到主应用的全局方法
-
去中心化的通信机制(EventBus实例)
//主应用 WujieReact.bus.$on("click", (e) => setAge(e)); //子应用 window.$wujie.bus.$emit("click", 19);