Table of Contents
微前端是我参加工作不久,一个同事大佬在公司尝试推行的,本人有幸参与其中。在微前端的概念出现之前,微服务就已经出现并且大火,而微前端就是借鉴了微服务的架构而产生的,他们很相似,我们可以对比着理解。
微服务 | 微前端 |
---|---|
一个微服务就是由一组接口构成,接口地址一般是 URL。当微服务收到一个接口的请求时,会进行路由找到相应的逻辑,输出响应内容。
后端微服务会有一个网关,作为单一入口接收所有的客户端接口请求,根据接口 URL与服务的匹配关系,路由到对应的服务。 |
一个微前端则是由一组页面构成,页面地址也是 URL。当微前端收到一个页面 URL 的请求时,会进行路由找到相应的组件,渲染页面内容。
微前端则会有一个加载器,作为单一入口接收所有页面 URL 的访问,根据页面 URL 与微前端的匹配关系,选择加载对应的独立模块 |
表一
关于微前端网上也有很多讨论,本以为微前端是一个很复杂的东西,然而自己亲自参与过后发现,微前端并不难理解,接下来,我将带你一步步带你搞一个微前端框架。
微前端的实现方案有很多,今天我们使用的是比较火的 single-spa 的方式。如果你学会了,就可以尝试用微前端的方式重构你公司的应用了。
我们搞微前端的背景
首先我们需要先知道什么是微前端,如果我在这里按照概念说一遍,相信大家也比较抽象。我这里讲一下我们搞微前端的背景,大家就会知道微前端可以解决哪些问题,进而就会明白微前端是什么了。
我所在的组有很多2B的业务,而且后来公司运用策略发生变化,出现了很多新的业务线,每个业务线都有自己需要的定制的运营功能,为了满足这些新业务线的运营需求,我们要做的就是:
- 将原有的运营后台加入切换业务线的功能
- 将分散在其他平台的运营功能迁移过来
- 为每个业务线开发定制的功能并能够根据业务线展示
- 对于业务线定制的功能,会尽量交给各个业务线负责,这就要求各个业务线都要用统一的技术栈来开发
最终呈现就像下边这样。
按照传统方案的设计就是这样,然而这样的话将会造成以下问题:
- 原有的运营后台需要重构,主要重构点是鉴权和业务线切换这块,当然后端的某些接口也要做一些处理
- 其他平台功能的迁移成本有点高,因为用的是不同的技术栈
- 运营需求越来越多,平台就会越来越庞大,会使维护成本越来越高,迭代不够灵活,多人协作造成各种冲突
- 某一个模块出了bug有可能造成整个系统崩溃
使用微前端的优势
俗话说,天下之大,分久必合,合久必分,微前端就是一个既合又分完美方案,在上述场景下,微前端再适合不过了。微前端的方案是指将系统的每个模块都拆分出来,独立开发、独立部署,然后将所有功能集成在一起,而这些功能模块完全解耦,甚至模块之间可以使用不同的技术栈来实现,老平台功能就可以平滑迁移。
图2
那么它是如何实现的呢?接下来我们使用Vue+single-spa实现一个微前端系统,你就可以理解微前端的原理了。
微前端的基本原理
微前端系统需要一个主模块和若干子模块,主模块包含整个项目的入口文件,需要对子项目进行注册,根据路由匹配对子项目进行渲染,其次需要提供首页、登录、菜单、鉴权、业务切换等。子项目不需要有HTML文件,只需要输出资源文件即可,资源包括js/css/img/fonts等。
当用户进入到微前端系统,首先运行的就是加载器(上边表一中提到的),加载器会对每个模块进行注册,模块注册成功之后,当用户进入到这个模块,微前端加载器就会渲染该模块,然后会执行钩子函数:
- bootstrap 生命周期,只会在挂载的时候执行一遍。
- mount 生命周期,加载器挂载时执行。
- unmount 生命周期,子模块卸载的时候执行。
图3
single-spa+Vue+Element实现一个微前端系统
按照上述的微前端的原理和流程图,我将以single-spa+Vue+Element带领大家分别构建出主项目和子项目,进而完成一个真正的微前端应用。为了方便大家的理解,此次讲解先不加入切换业务线的功能,这个功能重要但是实现起来不麻烦,同学们构建起微前端之后可以自行加入这个逻辑。
主项目
入口文件main.js
我们的主项目使用的是Vue+Element的技术栈,main.js和传统的Vue项目一样是完成Vue对象的初始化工作,下边先实现一个最基本的入口文件main.js,相信大家也很熟悉了。
import Vue from 'vue';
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
import Router from 'vue-router';
import routerMap from './router/index';
import App from './App';
import util from '@/common/js/util';
Vue.use(ElementUI);
Vue.use(Router);
// 绑定路由
const router = new Router(routerMap);
router.beforeEach((to, from, next) => {
if (to.fullPath === '/login' || from.fullPath === '/login') {
next();
} else {
// 判断是否登录
if (!util.login()) {
login(); // 登录
} else {
next();
}
}
});
new Vue({
router,
render: h => h(App)
}).$mount('#app');
复制代码
然后你需要在main.js中将子模块的配置引入进来,我们会在加载器注册子模块时用到,配置文件参考如下:
export default {
test: {
id: 1,
parent: {
id: 1,
name: 'test管理'
},
name: 'test模块',
path: 'http://xxx.com/app.js',
router: '/test'
},
demo: {
id: 2,
parent: {
id: 1,
name: 'demo管理'
},
name: 'demo模块',
path: 'http://xxx.com/app.js',
router: '/demo'
}
};
复制代码
然后将其引入进来并存储,在main.js中添加:
import appList from './appList'
util.setCache('appList', appList);
复制代码
路由配置
这里需要注意的是关于路由的配置,主项目中提供首页、菜单、登录、404等公共部分逻辑和相关页面,菜单我们使用Elemnet的NavMenu组件,另外就是加载器的逻辑和页面(Portal页面)。所以路由应该是这样的:
import Home from '@/views/Home';
import Portal from '@/views/Portal'; // 加载器
import Login from '@/views/Login';
import NotFound from '@/views/404';
export default {
routes: [
{
path: '/',
redirect: '/portal',
component: Home,
children: [
{
name: 'Portal',
path: `/portal*`,
component: Portal, // 加载模块
}
]
},
{
path: '/login',
component: Login,
meta: {
label: '登录',
hidden: true
}
},
{
path: '/404',
component: NotFound,
meta: {
label: '404',
hidden: true
}
},
{
path: '*',
redirect: '/404',
meta: {
hidden: true
}
}
]
}
复制代码
加载器Portal页面
404和login页面大家可以自行设计。接下来的重点是重头戏 Portal 页面的实现,这个页面实际上就是各个子项目的入口,其主要逻辑就是加载器的实现,接下来直接上代码,我们对着代码一步步理解。
<template>
<div id="MICRO-APP"></div>
</template>
<script>
/* eslint-disable */
import util from '@/common/js/util';
import { registerApplication, start, getAppNames, getAppStatus, unloadApplication } from 'single-spa';
export default {
data() {
return {};
},
methods: {
registry(key, app) {
// 去重
if (getAppNames().includes(key)) {
return;
}
// registerApplication 用于注册我们的子项目,第一个参数为项目名称(唯一即可),第二个参数为项目地址,第三个参数为匹配的路由,第四参数为初始化传值。
registerApplication(
key,
() => {
const render = () => {
// 渲染
return window.System.import(app.path).then(res => {
if (res) {
return res;
} else {
return render();
}
});
};
return render();
},
location => {
if (location.pathname.indexOf(app.router) !== -1) {
return true;
} else {
return false;
}
}
);
},
// 注册模块
async registerApp() {
const appList = util.getCache('appList');
for (const key in appList) {
if (appList.hasOwnProperty(key)) {
const app = appList[key];
this.registry(key, app);
}
}
}
},
mounted() {
start(); // 启动项目
this.registerApp();// 注册模块
}
};
</script>
<style lang="less" scoped>
#MICRO-APP {
position: relative;
width: 100%;
height: 100%;
z-index: 10;
}
</style>
复制代码
portal页面的HTML部分只有一个id为MICRO-APP的根元素,它作为子项目的容器,我们会在子模块中进行配置。
生命周期
在讲解registerApplication之前,我们首先对注册子模块的生命周期做一个介绍,这将帮助你可以更加深刻的理解加载器的原理和注册的过程。需要注意的是,我说的生命周期指的是注册子模块这个过程的生命周期,而不是这个页面的生命周期。
注册的子模块会经过下载(loaded)、初始化(initialized)、被挂载(mounted)、卸载(unmounted)和unloaded(被移除)等过程。single-spa会通过“生命周期”为这些过程提供钩子函数。这些钩子函数包括:
- bootstrap:这个生命周期函数会在子模块第一次挂载前执行一次。
- mount:在注册某个子模块过程中,当 activityFunction(registerApplication的第三个参数)返回为真,且该子模块处于未挂载状态时,mount生命周期函数就会被调用。调用时,函数会根据URL来确定当前被激活的路由,创建DOM元素、监听DOM事件等以向用户呈现渲染的内容。挂载过之后,接下来任何子路由的改变(如
hashchange
或popstate
等)不会再次触发mount
,需要各模块自行处理。 - unmount:每当应用的 activityFunction 返回假值,但该应用已挂载时,卸载的生命周期函数就会被调用。卸载函数被调用时,会清理在挂载应用时被创建的DOM元素、事件监听、内存、全局变量和消息订阅等。
页面的其他生命周期不在详细论述,想了解更多请参考官方文档构建应用
另外还需要注意:这些生命周期函数的调用是需要在各个子模块的入口文件中实现的,我们在讲到子模块的时候在给大家介绍如何实现
registerApplication方法
这个方法的定义如下:
singleSpa.registerApplication(
app.key,
application(app.path),
activityFunction(app.router),
{ access_token: 'asnjknfjkds' }
);
function loadingFunction(path) {
return import(path);
}
function activityFunction(location,router) {
return location.pathname.indexOf(router) !== -1;
}
复制代码
参数讲解:
- 第一个参数是一个key值,需要唯一
- 第二个参数是一个回调函数,必须是返回promise的函数(或”async function”方法)。这个函数没有入参,会在子模块第一次被下载时调用。返回的Promise resolve之后的结果必须是一个可以被解析的子模块。常见的实现方法是使用import加载:() => import(‘/path/to/application.js’),为了能够独立部署各个应用,这里的import使用的是 SystemJS
- 第三个参数也是一个函数,返回bool值,
window.location
会作为第一个参数被调用,当函数返回的值为真(truthy)值,应用会被激活,通常情况下,Activity function会根据window.location
/后面的path来决定该应用是否需要被激活。激活后,如果子模块未挂载,则会执行mount生命周期。 - 第四个参数属于自定义参数,,然后我们可以在各个生命周期函数中通过props**.**customProps接收到这个参数这个参数也许会很有用,比如以下场景:
- 各个应用共享一个公共的 参数,比如:access_token
- 下发初始化信息,如渲染目标
- 传递对事件总线(eventBus)的引用,方便各应用之间进行通信
小结
以上就是关于主项目的讲解,原理上还是有点复杂的,但是实现起来代码量不大,总结下来就是:
- mian.js是主项目入口文件,初始化项目和处理登录逻辑,引入子模块配置文件。
- 路由的配置
- 加载Portal页面执行 registerApplication 注册,生命周期有些复杂,不过搞不懂也不影响使用
子项目
子项目原则上可以采用任何技术栈,这里我以Vue为例和大家一起实现一个子模块。
single-spa-vue
完成了主项目之后,子项目的实现也就非常简单了,这里主要用到的一个东西就是 single-spa-vue,singleSpaVue 是 single-spa 结合 vue 的方法,我们使用它来实现已经注册该子模块的生命周期逻辑。它的第一个参数是 vue,第二个参数 appOptions 就是我们平时传入的vue配置。
子项目入口mian.js
import Vue from 'vue';
import Router from 'vue-router';
import App from './App';
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
import routerMap from './router';
import store from './store';
import singleSpaVue from 'single-spa-vue';
Vue.use(ElementUI);
Vue.use(Router);
const router = new Router(routerMap);
const vueLifecycles = singleSpaVue({
Vue,
appOptions: {
router,
store,
render: h => h(App),
el:'#MICRO-APP' // Portal页面中的子模块容器,子模块会被插入到该元素中
}
});
export const bootstrap = vueLifecycles.bootstrap;
export const mount = vueLifecycles.mount;
export const unmount = vueLifecycles.unmount;
复制代码
如果你想在生命周期函数之后做些事情可以向下边这样做
const vueLifecycles = singleSpaVue({...})
export const mount = props => vueLifecycles.mount(props).then(instance => {
// do what you want with the Vue instance
...
})
复制代码
路由配置
import List from '../views/List.vue';
export default {
mode: 'history',
base: '/portal',
routes: [
{
path: '/demo',
redirect: '/demo/list',
component: List
},
{
name: 'List',
path: '/demo/list',
component: List
}
]
};
复制代码
路由配置一样很常规,只要注意base的设置即可
小结
以上是关于子项目的讲解
最后想在这里说一句,微前端是个好技术,不过也要考虑场景,在适合的场景下用才是好技术。
资源
- 本项目源码git 地址:
主项目:github.com/hui-fly/mic…
子项目demo:github.com/hui-fly/mic…
后续会逐步完善,优化项目性能,以及增加react子项目,欢迎star交流 - 快速生成子项目的脚手架:
rv-cli
使用方法
npm i rv-cli -g
rv create
参考
qiankun.umijs.org/zh/
tech.meituan.com/2018/09/06/…
alili.tech/archive/ea5…
juejin.im/post/5cadd7…
juejin.im/post/5d7f70…
juejin.im/post/5d3925…
评论前必须登录!
注册