本篇文章来自团队小伙伴 @OnWork 的一次学习分享,希望跟大家分享与探讨。
求积硅步以致千里,勇于探享生活之美。
主应用功能分析
主应用作为基座应用,需具有以下功能:
- 加载「子应用」、应用挂载初始化程序;
- 提供一些必要的验证信息,例如 token 的获取;
- 提供一些应用间通讯的字段定义;
- 统一处理静态资源;
- 静态菜单(有时候左侧菜单栏大可不必是子应用);
- 暴露「主应用」方法给「子应用」调用;
- 提供全局常量;
- 区分是否需要验证的「子应用」;
- 404 页面处理(重写 fetch、vue-router 的动态路由匹配)
依据上述所列,我们大致对「主应用」的功能有所了解,知道「主应用」的存在是为了干些什么?(实际需求可根据项目类型动态调整)。
微前端下多应用管理
分析完主应用的功能后,接下来我们就要着手去创建「主应用」。但在这之前,我们需要解决应用(项目)管理的小问题。
微前端的最终成果,就是将一个巨型应用拆分成多个应用。
那么项目管理从单个变成多个:一个「主应用」 + 多个「子应用」,需要考虑分治与聚合,而且应用多了维护时频繁切换项目总不太优雅。
微前端的项目管理:「独立与整体互相依存」,「子应用」保持独立性,又可聚合到一起作为一个整体项目去维护管理。
为了方便管理多个应用,这里推荐使用 Git Submodule 进行项目管理。
Git 通过子模块来解决这个问题。 子模块允许你将一个 Git 仓库作为另一个 Git 仓库的子目录。 它能让你将另一个仓库克隆到自己的项目中,同时还保持提交的独立。
创建基座「主应用」和业务「子应用」
使用 Vue-CLI 脚手架创建, 因此创建时模板相差不大,「子应用」的配置大体相同,不同的是「主应用」的配置。
我们先创建四个应用:
功能 | 简称 | 应用 |
---|---|---|
主应用 | main | demo-web-main |
登录应用 | login | demo-web-login |
系统应用 | system | demo-web-system |
商品应用 | order | demo-web-order |
➜ demo-web vue -V
@vue/cli 4.5.9
➜ demo-web vue create demo-web-main
➜ demo-web vue create demo-web-login
➜ demo-web vue create demo-web-system
➜ demo-web vue create demo-web-order
并在这四个应用的根目录下添加 vue.config.js,后续我们会定义一些配置项。
搭建 main 应用
进入 main
应用,添加 vue 的环境配置文件 .env.development
demo-web-main (master) ✔ touch .env.development
在 .env.development 添加代码:
NODE_ENV = 'development'
VUE_APP_RUNTIME = ''
VUE_APP_BASE_API_GW = '/gw'
VUE_APP_NAME = demo-web-main
VUE_APP_BASE_DEVELOPMENT_PORT = 10000
VUE_APP_PROXY_URL = '//web-main.micro.com/'
修改 vue.config.js 里面的 devServer:
const {
VUE_APP_BASE_DEVELOPMENT_PORT: port, // 取自 .env.development 常量
} = process.env;
module.exports = {
// publicPath: './', // 主应用不需要 publicPath
devServer: {
hot: true,
disableHostCheck: true,
port, // 主应用端口设置:9000
overlay: {
warnings: false,
errors: true
},
headers: {
// 所有应用都需要这个,不然主应用加载子应用都会报跨域
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': '*',
'Access-Control-Allow-Headers': '*'
},
proxy: {
'/gw': {
target: 'http://www.api.com', // 代理目标地址
changeOrigin: true,
ws: true,
// 重写路径,一般配合 axios 的 baseUrl 实现代理解决跨域问题
// 我们团队后台接口都走网关,那么对应的就是 /gw
pathRewrite: { '^/gw': '' },
secure: false
}
}
},
}
在 demo-web-main/src 目录里面添加配置文件 settings.js:
module.exports = {
dashboard: { // 首页可拆成子应用(看具体需求)
icon: '',
title: '首页',
path: '/dashboard',
name: 'dashboard',
},
error404: { // 404 页面
icon: '',
title: '404',
path: '/404',
name: '404',
},
extract: { // 在新标签页中打开标签页
'help-center': { // 跳转帮助中心
icon: '',
title: '帮助中心',
path: '/help-center',
name: 'goHelpCenter',
},
},
// 子应用存放的地方 类似于 public/index.html 中的 <div id="app"></div>
SUB_APP_PREFIX: 'subapp-viewport',
MENUS: {
order: {
devEntry: '//localhost:9010',
depEntry: `//web-order.demo.com`,
moduleName: 'order', // 子应用唯一表识别此 key (方便调试)
},
system: {
devEntry: '//localhost:9024',
depEntry: `//web-system.demo.com`,
moduleName: 'system',
},
},
noAuthApp: [ // 不需要进行验证的应用,比如登录
{
module: 'login',
defaultRegister: true,
devEntry: '//localhost:9001',
depEntry: `//web-login.demo.com`,
routerBase: '/login',
}
]
}
我们既然要做微前端,那么 public/index.html 里面应用挂载点再叫作 #app
就不那么合适了,我们给他改一改名:
<!-- public/index.html -->
- <div id="app"></div>
+ <div id="main-app"></div>
现在 main
应用的初期工作已经完成了,不过距离运行起来还有点距离。
我们现在接入 qiankun,在 demo-web-main 根目录运行:yarn add -D qiankun
。
一般 Vue 应用 main.js 会做如下操作:
- 处理渲染/挂载逻辑;
- 注入第三方库依赖;
现在我们稍做改造,在 demo-web-main/src 下面新建 core 文件夹,用于存放 qiankun 相关的东西。
core/render.js 抽离渲染逻辑
我们在 demo-web-main/src/core 下面新建一个 render.js,将原本放在 main.js 里面的初始化程序搬过来:
import Vue from 'vue'
import App from '../App.vue'
import router from '../router'
import store from '../store'
Vue.config.productionTip = false
new Vue({
router,
store,
render: h => h(App)
- }).$mount('#app') // 对应上文改的挂载点
+ }).$mount('#main-app')
把渲染逻辑抽离出来了,那么 main
应用里面的 main.js 里面还放着什么呢?我们需要的第三方依赖之前是怎么安装的呢?是不是一股脑的放在 main.js 里面?这里我们再做抽离。
core/install.js 抽离注入第三方库
在 core 里面新建 install.js 去进行第三方依赖的加载:
import Vue from 'vue';
import Viewer from 'v-viewer';
import 'viewerjs/dist/viewer.css';
// eg. Viewer
Vue.use(Viewer, { defaultOptions: { zIndex: 9999, }, });
...
// 一些其他的 比如 elementui 之类的加载
第三方依赖加载完毕之后,到这里我们就可以启动下 main
应用跑跑看。
core/auth.js 用户验证
在 demo-web-main/src/core 下面新建 auth.js:
// vuex 根据项目来进行编写 这里不给出详细的
import store from '../store';
// 上一节讲到的基础设施搭建
import { LocalStorage, } from '@fe-micro/micro-util';
// 导入 qiankun 注册子应用方法 auth.js 顾名思义是用来限制权限
import qianKunStart from './app-register';
import defaultSettings from '@/settings';
const microAppStart = () => {
const token = LocalStorage.getToken();
if (token) { // 已登录状态获取服务端微应用注册表
// 处理 token 状态共享
store.dispatch('appstore/setToken', token);
return;
}
// 默认加载(未登录时无需服务端获取的微应用)
qianKunStart(defaultSettings.noAuthApp);
};
export default microAppStart;
core/app-register.js 子应用的加载注册
在 demo-web-main/src/core 下面新建 app-register.js:
项目中使用「子应用」的加载方式 : 基于路由配置
import {
registerMicroApps,
setDefaultMountApp,
start,
initGlobalState,
} from 'qiankun';
import store from '@/store';
import appStore from '../utils/app-store';
let props = {
data: store.getters,
parentStore: store,
};
let isDev = process.env.NODE_ENV === 'development';
// 用于加载 login 应用(不需要 token 的应用)
const qianKunStart = (list = []) => {
let apps = []; // 子应用数组盒子
let defaultApp = null; // 默认注册应用路由前缀
list.forEach(i => {
apps.push({
name: i.module,
entry: isDev ? i.devEntry : i.depEntry,
container: `#subapp-viewport-${i.module}`,
activeRule: i.routerBase, // 登录的 base router 是写死的
props: {
...props,
routes: i.data,
routerBase: i.routerBase,
},
});
if (i.defaultRegister) defaultApp = i.routerBase;
});
registerMicroApps(apps);
// 设置默认进入的子应用 需要进入的子应用路由前缀
setDefaultMountApp(defaultApp + '/');
// 启动微前端
start({
prefetch: 'all',
singular: false,
});
// 启动qiankun应用间通信机制
appStore(initGlobalState);
};
export default qianKunStart;
// 用于加载需要token的应用
export const qkAppStart = menus => {
let apps = []; // 子应用数组盒子
let isProd = ['production', 'prod'].includes(process.env.NODE_ENV);
menus.forEach(i => {
apps.push({
name: i.module,
entry: isProd ? i.depEntry : i.devEntry,
container: `#subapp-viewport-${i.module}`,
activeRule: i.routerBase, // 除登录外的 base router 是根据后端返回的 url 拼接的
props: {
...props,
routes: i.children,
routerBase: i.routerBase,
},
});
});
registerMicroApps(apps);
appStore(initGlobalState);
start({
prefetch: 'all',
singular: false,
});
};
现在我们在 main
应用里面的 main.js 引用加载这三个 js 文件,使其生效:
import './core/render';
import './core/install';
import microAppStart from './core/auth';
microAppStart();
完成上面的步骤那么 main
应用基本搭建完毕了。这时候我们去 yarn serve
启动 main
项目,我们不仅能看到项目正常启动,而且还会看到控制台抛出加载不到 login
应用资源的错误,这是因为 login
还没有启动。
那么下一步我们将补全 login
的配置。
搭建 login 应用
切换目录至 login
应用:
➜ demo-web-main (master) ✗ cd ..
➜ demo-web cd demo-web-login
➜ demo-web-login (master) ✔
那么我们先来对 login
应用进行改造,先把 package.json 中 name 进行修改:
{
- "name": "login",
+ "name": "demo-web-login",
...
}
添加 vue 的环境配置文件 .env.development:
demo-web-login (master) ✔ touch .env.development
demo-web-login (master) ✔
在 .env.development 里面添加代码:
NODE_ENV = 'development'
VUE_APP_RUNTIME = ''
VUE_APP_BASE_API_GW = '/gw'
VUE_APP_NAME = demo-web-login
VUE_APP_BASE_DEVELOPMENT_PORT = 10001
打开 vue.config.js:
const {
VUE_APP_NAME: name,
VUE_APP_BASE_DEVELOPMENT_PORT: port,
} = process.env;
const dev = process.env.NODE_ENV === 'development';
module.exports = {
publicPath: dev ? `//localhost:${port}` : '/',
outputDir: 'dist',
assetsDir: 'static',
filenameHashing: true,
devServer: {
hot: true,
disableHostCheck: true,
port,
overlay: {
warnings: false,
errors: true,
},
headers: {
'Access-Control-Allow-Origin': '*', // 每个应用都需要配置
},
},
configureWebpack: {
resolve: {
alias: {
'@': resolve('src'),
},
},
output: {
// 把子应用打包成 umd 库格式
// library: `${name}-[name]`,
library: 'demo-web-login',
libraryTarget: 'umd',
jsonpFunction: `webpackJsonp_${name}`,
},
}
}
我们照样把 login
应用根目录里面的 public/index.html 中 div
的 id
改成 app-login
。
这个 id
是跟 core/render 里面 vue 初始化 $mount
相关的。每个子应用都可参考 login
的配置。
我们同样在 src 目录下面创建 core 目录,并且 创建 install.js、life-cycle.js 两个文件。
install.js 里面存放第三方加载需要的东西,和 main
应用 install.js 配置一样的功能。
现在我们重点来说一下 life-cycle.js 生命周期,涉及应用间通讯及子应用渲染。
core/life-cycle.js
import { renderSubchild, } from '@fe-micro/micro-core';
import { routeMatch, } from '@fe-micro/micro-router';
import App from '@/App.vue';
import store from '@/store'; // login 应用自己的 vuex
import selfRoutes from '@/router/routes'; // login 应用自己的 路由配置
import messages from '@/lang';
// 官方通信方法
import appStore from '@/utils/app-store';
const __qiankun__ = window.__POWERED_BY_QIANKUN__;
let instance = null;
let parentStore = null;
// 导出生命周期函数
const lifeCycle = () => ({
// 只会在微应用初始化的时候调用一次
async bootstrap() {},
// 每次进入都会调用 mount 方法
async mount(props) {
props.parentStore.dispatch('appstore/setAppName', 'login');
appStore(props); // 应用间通讯
parentStore = props.parentStore;
render(props); // 渲染子应用
},
// 应用切换、卸载
async unmount() {
// 子应用 - 应用级的 keep-alive 其实 login 应用不需要这个的 其他应用可以照抄以下内容
// 应用内 - 需要各自应用实现自己的 keep-alive
const cachedInstance = instance.cachedInstance || instance;
window.__CACHE_INSTANCE_BY_QIAN_KUN_FOR_VUE__ = cachedInstance;
const cachedNode = cachedInstance._vnode;
if (!cachedNode.data.keepAlive) cachedNode.data.keepAlive = true;
cachedInstance.catchRoute = {
apps: [...instance.$router.apps]
};
if (instance.cachedInstance || instance) {
instance.$destroy();
instance = null;
}
},
// 还有一些生命周期方法请自行查阅官网进行配置
});
// 子应用渲染
const render = ({ routes, routerBase, container, i18n, }) => {
const macthRoutes = routeMatch(routes, routerBase); // 详情页
const fullMacth = [...macthRoutes, ...selfRoutes];
const fullSelf = [...selfRoutes];
const routeBase = __qiankun__ ? routerBase : '/'; // 目前并没有独立运行 所有 / 可以忽略
const __routes = __qiankun__ ? fullMacth : fullSelf;
Object.keys(messages).forEach(key => {
i18n && i18n.mergeLocaleMessage(key, messages[key]);
});
const { originInstance, } = renderSubchild({
routes: __routes,
routerBase: routeBase,
store,
parentStore,
container,
i18n,
subappKey: '#app-login',
mountPoint: App,
});
instance = originInstance;
});
export { lifeCycle, render }; // 导出给 quankun 调用
我们去修改 login
应用的 main.js:
import { loadPublicPath, } from '@fe-micro/micro-core';
import { lifeCycle, render, } from './core/life-cycle';
loadPublicPath(); // 封装了 qiankun 提供的 public-path.js
import './core/install';
const { bootstrap, mount, unmount, } = lifeCycle();
export { bootstrap, mount, unmount };
// 独立运行,但是目前并没有去整这个。还是依赖 main 去跑项目
const __qiankun__ = window.__POWERED_BY_QIANKUN__;
if (!__qiankun__) {
render();
}
我们现在运行 login
应用:yarn serve
,如果完成了上面的步骤我们会有两个 node 进程:
10000
端口的main
应用;10001
端口的login
应用
因为 10001
端口的 login
应用没有做独立部署运行,所以我们只能打开 10000
端口的 main
应用,依靠 main
应用去获取 login
应用的资源来进行显示。
打开 main
我们会得到一个错误:
没错,这个错误提示我们需要一个类似于 <router-view />
的东西去显示我们 login
应用里面的内容。
main 加载显示 login 页面
进入 main
应用修改它的 App.vue 添加相关代码:
html 部分:
<template>
<div id="root" class="root-container">
<!-- 有 Token 进入子应用视图 -->
<template v-if="hasToken">
<!-- 左侧菜单区 -->
<sidebar id="sidebar-container" class="sidebar-container"/>
<!-- 右侧视图 -->
<div class="container-panel">
<div class="main-container-content">
<!-- 上部导航区 -->
<Nav />
<!-- 子应用渲染区 -->
<div class="main-container-view">
<el-scrollbar class="main-scroll">
<div v-for="subapp in subappList" :id="subapp.key" :key="subapp.key" v-show="$route.path.startsWith(`/${subapp.pathPrefix}`)"></div>
</el-scrollbar>
</div>
</div>
</div>
</template>
<!-- 没有 Token 进入登录视图 -->
<div
v-else
id="subapp-viewport-login"
v-show="$route.path.startsWith('/login/')"
></div>
</div>
</template>
javascript 部分:
<script>
import { Sidebar, } from '@/components/sidebar';
import Nav from '@/components/nav/Nav.vue';
import defaultSettings from '@/settings';
export default {
name: 'mainView',
components: {
Sidebar,
Nav,
},
computed: {
hasToken() {
return !!this.$store.getters.token;
},
subappList() {
return Object.keys(defaultSettings.MENUS).map(key => ({
key: `${defaultSettings.SUB_APP_PREFIX}-${key}`,
pathPrefix: key,
}));
},
},
};
</script>
样式部分:
<style lang="scss">
@import '@/styles/mixin.scss';
html,
body {
margin: 0;
padding: 0;
height: 100%;
}
.root-container {
display: flex;
width: 100%;
height: 100%;
}
.container-panel {
position: relative;
flex: 1;
height: 100vh;
display: flex;
flex-flow: column;
overflow-x: overlay;
overflow-y: hidden;
.main-container-content {
flex: 1;
display: flex;
flex-flow: column;
height: 100%;
overflow-x: overlay;
overflow-y: hidden;
.main-container-view {
background: $white;
box-sizing: border-box;
overflow-y: overlay;
height: 100%;
.main-scroll {
width: 100%;
height: 100%;
background: #fff;
border-radius: 4px;
.el-scrollbar__wrap {
overflow-x: hidden;
}
}
}
}
}
[id^='subapp-viewport'] {
width: 100%;
height: 100%;
box-sizing: border-box;
[id^='__qiankun_microapp_wrapper_'] {
height: 100%;
}
}
</style>
完成上述步骤我们再重新跑一下 login
应用,效果如图:
那现在 main
应用 和 login
应用都起来了,我们可以把 login
页面做漂亮一点,做个简单的登录,其实就是为了设置 token
。
有了 token
之后,我们在 settings
里面配置的 MENUS
菜单才有意义。
因为菜单渲染这一块是业务强相关的,所以在文章里面就不放代码了,不过关键的逻辑无非循环或递归去实现的,有兴趣的同学可以看我文章尾部放出来的小 demo。
我们需要在拿到后台给的菜单数据之后,立马去调用 app-register
里面 qkAppStart
方法进行「子应用」加载,同时也进行菜单的渲染。
根据上图可以看出刷新的时候去加载了:
system
应用: http://localhost:10002/order
应用: http://localhost:10003/
由于暂未完善 system
和 order
的配置,并且没有启动,所以造成这两个应用标红报错。
我们可参照 main
和 login
的配置,来完成 system
和 order
的初始化工作,只要完成其中一个子应用,即可根据其复制粘贴配置其它子应用(前提是都基于 Vue技术栈),然后稍作修改:
- 挂载点 ID : public/index.html 中
div
的id
; - 端口配置: env.development 对应
main
里面 settings 的端口 - 子应用里面 core/life-cycle 中
subappKey
对应 public/index.html 改过的挂载点id
- 子应用 vue.config.js 中 configureWebpack.output.library
效果展示
我们看下最终的效果:
浏览器地址栏输入 http://localhost:10000 会通过 main
应用的 router
重定向到http://localhost:10000/login/ ,地址栏上的 login 后面要带斜杠不然无法触发 login
应用去加载,会造成 <div id="app-login"></div>
里面内 DOM 结构的问题。
注意事项
1. 子应用的 router
子应用的 src/router 文件夹里面此时导出的应当是 routes
数组,而不是 vue-router 的实例。
2. SyntaxError: entry should not be empty
在实际业务场景中如果出现 SyntaxError: entry should not be empty!
这个错误是因为 entry
没有配置对,建议使用 ip 或者域名。
3. qkAppStart 里面 activeRule 的路径
一定要注意 qkAppStart
里面 activeRule
的路径,一般是 /${子应用名称}
不然会遇到到至无法预料的问题,连排查都不好排查。
4. 全量加载所有子应用
如果一进入会加载全部的子应用并显示 DOM,那是因为在 app-registry.js 中没有设置 setDefaultMountApp 默认挂载的子应用。
5. 主应用子应用未能共享一个 Vuex
Vuex 我们试验过各种方案,目前采用的是将「主应用」 Vuex 暴露给「子应用」使用,如果各位有更好的方案请不吝分享。
主应用的 Vuex 已经通过 app-registry.js 里面的 qkAppStart
下发到子应用在子应用 life-cycle 中 renderSubchild
的封装方法中,自动绑定在 Vue 上。
「子应用」使用 Vuex :
import { mapParentGetters, } from '@fe-micro/micro-core';
// or
this.$parentStore
应用间的 keep-alive
应用间 keep-alive 的核心代码如下:
export const renderSubchild = ({ routes, routerBase, container, subappKey, store, parentStore, mountPoint, i18n, } = {}) => {
// 踩坑点:一定要在方法里面进行这个的获取,要结合子应用里面 life-cycle 设置的。
const { __CACHE_INSTANCE_BY_QIAN_KUN_FOR_VUE__, } = window;
let instance = null;
const router = new VueRouter({
mode: 'history',
base: routerBase,
routes: routes,
})
router.onError(err => {
console.log(err, 35);
window.history.pushState(null, null, '/404')
});
/*
set subapplication's routes
will need when add tag
*/
LocalStorage.setSubapplicationRoutes(routes.map(i => {
const prefix = i.path.slice(0, routerBase.length + 1);
let path = i.path;
if (prefix !== `${routerBase}/`) {
path = `${routerBase}${path}`;
}
return {
...i,
path,
}
}));
Vue.prototype.$parentStore = parentStore;
if (__POWERED_BY_QIANKUN__ && __CACHE_INSTANCE_BY_QIAN_KUN_FOR_VUE__) {
const cachedInstance = __CACHE_INSTANCE_BY_QIAN_KUN_FOR_VUE__;
// 从最初的Vue实例上获得_vnode
const cachedNode = cachedInstance._vnode;
// 让当前路由在最初的Vue实例上可用
router.apps.push(...cachedInstance?.catchRoute?.apps);
instance = new Vue({
router,
store,
i18n,
render: () => cachedNode
});
// 缓存最初的Vue实例
instance.cachedInstance = cachedInstance;
router.onReady(() => {
const { path } = router.currentRoute;
const { path: oldPath } = cachedInstance.$router.currentRoute;
// 当前路由和上一次卸载时不一致,则切换至新路由
if (path !== oldPath) {
cachedInstance.$router.push(path);
}
});
instance.$mount(container ? container.querySelector(subappKey) : subappKey);
} else {
instance = new Vue({
router,
store,
i18n,
render: h => h(mountPoint)
}).$mount(container ? container.querySelector(subappKey) : subappKey)
}
return { originInstance: instance, originRouter: router };
};
然后再就是 life-cycle.js 里面的 unmount
的全部代码。如果想要实现按需 keep-alive
只要在卸载之前 instance.$destroy()
将 window.**CACHE_INSTANCE_BY_QIAN_KUN_FOR_VUE**
赋值为 false
而不是 cachedInstance
。
代码也是来自 qiankun 官网的 issues。
那做完这些我们再来演示一下应用间的 keep-alive:
最后来演示一下我们本地开发的项目:
Demo 地址
附上 Demo 地址,感兴趣的小伙伴们可自行查阅:
功能 | 简称 | 应用 |
---|---|---|
main | 主应用 | demo-web-main |
login | 登录应用 | demo-web-login |
system | 系统应用 | demo-web-system |
order | 商品应用 | demo-web-order |
server | 后台获取菜单(eggjs) | demo-server |
参考文章:
以上便是本次分享的全部内容,希望对你有所帮助 ^_^
喜欢的话别忘了动动手指,点赞、收藏、关注三连一波带走。
关于我们
我们是万拓科创前端团队,左手组件库,右手工具库,各种技术野蛮生长。
一个人跑得快,不如一群人跑得远。欢迎加入我们的小分队,牛年牛气轰轰往前冲。