Vben Admin管理系统集成qiankun微服务

前言

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/demo1http://localhost:5667/app/basic/demo2 子应用页面单独访问页面如下:

在主应用访问子应用页面效果:

遗留问题

  1. 主应用和子应用保持主题风格一致,主应用调整子应用同步调整。目前首次打开子应用会影响主应用样式变成黑色主题,还在研究当中。
  2. 子应用登录鉴权使用主应用鉴权,如果子应用鉴权过期要跳转到登录页面,

后续优化点

  1. 管理系统都是从后台加载路由菜单,本次仅是一个demo,后续优化调整成动态从后台获取菜单和实现子应用的动态更新,实现子应用的热插拔要和后端程序进行联动。
相关推荐
over6977 小时前
掌控 JavaScript 的 this:从迷失到精准控制
前端·javascript·面试
天才熊猫君7 小时前
基于 `component` 的弹窗组件统一管理方案
前端·javascript
南囝coding7 小时前
《独立开发者精选工具》第 024 期
前端·后端
苏打水com7 小时前
第二十篇:Day58-60 前端性能优化进阶——从“能用”到“好用”(对标职场“体验优化”需求)
前端·css·vue·html·js
沛沛老爹7 小时前
Web开发者快速上手AI Agent:基于提示工程的旅游攻略系统实战
前端·人工智能·ai·agent·react·旅游攻略
qq_172805597 小时前
Modbus Server数据采集Web之Server端模拟功能
前端·后端·golang·modbus
LYFlied7 小时前
【每日算法】LeetCode 739. 每日温度:从暴力遍历到单调栈的优雅解决
前端·算法·leetcode·面试·职场和发展
烛阴8 小时前
深入 C# 字符串世界:基础语法、常用方法与高阶实战
前端·c#
这是个栗子8 小时前
【前端知识点总结】关于基地址baseURL的介绍
前端·axios·baseurl