在写这篇文章的一个多月前,本坑还不知道微前端是什么,大概从字面上的含义是比较小的前端项目。
本坑开始实践它,是由于工作要求。改造一个运行多年,前端用 jsp 写的服务平台项目(以下简称该平台)。改造它是改造它的前端架构。改造它的原因是比较多人反馈,其页面加载和渲染显得吃力,页面切换后首屏等待时间长的问题,交互体验舒适度不可避免的下降了,特别是在老式电脑面前。
该平台业务比较多,所以组长希望前端框架组能把平台中的前端部分分离出来,最好用当下满大街的 Vue、能够按照各个一级菜单分成若干前端子项目,用户访问依然是整体的项目,同时这一改造实施过程不需要重做一个、而是整个 500 多个页面从局部开始、是逐步、兼容的,新旧同时运行,直至整体被替换。(ps: 不重做?这 …… 科学吗?)
看看, 慢在哪里
其实大概知道慢在哪里,但是不知道究竟慢在具体哪个部分。和其他一以贯之的类似管理平台布局并无不同。左边导航栏,上面顶栏,右侧内容栏,整体页面是一个 index.jsp。上面提到的内容栏是一个 iframe,里面通过切换 src 来切换页面。更多的业务造就更多页面,更多页面带来更多的加载。加上长时间没有做好资源加载的管理,导致渲染一次页面需要加载大量 js,css 或者多次加载同一个文件的情况。该平台大量的配置页面生成,是通过 easyui 的来做的。通过数据来创造整个页面 dom 节点,也拖累了内容完整呈现的时间。
我们使用谷歌浏览器 performance 可以最终追查到这个系统在哪些方面,哪个方法存在着哪些延迟。结论是:
1、混乱的项目资源管理导致大量的资源请求。
2、easyui 和项目中不少的 dom 操作带来大量的重排和重绘。
3、埋点,插件使用不当以及其他。
微前端
它是什么呢?
微前端的概念来自于之前流行的微服务。它的来源很大程度是来自于这篇文章。微服务系统使得后台服务架构能够比较好地规避越来越臃肿的体积带来的性能下降。根据业务合理拆分成一个个的服务,尽量避免一个子服务影响整个项目运行的优势,有效的进行隔离。
那么,前端也有同样的需求吗?答案是肯定的。
今天,日益更新的前端技术,已经能够把一个个页面各个小元素打包成组件库,功能包,在多个项目中引入使用。此外,我们不用再使用难受的 iframe 来聚合不同的项目,而是导出一个个 web component,只需要 import 到页面就可以使用。把一个个子项目打包成一个个 web component,聚合在入口项目之内。这也许就是微前端现在比较时髦的样子。
如果一个大项目有以下特点,微前端可以在这些项目中运用:
1、大项目有统一的入口,子项目页面需要无刷新下切换,可是各个子项目在业务上和开发团队上是不同的。
2、项目过大,打包、运行、部署效率出现显著下降的问题。这时希望能根据业务拆分打包,部署。
方案与实施
回到本文开头,一开始面对这样的需求还是有些想辞职的冲动,因为觉得需求有点不是符合实际,实际上要实施改版也是需要过程的。
不过静下来想想,搜搜,翻了翻当前项目的前端结构,隐隐约约似乎浮现一些需求可行性的线索。
因为项目的最终目的是把整个 jsp 页面改成 vue 来写。而这一要求是逐步替换的过程,所以在改造过程中,同时要保证项目兼容 jsp 的页面。我们继续沿用了原来就有的 iframe,借此把 jsp 融入整个微前端框架,而已经改造的 micro 则不需要 iframe.
我们的开发团队,分框架组和各个业务组。其中每个业务组有 3 到 8 个人,他们大多数是后端背景,主要做的也是后端开发。框架组有前端和后端。为了应付庞大的业务开发需求。大部分后端人员都需要使用 jsp,js 等前端技术进行开发。
框架组为了减少他们的前端开发门槛,前端框架组会封装好 easyui 组件,提供业务组使用。所以,正如前文提到,后端人员是通过数据,结合框架组提供的组件来完成页面的开发的。从某种角度来说,数据配置的页面对接下来的改造工作有一定的帮助,因为大部分页面可以同时改写。
我们对整个项目进行了大致的分类。
1、portal 项目:该项目是整个微前端项目的入口。里面含有 loader,用以加载各个项目模块。它也嵌入到子项目中,使得单独运行子项目和 portal 项目一样的界面要求。
2、permission 项目:该项目包含菜单组件,登录页面,顶栏组件,权限控制等。在任何环境下,它都必须首先加载,为子项目模块挂载提供锚点。
3、common 项目:该项目包含公共业务组件。比如封装好的页面,可以直接给不太能够掌握 vue 项目的后端人员更加友好的去使用。
4、业务项目:就是指业务组各个模块开发的前端项目。什么样的业务分为一个项目,这点由产品和技术人员一起来决定。相对于 portal 项目,业务项目相当于它的子项目。
前端框架组必须提供一套统一的业务项目的前端模板,可以在确认新建的子项目后迅速的加入到整个项目中,进行开发和部署,而这一过程不能影响其他项目的部署和运行。
除了上述方案浮出水面,还会在改造过程中遇到一个个细节问题。不过在大方向,框架组成上,前端结构上做好了,细节问题也会随耐心和时间被解决。
分别阐述
本坑根据以上的分类,大致进行说明其实现。这其中结合了不少前辈之经验,在文章结尾处鸣谢。
Portal:
portal 项目是整个项目部署的入口,它的核心来自于 single-spa
在整个项目结构中它将集成到每一个子项目。集成的方式很粗暴简单,就是外联加载。
portal 负责根据不同的环境来对应的组件和 app, 同时也安装各个 app,卸载各个 app 等,它负责 app 在 single-spa 的生命周期。比如集成模式下根据环境和路由加载对应的 app,而在子项目运行时只加载公共组件和不同业务的 app。
那么 protal 是如何加载的呢?
protal 维护了一个 json 里面包含了各个子项目的 index.html 的信息,通过匹配 index.html 里面的 src、link, 加载各项资源。
module.exports = {
common: {
webName:'common',
globalVarName: 'mfe:common',
componentsTarget: '/common/release/components/web.html',
resourcePatterns: ['/components.[0-9a-z]{8}.js/g'],
loadType:'before'
},
permission: {
webName:'permission',
globalVarName: 'mfe:permission',
// URL 匹配模式
matchUrlHash: '',
// 微前端地址
componentsTarget: '/permission/release/components/web.html',
webTarget:'/permission/release/web/web.html',
// 资源匹配模式
resourcePatterns: ['/common.[0-9a-z]{8}.css/g','/store.[0-9a-z]{8}.js/g', '/publicPath.[0-9a-z]{8}.js/g','/singleSpaEntry.[0-9a-z]{8}.js/g','/components.[0-9a-z]{8}.js/g'],
// 是否要在项目启动前加载,before 为提前加载,after 为 hash 变化后加载
loadType:'before'
},
app4vue:{
webName:'repair-order',
globalVarName: 'mfe:app4vue',
matchUrlHash: '/layout/repair-order',
webTarget: '/app4/release/web/web.html',
resourcePatterns: ['/common.[0-9a-z]{8}.css/g','/store.[0-9a-z]{8}.js/g', '/singleSpaEntry.[0-9a-z]{8}.js/g'],
loadType:'after'
}
}
async gatherResource () {
const self = this
// const spaEntry = 'portal'
const web = self._webName
// 如果是微前端聚合模式
if (window._IS_SIGLE_PORTAL) {if (web !== 'mfe-permission') {await self.loadComponents(micros.common)
await self.loadApp(micros.permission)
}
}
else {if (web === 'mfe-permission') {await self.loadComponents(micros.common)
} else {if (web !== 'mfe-common') {await self.loadComponents(micros.common)
}
await self.loadApp(micros.permission)
}
}
// return new Promise(resolve => resolve('loader:all Finish!'))
}
permisson:
permission 负责登录页,layout 中的菜单栏,顶栏。所有的子项目 app 都必须挂载到 permission 项目中的显示区块里。也就是说 permssion 会提供锚点给子项目挂载。
所以 permission 负责路由的控制,这里的路由有总体路由和 app 内路由切换。如果 app 切换的路由控制涉及到 singer-spa,app 的切换会触发 single-spa:routing-event 事件,portal 监听该事件 unmounted 和 mounted app,如果 app 内部的路由切换,需要触发 app 内部路由切换。
本坑尝试监听 permission 的 hash, 由于 vue 新版本,hash 实际是监听不了的,所以监听 hash 是没办法的。
这看上去会干涉到子项目的代码,如果哪位大神有好方法可以在评论区贴上你的看法。
业务项目
业务项目的独立运行只会发生在开发模式之下,在生产或者测试环境并不会独立运行。
集成环境下输出成三个周期,提供给 single-spa
export var global = {};
export const bootstrap = () => { return Promise.resolve(); }
export function mount (props) {
Vue.mixin({data: function () {
return {props}
}
})
return Promise.resolve().then(() => {createDomElement();
global.instance = new Vue({
el: '#app4',
router,
render: h => h(App)
})
})
}
export function unmount () {return Promise.resolve().then(() => {global.instance.$destroy();
global.instance.$el.innerHTML = '';
delete global.instance
})
}
function createDomElement () {
// Make sure there is a div for us to render into
let node = document.getElementById('main-content');
let el = document.getElementById('app4');
if (!el) {el = document.createElement('div');
el.id = 'app4';
node.appendChild(el);
}
return el;
}
开发模式独立运行
const init = async () => {
// 启动 single-spa
const loader = new Loader(process.env)
await loader.startSingleSpa()
Vue.mixin({data () {
return {loader}
}
})
Vue.config.productionTip = false;
//permission 渲染后再挂载自己上去
window.addEventListener('single-spa:main-content-mount', evt => {if (!window.vim) {
window.vm = new Vue({el: loader.createHookEle('app4'),// 挂载自己
// store,
router,
render: h => h(App)
})
}
})
}
init()
common
common 类似插件的打包,不赘述。
import './styles/vars.scss'
import MButton from './components/button'
const components = [MButton];
const install = function (Vue) {if (install.installed) return;
components.map(component => {Vue.use(component);
});
};
// 全局引用可自动安装
if (typeof window !== 'undefined' && window.Vue) {install(window.Vue);
}
export default {
install,
MButton
}
几个主要源码采用外联形式
<link rel="stylesheet" href="/micro-frontend/common/release/components/common.css">
<script rel="preload" src="/micro-frontend/base/vue/2.6.10/vue.min.js" as="script"></script>
<script rel="preload" src="/micro-frontend/base/element-ui/2.7.2/lib/index.js" as="script"></script>
<script rel="preload" src="/micro-frontend/base/vue-router/3.0.6/vue-router.min.js" as="script"></script>
<!-- <script rel="preload" src="/micro-frontend/base/redux/4.0.1/redux.min.js" as="script"></script> -->
<script rel="preload" src="/micro-frontend/base/vuex/2.2.1/vuex.min.js" as="script"></script>
<script rel="preload" src="/micro-frontend/base/axios/0.15.3/axios.min.js" as="script"></script>
<script rel="preload" src="/micro-frontend/base/jquery/3.1.0/jquery.min.js" as="script"></script>
Single-spa
single-spa 是怎么聚合各个独立的 vue app 的呢?本坑尝试理解它
Single-spa 把 app 聚合成三个周期 bootstrap mounted unmounted,这三个周期是需要自己去配置改写的,其实 single-spa 还有其他周期,不需要改写。
也就是对 single-spa 来说 app 只有这三个东西需要特别的关心,app 的卸载和加载。其他都是 app 自己的事。mounted、unmonuted 和 vue app 独立运行的 mounted 和 destroy 本质上没有区别,只是 single-spa 做了一层代理。代理完成 app 的挂载和销毁。
single-spa 内部也保存了一个数组,负责维护内部注册的 app. 注册完后代理完成 app 的挂载。卸载后销毁之。
总结
如果亲爱的客官,你也遇到这种问题,用这种改法是没有保证的。
本坑实践它很大的理由也是用自己的方法初探微前端实践方法的可行性。
这种大跨度的改变会带来不少不可预测的底层冲突,前后端的冲突。
第二呢,这种大跨度的改变几乎等同于重构。
第三呢,微前端方案也有自身的局限性,比如对库版本的管理,app 样式的隔离没有做到很好等等。还是要根据实际来衡量。
针对太过古老的系统比如 jsp, 可以先尝试把 jsp 转为 html,在前端性能上多改进,再另行考虑综合性改版。
估计很多地方搞的不好,代码或者信息错误,欢迎各位指导。
鸣谢
single-spa 官网
微前端实践
前端微服务化解决方案