前言
Vben Admin是一个优秀的企业级管理系统框架,我们正在进行业务适配,计划把各业务功能通过前端微服务方式集成到主系统里,网上没有找到可参考的案例,所以自行尝试实现初步demo,中间遇到较多的问题记录下集成过程,以备后续参考优化。
环境准备
为了快速验证我们从官网地址拉取一个全新的项目进行操作,减少中间其他影响的因素。
获取源码并运行主应用web-antd
markdown
# clone 代码
git clone https://github.com/vbenjs/vue-vben-admin.git
# 安装依赖
pnpm i
# 测试启动一个应用
pnpm dev:antd
出现如下,表明启动成功
**VITE** v7.2.2 ready in **3291** ms
➜ **Local**: http://localhost:**5666**/ 17:46:35
➜ **Network**: http://192.168.70.22:**5666**/ 17:46:35
➜ **Vben Admin Docs**: https://doc.vben.pro 17:46:35
➜ **Nitro Mock Server**: http://localhost:5320/api 17:46:35
➜ press **h + enter** to show help
✔ **Nitro Mock Server started.**
现在已经能正常运行程序。
启动子应用
框架在apps目录提供几个不同组件项目,我们这次选择web-antd作为主应用,将web-antd复制一个文件夹web-antd-child作为子应用的方式进行集成,具体操作如下:
bash
cd apps
# 拷贝生成子应用文件夹 web-antd-child
cp -r web-antd web-antd-child
cd web-antd-child
# 安装子应用依赖
pnpm i
# 启动
pnpm dev
主框架集成
添加qiankun文件
shell
# 转到主框架目录
cd apps/web_antd
# 安装qiankun
pnpm i qiankun
在web-antd目录的src目录添加qiankun目录,并新建文件index.ts 和config.ts
shell
cd src
##添加qiankun文件夹
mkdir qiankun&&cd qiankun
## 导入文件
touch index.ts config.ts
config.ts文件内容如下,大概意思就是加载子应用将文件内容注入到#sub-container容器中,容器定义后面会加,路由拦截规则是/app/basic,props定义的是主应用数据传给子应用,保证主应用登录后子应用可以共享主应用数据。
ruby
/** 本地应用测试微服务架构 */
export default {
subApps: [
{
name: 'basic', // 子应用名称,跟package.json一致
// entry: import.meta.env.VITE_API_BASE_URL, // 子应用入口,本地环境下指定端口
entry: 'http://localhost:5667', // 子应用入口,本地前端环境下指定端口'http://localhost:5174',发布可以调整为主系统:/app/workflow-app/= /app/插件名称/
container: '#sub-container', // 挂载子应用的dom
activeRule: '/app/basic', // 路由匹配规则
props: {
userInfo: [],
token: '',
}, // 主应用与子应用通信传值
sandbox: {
strictStyleIsolation: true, // 启用严格样式隔离
},
},
],
};
文件index.ts 文件内容如下,注册微服务生命周期
typescript
// 参考项目:https://github.com/wstee/qiankun-web
import { useAccessStore, useUserStore } from '@vben/stores';
import { registerMicroApps } from 'qiankun';
import config from './config';
let { subApps } = config;
export async function registerApps() {
try {
// 如果子应用是不定的,可以这里定义接口从后台获取赋值给subApps,动态添加
registerMicroApps(subApps, {
beforeLoad: [
(app: any) => {
// eslint-disable-next-line no-console
console.log('[主应用] beforeLoad', app.name);
const useStore = useUserStore();
const accessStore = useAccessStore();
app.props.token = accessStore.accessToken;
app.props.userInfo = useStore.userInfo;
// app.props.publicKey = import.meta.env.VITE_PUBLIC_KEY;
},
],
// 生命周期钩子
loader: (loading: any) => {
// 可以在这里处理加载状态
// eslint-disable-next-line no-console
console.log('子应用加载状态:', loading);
},
beforeMount: [
(app) => {
// eslint-disable-next-line no-console
console.log('[主应用] beforeMount', app.name);
const container = document.querySelector(app.container);
if (container) container.innerHTML = '';
},
],
afterUnmount: [
(app) => {
// eslint-disable-next-line no-console
console.log('count: %s', app);
},
],
});
} catch (error) {
// eslint-disable-next-line no-console
console.log('count: %s', error);
}
}
修改src/bootstrap.ts 设置启动时加载qiankun配置,此处只是加载,但没有向其他教程一样启动start({}),因为sub_container挂载点还没有加到应用中执行启动会报错,会找不到#sub-container元素。
ini
# src/bootstrap.ts 修改内容
#导入引用文件
import { registerApps } from '#/qiankun';
# 定义app前面注册registerApps
+ registerApps();
const app = createApp(App);
此时启动主应用 控制台会有一个警告,大概意思没有执行start({})启动微服务,暂时先不管。 
注册子应用路由
在scr/views/_core/appContainer.vue 位置添加一个文件,作为子应用路由着陆页,内容如下:
xml
<script>
// fix: 这个页面遄作为app子页面着陆页,不增加么会报错找不到加载页面
export default {};
</script>
<template>
<div style="display: none"></div>
</template>
本次要支持如下两个子应用路由访问,所以添加2个静态路由地址,页面都指向上面的空路由着陆页。
- /app/basic/demo1
- /app/basic/demo2 调整内容:修改src/routes/modules/demos.ts添加上述两个测试路由,在第26行添加内容如下:
css
{
meta: {
icon: 'ic:baseline-view-in-ar',
keepAlive: true,
order: 1000,
title: '子应用demo1',
},
name: 'subdemo1',
path: '/app/basic/demo1',
component: () => import('#/views/_core/appContainer.vue'),
},
{
meta: {
icon: 'ic:baseline-view-in-ar',
keepAlive: true,
order: 1000,
title: '子应用demo2',
},
name: 'subdemo2',
path: '/app/basic/demo2',
component: () => import('#/views/_core/appContainer.vue'),
},
此时访问http://localhost:5666页面 应该是可以看到2个路由内容,不过页面内容区域是空白. 
package包添加挂载容器
package作为单独核心包,里面包括主框架文件content.vue,需要增加主包安装qiankun。
bash
cd packages/effects/layouts
#安装qiankun
pnpm i qiankun
注意:修改主包引用有个不好的地方,后面如果更新vben主仓库的时候,有变更都要手动处理git冲突。
打开页面packages/effects/layouts/src/basic/content/content.vue文件,在相应位置添加如下代码
javascript
<script lang="ts" setup>
...
#导入
import { start } from 'qiankun';
...
# 加载start
function startApps() {
if (!window.qiankunStarted) {
window.qiankunStarted = true;
// registerApps()
start({
prefetch: false, // 开启预加载会导致重复加载应用
fetch(url, ...args) {
return window.fetch(url, ...args).catch(() => {
console.error('Fetch failed:', url);
// 直接返回一个失败的 Promise,阻止重试
throw Error;
});
},
sandbox: {
experimentalStyleIsolation: true, // 样式隔离
},
});
}
}
onMounted(() => {
startApps();
});
</script>
<!--- qiankun by go-caipu 注入容器 -->
<div id="sub-container" class="sub-container"></div>
完整文件内容如下:
typescript
<script lang="ts" setup>
import type { VNode } from 'vue';
import type {
RouteLocationNormalizedLoaded,
RouteLocationNormalizedLoadedGeneric,
} from 'vue-router';
import { computed, onMounted } from 'vue';
import { RouterView } from 'vue-router';
import { preferences, usePreferences } from '@vben/preferences';
import { getTabKey, storeToRefs, useTabbarStore } from '@vben/stores';
import { start } from 'qiankun';
import { IFrameRouterView } from '../../iframe';
defineOptions({ name: 'LayoutContent' });
const tabbarStore = useTabbarStore();
const { keepAlive } = usePreferences();
const { getCachedTabs, getExcludeCachedTabs, renderRouteView } =
storeToRefs(tabbarStore);
/**
* 是否使用动画
*/
const getEnabledTransition = computed(() => {
const { transition } = preferences;
const transitionName = transition.name;
return transitionName && transition.enable;
});
// 页面切换动画
function getTransitionName(_route: RouteLocationNormalizedLoaded) {
// 如果偏好设置未设置,则不使用动画
const { tabbar, transition } = preferences;
const transitionName = transition.name;
if (!transitionName || !transition.enable) {
return;
}
// 标签页未启用或者未开启缓存,则使用全局配置动画
if (!tabbar.enable || !keepAlive) {
return transitionName;
}
// 如果页面已经加载过,则不使用动画
// if (route.meta.loaded) {
// return;
// }
// 已经打开且已经加载过的页面不使用动画
// const inTabs = getCachedTabs.value.includes(route.name as string);
// return inTabs && route.meta.loaded ? undefined : transitionName;
return transitionName;
}
/**
* 转换组件,自动添加 name
* @param component
*/
function transformComponent(
component: VNode,
route: RouteLocationNormalizedLoadedGeneric,
) {
// 组件视图未找到,如果有设置后备视图,则返回后备视图,如果没有,则抛出错误
if (!component) {
console.error(
'Component view not found,please check the route configuration',
);
return undefined;
}
const routeName = route.name as string;
// 如果组件没有 name,则直接返回
if (!routeName) {
return component;
}
const componentName = (component?.type as any)?.name;
// 已经设置过 name,则直接返回
if (componentName) {
return component;
}
// componentName 与 routeName 一致,则直接返回
if (componentName === routeName) {
return component;
}
// 设置 name
component.type ||= {};
(component.type as any).name = routeName;
return component;
}
function startApps() {
if (!window.qiankunStarted) {
window.qiankunStarted = true;
// registerApps()
start({
prefetch: false, // 开启预加载会导致重复加载应用
fetch(url, ...args) {
return window.fetch(url, ...args).catch(() => {
console.error('Fetch failed:', url);
// 直接返回一个失败的 Promise,阻止重试
throw Error;
});
},
sandbox: {
experimentalStyleIsolation: true, // 样式隔离
},
});
}
}
onMounted(() => {
startApps();
});
</script>
<template>
<div class="relative h-full">
<IFrameRouterView />
<RouterView v-slot="{ Component, route }">
<Transition
v-if="getEnabledTransition"
:name="getTransitionName(route)"
appear
mode="out-in"
>
<KeepAlive
v-if="keepAlive"
:exclude="getExcludeCachedTabs"
:include="getCachedTabs"
>
<component
:is="transformComponent(Component, route)"
v-if="renderRouteView"
v-show="!route.meta.iframeSrc"
:key="getTabKey(route)"
/>
</KeepAlive>
<component
:is="Component"
v-else-if="renderRouteView"
:key="getTabKey(route)"
/>
</Transition>
<template v-else>
<KeepAlive
v-if="keepAlive"
:exclude="getExcludeCachedTabs"
:include="getCachedTabs"
>
<component
:is="transformComponent(Component, route)"
v-if="renderRouteView"
v-show="!route.meta.iframeSrc"
:key="getTabKey(route)"
/>
</KeepAlive>
<component
:is="Component"
v-else-if="renderRouteView"
:key="getTabKey(route)"
/>
</template>
</RouterView>
<!--- qiankun by go-caipu -->
<div id="sub-container" class="sub-container"></div>
</div>
</template>
到此主应用配置完成。
子应用web-antd-child集成
因为子应用调整内容较多,有的文件直接给出整个修改的文件
bash
# 转到子应用目录
cd ./apps/web-antd-child/
# 安装 vite 支持qiankun
pnpm i vite-plugin-qiankun
修改.env.development 环境变量
ini
# 修改VITE_BASE 让程序以/app/basic子包方式运行
VITE_BASE=/app/basic
# 是否注入全局loading 改置不要loading效果,因为加载子应用的时候直接是关闭不了,得要调整程序此次先关闭
VITE_INJECT_APP_LOADING=false
修改index.html
shell
# 将原文件中 <div id="app"></div> 调整为 <div id="app"></div>
修改.vite.config.ts 增加qiankun加载
javascript
import { defineConfig } from '@vben/vite-config';
import qiankun from 'vite-plugin-qiankun';
export default defineConfig(async () => {
return {
application: {},
vite: {
server: {
proxy: {
'/api': {
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
// mock代理目标地址
target: 'http://localhost:5320/api',
ws: true,
},
},
},
plugins: [
qiankun('/app/basic', {
useDevMode: true,
}),
],
},
};
});
修改 bootstrap.ts文件
javascript
import { createApp, watchEffect } from 'vue';
import { registerAccessDirective } from '@vben/access';
import { registerLoadingDirective } from '@vben/common-ui/es/loading';
import { preferences } from '@vben/preferences';
import { initStores } from '@vben/stores';
import '@vben/styles';
import '@vben/styles/antd';
import { useTitle } from '@vueuse/core';
import { $t, setupI18n } from '#/locales';
import { initComponentAdapter } from './adapter/component';
import { initSetupVbenForm } from './adapter/form';
import App from './app.vue';
import { router } from './router';
async function bootstrap(namespace: string, container: any = null) {
// 初始化组件适配器
await initComponentAdapter();
// 初始化表单组件
await initSetupVbenForm();
// // 设置弹窗的默认配置
// setDefaultModalProps({
// fullscreenButton: false,
// });
// // 设置抽屉的默认配置
// setDefaultDrawerProps({
// zIndex: 1020,
// });
const app = createApp(App);
// 注册v-loading指令
registerLoadingDirective(app, {
loading: 'loading', // 在这里可以自定义指令名称,也可以明确提供false表示不注册这个指令
spinning: 'spinning',
});
// 国际化 i18n 配置
await setupI18n(app);
// 配置 pinia-tore
await initStores(app, { namespace });
// 安装权限指令
registerAccessDirective(app);
// 初始化 tippy
const { initTippy } = await import('@vben/common-ui/es/tippy');
initTippy(app);
// 配置路由及路由守卫
app.use(router);
// 配置Motion插件
const { MotionPlugin } = await import('@vben/plugins/motion');
app.use(MotionPlugin);
// 动态更新标题
watchEffect(() => {
if (preferences.app.dynamicTitle) {
const routeTitle = router.currentRoute.value.meta?.title;
const pageTitle =
(routeTitle ? `${$t(routeTitle)} - ` : '') + preferences.app.name;
useTitle(pageTitle);
}
});
app.mount(container ? container.querySelector('#sub_app') : '#sub_app');
return app;
}
export { bootstrap };
修改 main.ts
javascript
import { initPreferences } from '@vben/preferences';
import { useAccessStore, useUserStore } from '@vben/stores';
import '@vben/styles';
import '@vben/styles/antd';
import { unmountGlobalLoading } from '@vben/utils';
import {
qiankunWindow,
renderWithQiankun,
} from 'vite-plugin-qiankun/dist/helper';
import { bootstrap } from './bootstrap';
import { overridesPreferences } from './preferences';
let app: any = null;
/**
* 应用初始化完成之后再进行页面加载渲染
*/
async function initApplication(container: any = null) {
// name用于指定项目唯一标识
// 用于区分不同项目的偏好设置以及存储数据的key前缀以及其他一些需要隔离的数据
const env = import.meta.env.PROD ? 'prod' : 'dev';
const appVersion = import.meta.env.VITE_APP_VERSION;
const namespace = `${import.meta.env.VITE_APP_NAMESPACE}-${appVersion}-${env}`;
// app偏好设置初始化
await initPreferences({
namespace,
overrides: overridesPreferences,
});
// 启动应用并挂载
// vue应用主要逻辑及视图
app = await bootstrap(namespace, container);
// 移除并销毁loading
unmountGlobalLoading();
}
const initQianKun = async () => {
renderWithQiankun({
async mount(props: any) {
const { container, token, userInfo } = props;
await initApplication(container);
const useStore = useUserStore();
const accessStore = useAccessStore();
console.log('[子应用] mounting', props);
console.log('[子应用] token:', token);
console.log('[子应用] userInfo:', userInfo);
useStore.setUserInfo(userInfo);
accessStore.setAccessToken(token);
// 移除并销毁loading
unmountGlobalLoading();
},
bootstrap() {
// eslint-disable-next-line no-console
console.log('[子应用] bootstraped');
},
update(props: any) {
// eslint-disable-next-line no-console
console.log('[子应用] update');
const { container } = props;
initApplication(container);
},
unmount(props) {
// eslint-disable-next-line no-console
console.log('[子应用] unmount', props);
app?.unmount();
app = null;
},
});
};
// 判断是否为乾坤环境,否则会报错iqiankun]: Target container with #subAppContainerVue3 not existed while subAppVue3 mounting!
qiankunWindow.__POWERED_BY_QIANKUN__
? await initQianKun()
: await initApplication();
添加测试路由
修改/web-basic/src/router/routes/index.ts 的内容,增加2个测试路由。
yaml
#将const externalRoutes: RouteRecordRaw[] = [] 调整为下面内容
const externalRoutes: RouteRecordRaw[] = [
{
meta: {
icon: 'ic:baseline-view-in-ar',
keepAlive: true,
order: 1000,
title: '演示功能',
},
name: 'Test',
path: '/demo',
component: () => import('#/views/demos/antd/index.vue'),
},
{
meta: {
icon: 'ic:baseline-view-in-ar',
keepAlive: true,
order: 1000,
title: '演示功能',
},
name: 'demo2',
path: '/demo2',
component: () => import('#/views/dashboard/workspace/index.vue'),
},
];
重新运行子项目pnpm dev , 此时应该能打开http://localhost:5667/app/basic/demo1 和 http://localhost:5667/app/basic/demo2 子应用页面单独访问页面如下: 
在主应用访问子应用页面效果: 
遗留问题
- 主应用和子应用保持主题风格一致,主应用调整子应用同步调整。目前首次打开子应用会影响主应用样式变成黑色主题,还在研究当中。
- 子应用登录鉴权使用主应用鉴权,如果子应用鉴权过期要跳转到登录页面,
后续优化点
- 管理系统都是从后台加载路由菜单,本次仅是一个demo,后续优化调整成动态从后台获取菜单和实现子应用的动态更新,实现子应用的热插拔要和后端程序进行联动。