微前端探索
fengshi123 opened this issue · 0 comments
一、微前端由来
随着前端历史化进程的推进,出现了两种前端开发模式,MPA 多页面应用模式和 SPA 单页面应用模式,其分别有自己的独到之处以及不足点。
(1)MPA 模式
例如中后台系统涵盖多个业务模块,分别由不同的团队负责,并且每个业务模块都有独立的域名,访问不同的业务模块会重新刷新浏览器或者新开标签页的方式来实现系统间的跳转。MPA 模式的优点在于部署简单、各个业务模块之间隔离,天然具备技术栈无关、独立开发、独立部署的特性;其缺点也明显,不同模块之间切换会造成浏览器重刷,不同产品域名之间相互跳转,流程体验上会存在明显的断点。
(2)SPA 模式
相信现在前端应用几乎由 SPA 三大马车 Vue、React、Angular 构建开发,应用之间页面跳转通过监听浏览器 URL 进行页面的卸载/挂载,所以其优点是天生具备体验上的优势,页面之间切换无需刷新浏览器,能极大的保证多产品之间流程操作串联时的流程性;缺点则在于各应用模块是强耦合的,并且随着应用的需求迭代,会产生巨石应用。
微前端是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将单页面前端应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。其集合了 MPA 模式和 SPA 模式各自的优势,通常的微前端架构具有以下优势:
- 技术栈无关:在同一页面上使用多个前端框架 而不用刷新页面 (Vue,React, Angular 等);
- 强独立性:不同业务应用独立开发、独立部署、增量更新;
- 运行时隔离共享:不同业务子应用之间可以共享数据以及进行通信,但又能做到 js 和 css 互不影响;
- 体验优势:具有单页面应用流程操作连贯性,页面切换无需刷新;
二、iframe 存在的问题
在早期,微前端概念出现之前,我们整合多个团队多个应用,我们不约而同的选择即为 iframe。iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。但它的最大问题也在于它的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题。
- url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。
- ui 不同步,dom 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中..
- 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。
- 子应用首批加载慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。
三、single-spa 实例
single-spa 是很多微前端框架的基石(其本身就是微前端框架,但很多大厂、微前端框架基于其进行二次封装),所以深刻知道其原理是对微前端的探究和实践的基础。single-spa 是一个将多个单页面应用聚合为一个整体应用的 javascript 微前端框架。
single-spa 具体的教程以及 api 可以查看 single-spa 官网,也建议在阅读以下章节内容时,先自行稍微简单阅读下其官网,知道其是什么、能做什么等。本章节,我们直接使用 single-spa 官网提供的实践实例来进行描述 single-spa 的使用,并且带着对现象背后的思考引出下一章节的原理解析。
1、克隆实例项目
git clone https://github.com/joeldenning/coexisting-vue-microfrontends.git
2、启动项目
在基座应用和子应用目录,分别安装依赖包,然后分别启动应用:
// root-html-file
cd root-html-file
npm install
npm run serve
// navbar
cd navbar
npm install
npm run serve
// app1
cd app1
npm install
npm run serve
// app2
cd app2
npm install
npm run serve
3、观察实例 & 思考
(1)我们查看基座应用的实例代码,基座应用中进行子应用的注册(registerApplication)调用了 single-spa 的 registerApplication 函数,其内部实现了些啥,registerApplication 完再进行 start 启动,start 是做什么的,内部又是怎么实现的?
(2)我们查看子应用的入口执行文件 main.js 发现子应用都会导出 3 个周期函数 bootstrap/mount/unmount,那这三个周期函数又是在什么时候执行呢?
(3)我们通过浏览器访问 http://localhost:5000/ ,可得以下页面。通过看实例代码,我们可以发现以下页面是基座应用 navbar 的页面,那 single-spa 是怎么做到这个路由匹配的,以及点击 App1、App2 会分别跳转到 app1、app2 子应用的页面,且不会刷新页面,也就是 single-spa 的关键功能点 —— url 路由匹配;
(4)我们不断点击 App1 App2,观察浏览器调试框 network tab 选项,我们会发现:切换到不同的子应用,只会在第一次渲染子应用时才会去加载子应用渲染所需的资源,后续的切换不会再加载相关资源;观察浏览器调试框 Elements 选项,我们会发现:不同子应用的 Dom 结构会随着子应用的切换,而对应地挂载、卸载,那 single-spa 是如何进行子应用的资源下载、挂载和卸载呢?
如果你对以上的一些现象 or 实现不知所以然,并且你很想去了解下原理,那么下一章节的内容 — 原理解析将很适合你。
四、single-spa 原理
用一句话概括 single-spa 的原理:single-spa 是一个状态机 ,框架只负责维护各个子应用的状态,其中怎么加载子应用、挂载子应用、卸载子应用等,都由子应用自身控制,从而 single-spa 框架有很好的扩展性。
我们可以从 single-spa 的 github 官网 clone 源码查看,sinlge-spa 的功能源码主要集中在 src 目录下,src 目录下各个文件的主要功能汇总如下图
我们了解大概的源码目录及功能后,我们再回过头去逐一探究下第二部分的一些 api 以及功能的原理。在一些代码注释讲解部分,我们省略掉一些不影响原理理解的参数校验等。
1、registerApplication 注册做了哪些事情
registerApplication 注册方法在文件 /src/applications/app.js 中定义,代码及注释如下所示,其主要做了三件事情:
- sanitizeArguments: 参数规范化,保证每个子应用注册的参数合法;
- apps.push: 将注册的子应用添加到数组 apps,并为每个子应用添加内部属性,例如非常重要的属性 status 标注子应用的状态;
- reroute: 这个方法在下一小节重点介绍,我们暂且知道方法内部会判断如果是注册会进行子应用的资源加载 (loadApps 方法),并在加载完成时,在子应用的 app 上添加相关的生命周期钩子(toLoadPromise 方法);
(1)registerApplication 注册方法代码如下:
export function registerApplication(
appNameOrConfig,
appOrLoadApp,
activeWhen,
customProps
) {
// hb: 格式化用户传入的应用配置参数,保证传入的参数是合法的
const registration = sanitizeArguments(
appNameOrConfig,
appOrLoadApp,
activeWhen,
customProps
);
// hb: 已经存在相同名称的应用报错
if (getAppNames().indexOf(registration.name) !== -1)
throw Error(
formatErrorMessage(
21,
__DEV__ &&
`There is already an app registered with name ${registration.name}`,
registration.name
)
);
// 将每个应用的配置信息都存放到 apps 数组中
apps.push(
assign(
{
loadErrorTime: null,
status: NOT_LOADED,
parcels: {},
devtools: {
overlays: {
options: {},
selectors: [],
},
},
},
registration
)
);
if (isInBrowser) {
ensureJQuerySupport();
reroute();
}
}
(2)loadApps 方法代码如下:
// hb: 整体返回一个立即resolved的promise,通过微任务来加载apps
function loadApps() {
return Promise.resolve().then(() => {
// hb: 返回封装后的 app,包括给其附上生命周期等
const loadPromises = appsToLoad.map(toLoadPromise);
console.log('查看 loadPromises :', loadPromises);
return (
Promise.all(loadPromises)
.then(callAllEventListeners)
// there are no mounted apps, before start() is called, so we always return []
.then(() => [])
.catch((err) => {
callAllEventListeners();
throw err;
})
);
});
}
(3)toLoadPromise 方法核心代码如下:
export function toLoadPromise(app) {
return Promise.resolve().then(() => {
if (app.loadPromise) {
// hb: 说明 app 已经被加载
return app.loadPromise;
}
if (app.status !== NOT_LOADED && app.status !== LOAD_ERROR) {
return app;
}
app.status = LOADING_SOURCE_CODE;
let appOpts, isUserErr;
return (app.loadPromise = Promise.resolve()
.then(() => {
// hb: loadApp 即是用户传入的参数 () => System.import('navbar'),
// 所以加载子应用其实就是通过用户自己传入的加载方式,即使如果不用 System.import 也可以;
// 没有明白属性获取来干嘛用 getProps(app) ???
const loadPromise = app.loadApp(getProps(app));
// hb: 子应用导出的必须是个对象,且包含 3 个生命周期:bootstrap、mount、unmount
return loadPromise.then((val) => {
app.loadErrorTime = null;
appOpts = val;
app.status = NOT_BOOTSTRAPPED;
// hb: 在app对象上挂载生命周期方法,每个方法都接收一个props作为参数,方法内部执行子应用导出的生命周期函数,并确保生命周期函数返回一个promise
app.bootstrap = flattenFnArray(appOpts, "bootstrap");
app.mount = flattenFnArray(appOpts, "mount");
app.unmount = flattenFnArray(appOpts, "unmount");
app.unload = flattenFnArray(appOpts, "unload");
app.timeouts = ensureValidAppTimeouts(appOpts.timeouts);
// hb: 执行到这里说明子应用已成功加载,删除app.loadPromise属性
delete app.loadPromise;
return app;
});
})
.catch((err) => {
// hb: 加载失败,稍后重新加载
delete app.loadPromise;
let newStatus;
if (isUserErr) {
newStatus = SKIP_BECAUSE_BROKEN;
} else {
newStatus = LOAD_ERROR;
app.loadErrorTime = new Date().getTime();
}
handleAppError(err, app, newStatus);
return app;
}));
});
}
2、start 方法做了些什么
我们从上一小节已经知道 registerApplication 时会对注册并且 url 路径匹配到的子应用进行下载,也是说如果只注册,会下载匹配子应用的资源,但是并不会进行初始化或者渲染;那么 start 方法的作用即进行子应用的初始化和渲染,代码如下所示,我们可以看到在 start 方法中主要调用 reroute 方法,在 reroute 方法中会区分是 start 前还是后,如果是 start 前,则就像上一小节所讲的,是注册时进行子应用资源的下载;如果是 start 后,则调用 performAppChanges 方法,对不同状态的子应用进行对应操作
- appsToUnload // 需要被移除的应用进行移除
- appsToUnmount // 需要被卸载的应用进行卸载
- appsToLoad // 需要被加载的应用进行加载
- appsToMount // 需要被挂载的应用进行挂载
(1)start 方法代码
// hb: 调用 start 之前,应用会被加载,但不会初始化、挂载和卸载,有了 start 可以更好的控制应用的流程
export function start(opts) {
started = true;
if (opts && opts.urlRerouteOnly) {
setUrlRerouteOnly(opts.urlRerouteOnly);
}
if (isInBrowser) {
reroute();
}
}
(2)reroute 方法
export function reroute(pendingPromises = [], eventArguments) {
const {
appsToUnload, // hb: 需要被移除的
appsToUnmount, // hb: 需要被卸载的
appsToLoad, // hb: 需要被加载的
appsToMount, // hb: 需要被挂载的
} = getAppChanges();
let appsThatChanged;
// hb: 是否 start 调用 isStarted 为 false 时表示是 start 调用
if (isStarted()) {
appChangeUnderway = true;
appsThatChanged = appsToUnload.concat(
appsToLoad,
appsToUnmount,
appsToMount
);
return performAppChanges();
} else {
appsThatChanged = appsToLoad;
return loadApps();
}
// hb: 整体返回一个立即resolved的promise,通过微任务来加载apps
function loadApps() {
return Promise.resolve().then(() => {
// hb: 返回封装后的 app,包括给其附上生命周期等
const loadPromises = appsToLoad.map(toLoadPromise);
return (
Promise.all(loadPromises)
.then(callAllEventListeners)
// there are no mounted apps, before start() is called, so we always return []
.then(() => [])
.catch((err) => {
callAllEventListeners();
throw err;
})
);
});
}
function performAppChanges() {
return Promise.resolve().then(() => {
// hb: 移除应用 => 更改应用状态,执行unload生命周期函数,执行一些清理动作
// 其实一般情况下这里没有真的移除应用
const unloadPromises = appsToUnload.map(toUnloadPromise);
// hb: 先卸载再移除
const unmountUnloadPromises = appsToUnmount
.map(toUnmountPromise)
.map((unmountPromise) => unmountPromise.then(toUnloadPromise));
const allUnmountPromises = unmountUnloadPromises.concat(unloadPromises);
const unmountAllPromise = Promise.all(allUnmountPromises);
unmountAllPromise.then(() => {
window.dispatchEvent(
new CustomEvent(
"single-spa:before-mount-routing-event",
getCustomEventDetail(true)
)
);
});
// hb: 待加载的进行加载 并且进行挂载
const loadThenMountPromises = appsToLoad.map((app) => {
return toLoadPromise(app).then((app) =>
tryToBootstrapAndMount(app, unmountAllPromise)
);
});
// hb: 待挂载的进行挂载
const mountPromises = appsToMount
.filter((appToMount) => appsToLoad.indexOf(appToMount) < 0)
.map((appToMount) => {
return tryToBootstrapAndMount(appToMount, unmountAllPromise);
});
return unmountAllPromise
.catch((err) => {
callAllEventListeners();
throw err;
})
.then(() => {});
});
}
}
3、子应用导出的生命周期函数执行时间点
我们查看子应用的入口执行文件 main.js 发现子应用都会导出 3 个周期函数 bootstrap/mount/unmount,那这三个周期函数又是在什么时候执行呢?
其实在下载完子应用资源后,会将子应用的生命周期函数添加在 app(single-spa 中每个子应用是一个 app 对象,然后汇总成数组 apps)的属性中,然后 single-spa 会在子应用 app 状态更新时对应执行其生命周期函数。
(1)加载方法中处理子应用生命周期方法的代码
export function toLoadPromise(app) {
app.status = NOT_BOOTSTRAPPED;
// hb: 在app对象上挂载生命周期方法,每个方法都接收一个props作为参数,方法内部执行子应用导出的生命周期函数,并确保生命周期函数返回一个promise
app.bootstrap = flattenFnArray(appOpts, "bootstrap");
app.mount = flattenFnArray(appOpts, "mount");
app.unmount = flattenFnArray(appOpts, "unmount");
app.unload = flattenFnArray(appOpts, "unload");
app.timeouts = ensureValidAppTimeouts(appOpts.timeouts);
});
})
(2)flattenFnArray 方法
// hb: 返回一个接受 props 作为参数的函数,这个函数负责执行子应用中的生命周期函数,
// 并确保生命周期函数返回的结果为promise
export function flattenFnArray(appOrParcel, lifecycle) {
let fns = appOrParcel[lifecycle] || [];
fns = Array.isArray(fns) ? fns : [fns];
if (fns.length === 0) {
fns = [() => Promise.resolve()];
}
return function (props) {
return fns.reduce((resultPromise, fn, index) => {
return resultPromise.then(() => {
const thisPromise = fn(props);
});
}, Promise.resolve());
};
}
4、子应用切换
通过实例发现我们的基座应用能根据不同的 URL 对应挂载/卸载我们的子应用,且不会刷新页面,那么 single-spa 是如何做到 url 路由匹配的呢?其实如果大家有了解过 vue-router、react-router 这些单页面应用的路由切换原理的话,其无非应用了 hashchange 事件来监听 hash 路由的变化、popstate事件来监听 history 路由的变化,其 single-spa 的 url 路由匹配的原理是一模一样的(基础 api 就那些,难道还能变出花来吗),在 /src/navigation/navigation-events.js 文件中定义相关操作。
window.addEventListener("hashchange", urlReroute);
window.addEventListener("popstate", urlReroute);
而且我们在注册子应用时,把子应用路由匹配规则作为参数进行传入了,single-spa 在 /src/applications/app.helpers.js 中使用这个传入的路由匹配判断条件进行判断该子应用是否应该处于活跃(挂载)状态,相关代码如下,其中 app.activeWhen 即为传入路由匹配函数。到此,我们就差不多知道了 single-spa 是如何做到当 url 切换时,匹配到相应子应用的。
// hb: 是否应该活跃状态(url 匹配到路由)
export function shouldBeActive(app) {
try {
return app.activeWhen(window.location);
} catch (err) {
handleAppError(err, app, SKIP_BECAUSE_BROKEN);
return false;
}
}
5、子应用挂载/卸载如何实现
我们通过以上已经知道 single-spa 控制着子应用的状态变化,例如在注册时进行子应用资源的下载、进行子应用的挂载/卸载,我们第三章节中是通过我们注册应用时传入的下载方式 System.import 进行下载的,并不是 single-spa 内部有资源下载方式;那么挂载/卸载呢?其实挂载/卸载也是通过各个子应用自己传入对应的生命周期函数进行对应的操作,我们查看子应用使用的插件 single-spa-vue(不是 single-spa) 的挂载/卸载生命周期函数,可以看到对应生命周期函数进行 dom 元素的挂载和卸载。而在 single-spa 内部仅仅是在对应的子应用状态执行子应用对应的生命周期函数,single-spa 本身只起控制状态的作用,它自己本身不亲自操刀的,无论下载、挂载、卸载等,这样也能做到更好的扩展性,用户想怎么下载、挂载、卸载,他们自己来决定,只要你传入规范的参数即可。
(1)single-spa-vue 的挂载/卸载生命周期函数
// 挂载生命周期函数
function mount(opts, mountedInstances, props) {
return Promise
.resolve()
.then(() => {
const appOptions = {...opts.appOptions}
if (props.domElement && !appOptions.el) {
appOptions.el = props.domElement;
}
if (!appOptions.el) {
const htmlId = `single-spa-application:${props.name}`
appOptions.el = `#${htmlId.replace(':', '\\:')} .single-spa-container`
let domEl = document.getElementById(htmlId)
if (!domEl) {
domEl = document.createElement('div')
domEl.id = htmlId
document.body.appendChild(domEl)
}
// single-spa-vue@>=2 always REPLACES the `el` instead of appending to it.
// We want domEl to stick around and not be replaced. So we tell Vue to mount
// into a container div inside of the main domEl
if (!domEl.querySelector('.single-spa-container')) {
const singleSpaContainer = document.createElement('div')
singleSpaContainer.className = 'single-spa-container'
domEl.appendChild(singleSpaContainer)
}
mountedInstances.domEl = domEl
}
if (!appOptions.render && !appOptions.template && opts.rootComponent) {
appOptions.render = (h) => h(opts.rootComponent)
}
if (!appOptions.data) {
appOptions.data = {}
}
appOptions.data = {...appOptions.data, ...props}
mountedInstances.instance = new opts.Vue(appOptions);
if (mountedInstances.instance.bind) {
mountedInstances.instance = mountedInstances.instance.bind(mountedInstances.instance);
}
})
}
// 卸载生命周期函数
function unmount(opts, mountedInstances) {
return Promise
.resolve()
.then(() => {
mountedInstances.instance.$destroy();
mountedInstances.instance.$el.innerHTML = '';
delete mountedInstances.instance;
if (mountedInstances.domEl) {
mountedInstances.domEl.innerHTML = ''
delete mountedInstances.domEl
}
})
}
相信阅读到这里的读者,此时脑海中对笔者关于 single-spa 的原理总结也深有感触吧,single-spa 是一个状态机 ,框架只负责维护各个子应用的状态,其中怎么加载子应用、挂载子应用、卸载子应用等,都由子应用自身控制,从而 single-spa 框架有很好的扩展性。
通过本章节的阅读,我门深刻理解 single-spa 框架的运行机制,但是 single-spa 作为最底层架构,在实际场景中还是存在一些问题的,如下所示,下一章节我们将围绕这些问题去探讨如何解决。
- single-spa 使用 js entry 作为子应用入口,旧有项目改造成本很高;
- 子应用存在 css 样式相互影响;
- 子应用存在全局 js 污染;
- single-spa 框架并没有提供子应用之间或者子应用与基座应用之间的通信机制;
五、qiankun(乾坤) 原理
qiankun(乾坤) 就是一款由蚂蚁金服推出的比较成熟的微前端框架,基于 single-spa 进行二次开发,用于将 Web 应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。如果还不了解该框架的同学,可以先查阅qiankun 官网;本章节我们主要围绕第三节抛出的几个疑问,来探讨下 qiankun(乾坤)是如何处理的。
1、子应用独立运行
qiankun 使用 import-html-entry 插件将子应用的 html 作为入口,框架会将 HTML document 作为子节点塞到主框架的容器中。就算子应用更新了,其入口 html 文件的 url 始终不会变,并且完整的包含了所有的初始化资源 url,所以不用再自行维护子应用的资源列表了。并且对旧有的项目作为子应用接入成本几乎为零,开发体验与独立开发时保持不变,相较于 single-spa 的 js entry 而言更加灵活、方便、体验更好。
2、css 样式隔离
由于微前端场景下,不同技术栈的子应用会被集成到同一个运行时中,子应用之间难免会出现样式互相干扰的问题。样式隔离有两个思路,第一个是使用类似于 CSS Module 或者 BEM 的方案,本质上是通过约定来避免冲突,对于新项目来说,这种方案成本很低,但是如果涉及到与老项目一同运行,那改造成本将会非常高昂;第二个思路是在子应用卸载的时候同时卸载掉样式表,技术原理是浏览器会对所有的样式表的插入、移除做整个 CSSOM 的重构,从而达到 插入、卸载样式的目的,这样即能保证,在一个时间点里,只有一个应用的样式表是生效的。
qiankun 框架采用的是第二种思路,使用 import-html-entry,通过解析 html entry 中的 和 <style> 标签获取样式信息,下载样式文件,并最终以 <style> 标签的形式插入到主框架的容器中去,在子应用卸载时一并移除,这样确保不同子应用之间避免样式冲突。
3、js 全局隔离
相较于样式隔离来说,js 隔离显得更为重要。因为在 SPA 的场景下,类似内存泄漏、全局变量冲突等问题的影响会被放大,可能某个子应用内的问题会影响到其他应用的运行。而且这种问题通常非常难以排查和定位,一旦发生,解决成本非常高。
qiankun 框架基于 Proxy 为每个子应用启用了一个沙箱环境,所有子应用对 proxy/window 对象值的存取都受到了控制。设置值只会作用在沙箱内部的 updateValueMap 集合上,取值也是优先取子应用独立状态池(updateValueMap)中的值,没有找到的话,才再从主应用的 proxy/window 对象中取值,这样确保了各子应用的全局 js 互相冲突污染。
4、子应用之间通信
通常从我们从业务的角度出发划分各个子应用,尽可能减少应用间的通信,从而简化整个应用,使得我们的微前端架构可以更加灵活可控,但是有些场景下,各子应用之间的相互通信还是存在的。
qiankun 框架提供了 Actions 通信(观察者模式) ,内部提供了 initGlobalState 方法用于注册 MicroAppStateActions 实例用于通信,该实例有三个方法,分别是:
- setGlobalState:设置 globalState - 设置新的值时,内部将执行浅检查,如果检查到 globalState 发生改变则触发通知,通知到所有的观察者函数;
- onGlobalStateChange:注册观察者函数 - 响应 globalState 变化,在 globalState 发生改变时触发该观察者函数;
- offGlobalStateChange:取消观察者函数 - 该实例不再响应 globalState 变化。
六、总结
本文介绍了微前端的由来,分析微前端出现的必要性;然后总结了 iframe 用来聚合应用存在的一些问题;再通过 single-spa 的实例现象和 api 使用去探讨 single-spa 实现的原理;最后通过 qiankun 微前端框架探讨在实际场景中微前端模式存在哪些必解问题,以及如何去解决的。通过循序渐进,从了解微前端,到了解相关框架的原理,再到实际场景问题解决,从而全方面的“认识”微前端。