1 道
"微前端"的概念最早由 Thoughtworks 在2016年提出。
微前端是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将单页面前端应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。各个前端应用还可以独立开发、独立部署。 ------ 黄峰达《前端架构------从入门到微前端》
1.1 独立
独立开发、独立部署、独立运行,是微前端应用组织的关键词。独立带来了很多有价值的特性:
- 不同微应用可以使用不同的技术栈,从而兼容老应用,微应用也可以独立选型、渐进升级;
- 微应用有单独的 git 仓库,方便管理;
- 微应用隔离,单独上线,回归测试无需测试整个系统;
- 拆分应用,加速加载;
为了实现可靠且灵活的独立,微前端必须面对几个核心问题:
- 微应用间如何调度、解析、加载?
- 如何避免运行时互相污染?
- 微应用间如何进行通信?
1.2 大道至简------微前端的理论基础
微前端能成的理论基础是,底层API的唯一性。
首先无论各家前端框架多么天花乱坠,最后都离不开一个操作 ------「通过 js 在一个DOM容器中插入或更新节点树」。所以你在各家的demo也都看得到这样的 api 描述:
js
ReactDOM.render(<App />, document.getElementById('root')); // react
createApp(...).mount('#app'); // vue
所以只要提供容器,就能让任何前端框架正常渲染。再上一层,任何 JS API,都离不开在全局对象 window 上的调用,包括 DOM 操作、事件绑定、页面路由、前端存储等等。所以只要封住 window,就可以隔离微应用的运行时。
1.3 主流微前端方案套娃
微前端是一个概念,历史上各种实现方案层出不穷,到今天阿里 qiankun 的方案成为国内主流。
qiankun 底层基于 single-spa,而业务系统也倾向于再在外面包一层,三层方案各自专注解决不同的问题:
- single-spa 的官方定位是「一个顶层路由,当路由处于活动状态时,它将下载并执行该路由的相关代码」。放到微前端概念中,它专注解决微应用基于路由的调度。
- qiankun 是一个「微前端实现库,旨在帮助大家能更简单、无痛的构建一个生产可用微前端架构系统」。在 single-spa 的基础上:所谓「简单」,是降低了接入门槛,增强了资源接入方式,支持 HTML Entry;所谓「无痛」,是尽量降低了微前端带来的副作用,即提供了样式和JS的隔离,并通过资源缓存加速微应用切换性能。
- 到业务系统这一层,着重解决业务生产环境中的问题。最常见的像提供一个MIS管理后台,灵活配置,动态下发微应用信息,实现动态应用插拔。
2 single-spa
single-spa 做的事很聚焦,核心流程是:1、注册路由对应资源 ---> 2、监听路由 ---> 3、加载对应资源 ---> 4、执行资源提供的状态回调。
2.1 api 概览
为了实现这套流程,single-spa 首先提供了「1、注册路由对应资源」的接口:
js
singleSpa.registerApplication({ name, appLoader, activeWhen });
然后启动「2、监听路由 ---> 3、加载对应资源」机制:
js
singleSpa.start();
对资源则有「提供状态回调」的改造要求:
js
// 资源代码
export function bootstrap(props) {}
export function mount(props) {}
export function unmount(props) {}
export function unload(props) {} // 可选
2.2 整体实现原理
很显然,这里面有一套应用的状态机制,以及对应的状态流转流程,在 single-spa 内部是这样的:
- app 池收集注册进来的微应用信息,包括应用资源、对应路由。app 池中的所有微应用,都会维护一个自身的状态机。
- 刷新器是整个 single-spa 的发动机,负责流转整个状态流程。一旦刷新器被触发(首次启动或路由更新),就开始调度:
- 拿着最新路由去池子里分拣 app
- 根据分拣结果,执行 app 资源暴露的生命周期方法
2.3 app 池的实现
app 池的实现都在 src/applications/apps.js
模块中,首先是一个全局池:
js
const apps = [];
然后直接实现并导出 registerApplication 方法作为向 app 池添加成员的入口:
js
export function registerApplication( appNameOrConfig, appOrLoadApp, activeWhen, customProps) {
const registration = sanitizeArguments( appNameOrConfig, appOrLoadApp, activeWhen, customProps);
apps.push(
assign(
{ status: NOT_LOADED },
registration
)
);
if (isInBrowser) {
reroute();
}
}
registerApplication 做了几件事:
- 构造 app 对象,整理入参,这个和 single-spa 入参兼容有关系。最终 app 对象将包含app 信息、状态、资源、激活条件等信息。
- 加入 app 池。这里可以看到初始状态是
NOT_LOADED
。 - 触发了一次 reroute。
2.4 reroute 触发
前面 registerApplication 调了一次 reroute 方法,这就是执行一次刷新。reroute 会在下列场景执行:
- registerApplication:微应用注册
- start:框架启动
- 路由事件(popstate、hashchange)触发
js
window.addEventListener("hashchange", urlReroute);
window.addEventListener("popstate", urlReroute);
function urlReroute() {
reroute([], arguments);
}
2.5 reroute 分拣执行
reroute 先判断 app 是否应该激活,逻辑很简单,就是把当前路由带到 app.activeWhen 里计算返回(app.activeWhen(window.location)
),这里我们只看当前应该处于什么状态。而且按我们通常用法,只有少数 app 会激活。
接下来看微前端应用的激活过程,是先 load 下载应用资源,再 mount 挂载启动应用。
这样结合「app 是否应该激活」X「app 当前状态」,可以得到「应该对 app 做什么操作」。
- 「激活」X「not loaded」:应该去加载微应用资源
- 「激活」X「not mounted」:应该去挂载启动微应用
- 「激活」X「mounted」:什么都不用动
- 「不激活」X「not loaded」:什么都不用动
- 「不激活」X「not mounted」:应该去卸掉微应用资源
- 「不激活」X「mounted」:应该卸载微应用
这里只有1、2、5、6需要操作,也对应了上图中的四个箭头。于是 app 被进一步分拣为四个组:
代码如下:
js
switch (app.status) {
case NOT_LOADED:
case LOADING_SOURCE_CODE:
if (appShouldBeActive) {
appsToLoad.push(app);
}
break;
case NOT_BOOTSTRAPPED:
case NOT_MOUNTED:
if (!appShouldBeActive && getAppUnloadInfo(toName(app))) {
appsToUnload.push(app);
} else if (appShouldBeActive) {
appsToMount.push(app);
}
break;
case MOUNTED:
if (!appShouldBeActive) {
appsToUnmount.push(app);
}
break;
// all other statuses are ignored
}
拿到四个组后,需要转为具体操作,于是 reroute 中有这种 map:const unloadPromises = appsToUnload.map(toUnloadPromise);
,把 app 转换为操作的 Promise。
需要注意的是,load 后app处于中间状态,并未完成激活,还差一步,反之亦然。所以只到中间态的两个组 appsToUnmount、appsToLoad,还需要继续往前走一步。
js
const unmountUnloadPromises = appsToUnmount
.map(toUnmountPromise)
.map((unmountPromise) => unmountPromise.then(toUnloadPromise));
至于这些Promise是干嘛的也很容易猜到,无非是执行资源暴露的生命周期回调 + 修改应用状态。toXXXPromise 方法都定义在 src/lifecycles
下,可以找到对应生命周期。
至此 reroute 从分拣到执行生命周期的过程完成,完整图如下:
2.6 小结
- single-spa 主要实现了微前端微应用调度部分,包含一个 app 池及路由变化时刷新回调 app 生命周期函数的机制
- app 池维护了 app 的信息、资源和状态,暴露添加方法给 registerApplication api
- 刷新的过程:确定app是否active ---> 结合状态判断要做的操作 ---> 调用生命周期回调,改状态
3 qiankun
在 single-app 微应用调度的基础上,qiankun 要带来的是「更简单、无痛的」生产应用。这些特性包括:
- 💪 HTML Entry 接入方式,让你接入微应用像使用 iframe 一样简单。
- 🛡 样式隔离,确保微应用之间样式互相不干扰。
- 🧳 JS 沙箱,确保微应用之间 全局变量/事件 不冲突。
- ⚡️ 资源预加载,在浏览器空闲时间预加载未打开的微应用资源,加速微应用打开速度。
我们看它的实现思路。
3.1 qiankun 加载应用的过程
qiankun 的特性和它的应用加载方式密不可分,我们先从一个叫 loadApp 的方法入手,看看子应用加载的全过程。
1、入口解析
qiankun 从入参中拿到子应用的 name 和 entry,过一个import-html-entry
库,这个库也是 qiankun 自己的,有俩主要用途:从 html 解析静态资源(HTML Entry 的基础),并使其在特定上下文下运行(JS 隔离的基础)。
解析后可以得到子应用对应的可执行 JS(execScripts 方法)、静态资源(assetPublicPath)、html 模版(template)。
2、创建应用容器
随后 qiankun 需要构造一个给子应用的容器(createElement),这个容器是一个子应用独有的 div,标记了从子应用信息挖出来的 id、name、version、config 等信息。容器的形态取决于几个因素:
- 子应用是否有 html 模版,有的话需要装进去才能让子应用找到渲染 DOM
- 子应用是否需要样式隔离,有的话可能要加一层 shadow DOM
然后要确保在子应用挂载前,这个容器被渲染并挂到页面上。
3、沙箱构造
接着 qiankun 会构造一个沙箱(createSandboxContainer),然后依赖 execScripts 方法把 window 代理到沙箱上,并在恰当的时候开关拦截。
4、构造传给下游 single-app 的生命周期
这里先从子应用脚本中解析出生命周期(bootstrap, mount, unmount, update),然后补充一些逻辑:
- mount 时,补充容器获取和绑定、容器挂载、沙箱开启
- unmount 时,补充沙箱关闭、容器卸载
3.2 HTML Entry 接入
html 解析能力来自import-html-entry
库。
它加载完 html 资源,就按 string 继续解析(processTpl),主要方法是通过正则匹配出里面的字符串,比如异步 script:
js
// 异步 script
if (matchedScriptSrc) {
var asyncScript = !!scriptTag.match(SCRIPT_ASYNC_REGEX);
scripts.push(asyncScript ? {
async: true,
src: matchedScriptSrc
} : matchedScriptSrc);
return genScriptReplaceSymbol(matchedScriptSrc, asyncScript);
}
然后把这些资源打包返回。
3.3 样式隔离
样式隔离是避免子应用之间、子应用-父应用之间出现 class 名的相互污染。
处理样式隔离一般只有两个方法:一是为所有 class name 增加唯一的 scope 标记;二是利用 shadow dom 的天然隔离。
自己加 scope
参考 qiankun 文档:常见问题 - qiankun,可以通过干预编译、利用 antd 等框架的能力来做。
scope 的qiankun实现
如果懒得自己 scope,可以通过 qiankun 配置直接生成 scope:
js
sandbox: { experimentalStyleIsolation: true }
这个参数会给所有的 class name 外层增加一个子应用独有的标识:
css
div[data-qiankun-react16] .app-main {
font-size: 14px;
}
通过遍历所有 style 节点,增加前缀:
js
const styleNodes = appElement.querySelectorAll('style') || [];
forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => {
css.process(appElement!, stylesheetElement, appInstanceId);
});
// css.process
const prefix = `${tag}[${QiankunCSSRewriteAttr}="${appName}"]`;
processor.process(stylesheetElement, prefix);
shadow dom 的乾坤实现
qiankun 通过配置也可以实现 shadow dom 隔离:
js
sandbox: { strictStyleIsolation: true }
其实是在容器和内容间增加了一层 shadow:
js
// createElement
const { innerHTML } = appElement;
appElement.innerHTML = '';
let shadow: ShadowRoot;
if (appElement.attachShadow) {
shadow = appElement.attachShadow({ mode: 'open' });
} else {
shadow = (appElement as any).createShadowRoot();
}
shadow.innerHTML = innerHTML;
当然后面获取容器的时候也会兼容这点:
js
// getAppWrapperGetter
if (strictStyleIsolation && supportShadowDOM) {
return element!.shadowRoot!;
}
return element!;
3.4 JS 沙箱
子应用加载过程中,qiankun 构造 JS 沙箱:
js
// loadApp
if (sandbox) {
sandboxContainer = createSandboxContainer( appName, /* 其他参数 */ );
global = sandboxContainer.instance.proxy as typeof window;
mountSandbox = sandboxContainer.mount;
unmountSandbox = sandboxContainer.unmount;
}
沙箱创建后,会去包裹子应用脚本的执行上下文。沙箱实例被传到 import-html-entry
包里,最终用在对 script 标签的包装执行上:
js
const code = `;(function(window, self, globalThis){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`
eval(code);
这样子应用的所有「模块和全局变量」声明,就都挂到了代理上。脚本导出的生命周期,也都执行在代理上。
JS 沙箱的目的是,任何一个微应用,在活跃期间能正常使用和修改 window,但卸载后能把「初始」window 还回去供其他微应用正常使用,且当微应用再次活跃时,能找回之前修改过的 window。这就必然需要一个和 app 一一对应的代理对象来管理和记录「app 对 window 的修改」,并提供重置和恢复 window 的能力。
qiankun 为我们准备了三套沙箱方案:
- ProxySandbox:代理沙箱,在支持 Proxy 时使用
- LegacySandbox:继承沙箱,在支持 Proxy 且用户 useLooseSandbox 时使用
- SnapshotSandbox:快照沙箱,在不支持 Proxy 时使用
ProxySandbox
当我们有 Proxy 时,这件事很好办。我们可以让 window 处于「只读模式」,所有对 window 的修改,都将属性挂到代理对象上,使用时先找代理对象,再找真 window。
js
class ProxySandbox {
proxyWindow
isRunning = false
active() {
this.isRunning = true
}
inactive() {
this.isRunning = false
}
constructor() {
const fakeWindow = Object.create(null)
this.proxyWindow = new Proxy(fakeWindow, {
set: (target, prop, value, receiver) => {
if(this.isRunning) target[prop] = value
},
get: (target, prop, receiver) => {
return prop in target ?target[prop]:window[prop];
}
})
}
}
ProxySandbox 的好处是实现简单,在设计上非常严谨,完全不会影响原生 window,所以卸载时也不需要做任何处理。
LegacySandbox
Proxy 的另一种用法是,放 app 去修改 window,只做被修改属性的「键-初始值」、「键-修改值」记录,在卸载后把初始值挨个重置,在再次挂载后把修改值挨个恢复。
LegacySandbox 保证了真实 window 的属性和当前 app 用到的 window 属性完全一致。如果你需要全局监控当前应用的真实环境,这点就很重要。
SnapshotSandbox
如果环境不支持 Proxy,就没法在「活跃时」做监听,只能尝试在挂载卸载的时候想办法。
- 对 window 来说,我们只要在挂载时备份一份「快照」存起来,卸载时再把快照覆盖回去。
- 反过来对 app 环境来说,我们需要在卸载时 diff 出一份「被修改过」的快照,挂载时把快照覆盖回去。
拦截其他副作用
三种沙箱都实现了对 window 属性增删改查的拦截和记录,但子应用还可能对 window 做其他有副作用的操作,比如:定时器、事件监听、DOM节点API操作。
这就是沙箱实例暴露 mount、unmount 方法的原因。当子应用 mount 时,实现对其他副作用的拦截和记录,unmount 时再清除掉。这些副作用包括:
sh
patchInterval 劫持定时器
patchWindowListener 劫持window事件监听
patchHistoryListener 劫持history事件监听(umi专用)
patchDocumentCreateElement 劫持DOM节点创建
patchHTMLDynamicAppendPrototypeFunctions 劫持DOM节点添加方法
副作用的拦截方法都采用同样的接口实现:
js
function patchXXX(global) {
// 给 mount 调用,在 global 上拦截方法
return function free() {
// 给 unmount 调用,清除副作用
}
}
实现思路都是维护一个「池」,把 mount 后注册的定时器、事件、DOM等记录下来,在 unmount 时清除。比如定时器:
js
function patchInterval(global) {
// 给 mount 调用,在 global 上拦截方法
let intervals: number[] = [];
global.clearInterval = (intervalId: number) => {
intervals = intervals.filter((id) => id !== intervalId);
return rawWindowClearInterval.call(window, intervalId as any);
};
global.setInterval = (handler: CallableFunction, timeout?: number) => {
const intervalId = rawWindowInterval(handler, timeout);
intervals = [...intervals, intervalId];
return intervalId;
};
return function free() {
// 给 unmount 调用,清除副作用
intervals.forEach((id) => global.clearInterval(id));
}
}
3.4 预加载
qiankun 可以通过配置或手动调用发起 prefetch:
js
start({ prefetch: true });
// or
prefetchApps([...]);
发起预加载的时机无非两种:立即预加载(prefetchImmediately)、首个应用挂载后预加载其他应用(prefetchAfterFirstMounted)。但这只是时机差别,预加载的逻辑是一致的。
qiankun 说了,在浏览器空闲时预加载,那肯定要用 requestIdleCallback:
js
requestIdleCallback(async () => {
// 第一次空闲时解析入口资源
const { getExternalScripts, getExternalStyleSheets } = await importEntry(entry, opts);
// 后面空闲时下载资源
requestIdleCallback(getExternalStyleSheets);
requestIdleCallback(getExternalScripts);
});
3.5 小结
- 乾坤的特性离不开在子应用加载上下的功夫(loadApp),这个过程包含入口解析、容器创建、沙箱构造、生命周期补充,最后调用 single-app
- HTML Entry 特性由 import-html-entry 库实现,通过对 html 字符串进行正则匹配,得到资源信息。
- 样式隔离主要有 class name scope 和 shadow DOM 两种方式,qiankun 都做了支持。前者靠遍历 stylesheet 更改 class name,后者靠容器构建时增加 shadow DOM 层。
- JS 沙箱是用一个代理对象拦截对 window 的操作。qiankun 提供了Snapshot、Proxy、Legacy三种沙箱,区别在于对属性增删改的拦截方式,效果是一样的。一些直接调用全局 api 的副作用(定时器、DOM操作、事件等)则需要额外拦截和恢复,通常靠维护一个「属于当前子应用的副作用池」。
- 预加载用的是 requestIdleCallback。
4 业务系统
4.1 配置的数据模型
为了实现动态部署,业务平台要回答一个问题:每次启动时,这个微前端要注册哪些微应用?也就是「平台 - 系统 - 子应用 - 资源」之间关系的维护和下发。
好在这个关系并不复杂:
这是一套最简单的微前端管理模型,在此之上,可根据自己需求选择性加上用户、角色、权限、模版、菜单、导航等。
4.2 用户请求流程
当用户来访问业务平台上的系统时,基本会经过以下流程:
- 用户通过域名,经DNS解析,访问到平台的前端服务器。平台作为基建,会承载多个业务系统,每个业务系统又有各自的域名,这里会要求每个接入的业务域名都在DNS配置解析到平台统一的前端服务器IP。
- 匹配接入配置,锁定一个系统。一方面系统配置会作为"准入"的nginx配置挂在前端服务器上。另一方面,根据请求携带的域名等信息,可以匹配到具体请求来自哪个系统。
- 根据系统获取系统配置。这些配置包含整个系统ID关联的子应用、资源、权限、导航等等配置,通过接口可以一次性返回给客户端,也可以先返回系统ID,客户端再按需请求。客户端的微前端框架现在知道要注册哪些应用了。
- 客户端加载静态资源。在配置中会关联应用框架和子应用用到的所有静态资源的CDN地址,按微前端的逻辑,这些资源会在微应用 load 的时候异步加载。
Z 总结
- 微前端将单体应用拆分为若干个微应用,它们独立开发、独立部署、独立运行。其理论基础是不同框架下相同的底层API。目前主流是在 single-spa、qiankun 的技术方案基础上,做业务系统的封装。
- single-spa 根据路由变化调度微应用的资源加载和运行,并定义了一套微应用生命周期。实现上依赖内部的一套"应用池"+"刷新器"+路由监听。
- qiankun 在 single-spa 基础上增加了 js 和 css 隔离、html entry、预加载等开箱即用的工程友好特性。其基础是自己实现了
import-html-entry
库来控制资源加载和运行时,实现一层容器以隔离样式,并借助 Proxy 等api 实现沙箱来劫持 window 操作。 - 在业务系统实际应用中,还要在 qiankun 基础上构建数据模型和服务,实现「平台-系统-微应用-资源」各级配置的下发来启动和动态注册微前端。