微前端解决的三个核心问题:
- 1、解决异构前端网站的融合性问题,兼容多种前端框架和技术栈,新旧项目的融合为一个应用。
- 2、确保微前端应用之间全局变量、事件、样式资源等状态隔离,运行时状态不共享。
- 3、微前端应用之间的数据通信、数据状态与缓存管理,页面保活。方法一:使用iframe与自定义消息来传递;方法二:使用web Components, 通过主应用和微应用来通知
上面是微服务的定义,每个功能可以独立运行,也可以组合在一起来完成更复杂的业务功能。通常微服务讲的更多的是后台架构,微前端是微服务在前端中的一种实现方式。
一个子应用可以有自己独立的仓库,独立构建、测试、上线,在运行时把这些功能组合在一起。
- 多技术栈共存,当一个老项目运行着,此时又需要引进新技术,微前端就比较合适,如早年的 backbone 或者 vue2 等项目,当下要把 react + ts 引进来。
- 大型项目多团队负责,各团队可维护着自己的模块,比如大型控制台(如阿里云控制台、腾讯云控制台),各团队可以独立开发并上线,最后由统一的主应用来把各个团队做的功能组合在一起。
- 巨石应用,比如邮箱业务,除核心的邮件读写业务外,还有较多的附加功能,如记事本、日历、任务管理、文档等等,这些业务需要独立可以运行,又需要可以融合进主系统中。
- iframe 接入业务的体验问题,比如 iframe 内外通信困难,iframe 里面的弹框不能弹在外部等等。
微前端的种类

上面是 single-spa 定义的微前端的种类,通常 Application 和 Parcel 两种方式我们见的较多;Utility 这一类见的较少,一般通过 monorepository 或者 npm 包方式可以解决。
Application
这类很常见,他的特点是「子应用」为一个独立的系统,有自己的路由判断规则,根据路由显示不同的内容。
上图中主应用加载子应用时也告知了子元素的 dom 元素可以渲染到 #container div 中。所有子应用的路由都会去监听 url 的 hash 值,并改变要展示的内容,这里要自己避免子应用间的冲突,比如两个子应用都被激活,最后把内容都渲染到 #container 中了。
Pracel
这个也被称为组件型子应用,他原理比较简单,主应用负责加载与卸载子应用,调用后,子应用负责渲染 dom 到主应用提供的 dom 容器中就可以了。
目前组件型有两种方式来加载,一种是通过 js 代码加载:
const domElement = document.getElementById('place-in-dom-to-mount-parcel');
const parcelProps = { domElement, customProp1: 'foo' };
const parcel = singleSpa.mountRootParcel(parcelConfig, parcelProps);
另一种是基于 custom element,如:
<Frame-x name="button" subapp="xxx" />
组件型微前端做的比较好的案例是 bit.dev,通过鼠标移动不同的元素上可以看到这些组件由不同的 team 完成的。
市面上的框架
接下来通过市面上的前端框架 single-spa、qiankun 等来讲述下他们是怎么实现微前端的;qiankun 也是基于 single-spa 的,他的主要目标是做到类似 iframe 一样来接入子项目,所以增加了 html entry 特性,同时为了让多个子应用共存,也做了沙箱。
single-spa
这里拿 single-spa 的 application(路由型)来举例,路由型的特点是根据路由来判断要激活哪些 application。比如根路由为 /,/app1 为子「应用1」,/app2 为「子应用 2」。拿官方的一个 demo 来阐述:
demo 中由三个应用组成,root-application 称为主应用(有时也叫基座);app1 和 app2 分别为两个子应用。
import * as singleSpa from 'single-spa';
// 注册 app-1 子应用,同时指定了子应用的 js 文件,以及 url 中有 /app1 时激活这个应用
singleSpa.registerApplication('app-1', () =>
import ('../app1/app1.js'), pathPrefix('/app1'));
// 注册 app-2 子应用,同时指定了子应用的 js 文件,以及 url 中有 /app2 时激活这个应用
singleSpa.registerApplication('app-2', () => import ('../app2/app2.js'), pathPrefix('/app2'));
singleSpa.start();
上面代码为 root-application 的「主代码」,引入 single-spa 后,并在 singleSpa 中注册了两个子应用,当 url 中包含 /app1 时则加载 app1 的「../app1/app1.js」并执行,/app2 时加载 app2 文件执行。
具体每个子应用做什么事情,则由子应用决定,可以通过 react/vue/backbone 等各种方式实现业务功能,产生 dom 附加到主应用提供的 dom 元素下。
demo 的子应用基于 react 实现,通过 ReactDom.render() 把内容渲染到动态创建的 div 元素,再把这个新创建的 div append 到 body,完成了子应用的 DOM 附加到主应用里,这一块新产生的 DOM 区域就和子应用的业务代码捆板了再一起。demo 中主应用也提供 navbar,用于提供路由切换。
上面 demo 的效果图,默认进来时,主应用没有加载任何子应用,所以只渲染自己主应用的 html 内容,当点击 app1 时,url 地址则变为 http://location:8888/app1。 此时 single-app 执行 app1/app1.js 代码,渲染 app1 子应用。
例子虽然简单,但 single-spa 做了挺多事情,用户切换路由时,需要判断哪个子应用被激活,同时哪些子应用需要被卸载。比如当用户已切换到 /app1 ,再切到 /app2 时,则需要把 app1 卸载了,同时把 app2 加载进来,single-spa 完成这一系列的子应用切换管理,每个子应用都有装载与卸载的过程,single-spa 抽象为子应用生命周期。
single-spa 生命周期
一个子应用的生命周期有如下几个阶段,分别为:
- register,注册一个子应用,由主应用注册
- load,加载子应用的入口 JS
- bootstrap,启动子应用,告诉子应用可以做一些初始化操作
- mount,装载子应用,执行子应用的渲染逻辑,同时产生的元素添加到主应用的 document 中
- unmount,卸载子应用,把元素从基座 document 中移除
export {
boostrap:()=>{}
// 渲染子应用到 #app dom 元素下
mount: ()=>{ReactDOM.render(<App/>, document.querySelector('#app'))},
unmount: ()=>{ },
}
singleSpa.registerApplication({
name: 'app-1',
app: () => import ('../app1/app1.js'),
activeWhen: '/app1',
})
上面代码 register 了 「app-1」 子应用,完成了生命周期的第1步; 第 2 步则执行注册时提供的 app 方法,用于加载子应用的入口文件,入口文件只会执行一次,当应用被 unmount 后,下一次重新 mount,则会用内存中已 import 的对象。
剩下的3步,就是第2步 import ('../app1/app1.js') 里暴露出来的方法,boostrap 和 mount 为应用被激活时调用,unmount 为应用卸载时调用。
这样一个子应用只需要把自己产生的入口文件给到 single-spa,同时提供 mount/unmount 负责把自己的内容装载到 dom 和 从 dom 上卸载。single-spa 就能达到管理多个子应用的能力。
应用 mount/unmount
注册子应用到 single-spa 时,指定了 url 的前缀,用于判断是否子应用被激活。single-spa 通过重写 window.history.pushState/replaceState 等方法来监听 url 上的变化,来判断哪些子应用需要被激活和卸载。
window.history.pushState = patchedUpdateState(
window.history.pushState,
"pushState"
);
window.history.replaceState = patchedUpdateState(
window.history.replaceState,
"replaceState"
);
当监听到 url 发生变化后,则判断哪些应用需要 mount 和 unmount。对应代码如下,通过遍历一遍所有注册过的子应用,判断当前 url 上的路由是否命中了当前 url。
export function getAppChanges() {
const appsToUnload = [], appsToUnmount = [], appsToLoad = [], appsToMount = [];
apps.forEach((app) => {
const appShouldBeActive =
app.status !== SKIP_BECAUSE_BROKEN && shouldBeActive(app);
switch (app.status) {
....
case NOT_BOOTSTRAPPED:
case NOT_MOUNTED:
if (!appShouldBeActive && getAppUnloadInfo(toName(app))) {
appsToUnload.push(app);
} else if (appShouldBeActive) {
appsToMount.push(app);
}
break;
....
}
});
return { appsToUnload, appsToUnmount, appsToLoad, appsToMount };
}
子应用写法区别
写 react 应用,会有一句 ReactDOM.render(<App/>, document.querySelector('#app')) 来渲染到 DOM 上,但上面所说,要让 single-spa 使用,需要提供 mount/unmount,这个渲染并附加到 DOM 上的时机让 single-spa 来控制,而不是一加载 js 就执行。需要改成如下写法:
{
boostrap:()=>{/** boostrap */}
mount: ()=>{ReactDOM.render(<App/>, document.querySelector('#app'))},
unmount: ()=>{ /* 从
}
但是这里还没完成,通过 webpack 打包产生的入口文件为一个闭包函,import 这个入口文件并不能返回子元素入口实例,所以需要通过 system.js 来解决这个问题,使得这个入口文件可以导出,同时也有相关 webpack 插件。具体可参考,这里不做细说。也可以通过 webpack 中 output 提供的 libray 方式,把子应用的入口文件导出到一个 window 变量中,主应用可以通过这个变量拿到子应用暴露的入口文件导出的实例。
沙箱
single-spa 也有没有解决的问题,比如说:
- 变量隔离,两个子应用或者子应用和主应用都在 window 上挂了一个同样名称的变量,相互覆盖了。
- 卸载后残留,应用被 unmount 后,子应用的 定时器、history事件、全局事件还在运行。
- dom 隔离,子应用的 dom 元素被卸载后, js 动态添加的 css 脚本放在了 head 里,没有被卸载,影响到了后期其它应用。
以上是沙箱要解决的问题,所以市场上出了较多新的前端框架,如 qiankun,alibabacloud-alfa 等等,有了自己的沙箱,通过不同的手段来保证各子应用的 JS/CSS 不冲突。
JS 沙箱
传统 iframe 加载的子应用,一个页面下通过 iframe 加载了多个子应用都没关系,因为他们都是操作自己的 window 对象,但是 single-spa 就不行了,子应用一加载后,和主应用或者多个子应用都操作同一 window 对象。所以就产生了微前端沙箱来解决这个问题,目标也是逼近 iframe 版沙箱,达到隔离效果。
目前主要有三种沙箱,Snapshot、Iframe、Proxy 实现的沙箱,下面来分别看下,不过这里就只能隔离 window 上的变量来讨论,隔离 setInterval、setTimeout、全局事件 可参考 qiankun 代码,原理也是重写 widnow 下的方法,达到可控:
Proxy 沙箱
较完美的一种沙箱,用的是 Proxy 特性,通过 Proxy 代理 window 对象,生成一个 fakeWindow 全新对象给到子应用使用,fakeWindow 再判断哪些需要转到原始 window 上去,同时后期子应用产生的所有全局变量都是设置在这个 fakeWindow 上,从而达到了隔离。具体可参考
const fakeWindow = {} as FakeWindow;
接下来是怎么让子应用用上这个 fakeWindow,方法是给所有引入子应用的入口 JS 文件内容外加一层闭包,使得入口 JS 文件下用的 window,都是我们传入的 fakeWindow。
(function(window, self, globalThis){;${入口文件内容}}).bind(fakeWindow)(fakeWindow,fakeWindow,fakeWindow);
这样入口 JS 文件下引入的 window 变量就是我们传入的 fakeWindow 了。
iframe 沙箱
用 Proxy 代理也有一些缺点,实际业务中需要隔离的不仅是 window 下的全局变量,还有一类全局事件 widnow.setInterval、window.addEventListener、history 这类产生的内容要怎么清理?在 Proxy 中是需要去 hack 这一类全局的方法,使得他们产生的内容也只在 fakeWindow 上有效,但需要去做比较多的 hack 才能完成。
Iframe 沙箱较好的解决了这类问题,由于 Iframe 标签产生的 window 是一个全新的,不设置 Iframe 的 url ,使之和当前页面是同域,进而可以拿到 Iframe 下的 widnow 变量。再把所有的 JS 代码都放在 Iframe 下加载,所有 JS 产生的全局变量都是设置在 Iframe 下的 window。
但这里还有些细节,比如说 iframe 下的 history/location/document 需要操作的为主应用下的,所以这里也是通过 Proxy 方式,把 iframe 这一类操作透传到外部,进而操作的都是外部的内容。具体可参考 alibabacloud-alfa
export const evalScripts = async (code, conf = {}) => {
const ctx = await Context.create( conf );
const resolver = new Function(`
return function({window, location, history, document}){
with(window.__CONSOLE_OS_GLOBAL_VARS_) {
${code}
}
}//@sourceURL=${conf.name}`);
return resolver({ ...ctx });
}
snapshot 沙箱
上面两种方案看起来都完美,但是都是依赖于 window.Proxy 特性,这个特性在 IE 下直接不支持,所以 snapshot 是一个退而求其次的办法了,他的实现思路简单,子应用启动前,先把 window 上的所有变量都备份一次,然后子应用用原始 window 对象,当子应用被卸载时,则再把备份的恢复。
但是如果有两个子应用同时启动,这个方案就不支持了。
CSS 沙箱
这一块有业界较多框架是没做,子应用间通过命名来隔离,可以直接通过 postcss 在发布前来解决,在 css 规则前加上前缀来隔离,这种叫做编译型的隔离方案。
在 qiankun 中支持两种运行期的隔离方案:
- 通过 ShoadowDow 隔离,这个不细说,具体可参考。不过官方案例在启用了这个特性后也不能正常使用,比如说 modal 不可用。
- 通过改写用户样式代码
第 2 点的原理就是拿到子应用的 style 后,然后依次改写样式内容,比如原 css 的内容为
.app-main {
display: flex; flex-direction: column; align-items: center;
}
会改写成
div[data-qiankun="react16"] .app-main {
display: flex; flex-direction: column; align-items: center;
}
做法就是在每条样式规则前加上子应用 dom 容器上的应用名,通过正则方式替换原始 css 规则,从而达到隔离,具体可参考。css 隔离上个人研究通过 postcss 在编译阶段就解决比较合适。
但是市场上的微前端框架还不止步于此,比如 qiankun 的目标是让子应用像接入 iframe 一样简单。
html entry
这是一个大胆的创新,就像接入 iframe 一样简单,比如通过 webcomponent 定义一个自定义标签,元素内拿到 entry 再处理后续:
或者 qiankun 下注册一个子应用
registerMicroApps([
{
name: 'react16',
entry: 'http://localhost:7100',
container: '#subapp-viewport',
loader,
activeRule: '/react16',
}]
这里提供的入口文件返回的不是一个 JS 文件,而是一个 html ,就完全和 iframe 一样接入子应用了,此时 qiankun 就多做了一步,他需要从 html 中找出所有的 JS / CSS 内容,拿到 JS 后再通过 single-spa 提供的 registerApplication 来注册子应用,并把 html 中的 body 内容插入到子应用容器中。再通过沙箱来加载 JS,从而完美的实现了类似 iframe 一样来接入微前端子应用。这里主要分几步:
- 远程获取 html 内容,直接通过 fetch 来请求
- 解析 html 内容,从 html 中找出 JS 和 CSS 标签并把地址抽取出来
- fetch JS 内容,并把内容放在沙箱环境中跑
prefetch
qiankun 会在 start 后,先去拉每个子应用的 html 与 JS/CSS 内容,避免在真正使用时才去拉,加快使用时的速度。不过这里会通过 requestIdleCallback 函数去拉取每个文件的内容,避免阻塞正常使用。
总结
整体微前端还有较多体系化的工作要做,比如说子项目怎么管理、发布、上线等,子项目在开发期怎么和主项目一起运行等,接下来等邮箱微服务落地后再与大家分享。
参考文章: