微前端跨应用中通用前端业务模块的实现

前言

在同时包含门户(运营方)和后管(审批方)的微前端项目中进行需求迭代时,遇到了这么一个场景:

某业务模块同时存在于门户和后管项目中,页面几乎一模一样,代码却需要维护两套。

为了解决这个问题,我尝试将包含页面和请求的具体业务模块抽到门户(apps/portal)和后管(apps/backstage)的外面,package/shared-business,在此记录具体实现。

一、改造核心目标

shared-business 包的核心定位是抽象并复用跨应用的通用业务模块,通过改造实现以下目标:

  1. 消除 backstage、portal 等多应用间的代码冗余;
  2. 统一各应用的业务逻辑与用户体验;
  3. 降低后续开发与维护成本,提升整体工程化效率。

二、关键改造策略与实现细节

1. 业务模块共享(Module Sharing)

方案概述

将通用性强的完整业务模块(含页面组件、API 定义、常量、工具函数)迁移至 shared-business 包,宿主应用通过路径别名路由集成直接复用模块。

实现细节

(1)宿主应用配置路径别名

在宿主应用(如 backstage)的 vite.config.ts 中,配置 shared-business 包的路径别名,便于直接引用:

typescript 复制代码
// vite.config.ts
import path from 'path';
import { defineConfig } from 'vite';

export default defineConfig({
  resolve: {
    alias: {
      // 映射 shared-business 包的 src 目录
      '@shared-business': path.resolve(__dirname, '../../packages/shared-business/src')
    }
  }
});
(2)宿主应用集成共享路由

在宿主应用的路由配置文件(router/routes.ts)中,通过异步导入直接使用 shared-business 中的页面组件,无缝集成到自身路由体系:

typescript 复制代码
// backstage/src/router/routes.ts
export default [
  {
    path: '/data_resource/admin',
    name: 'DataResourceAdmin',
    component: () => import('@/layouts/MainLayout.vue'), // 宿主应用布局
    children: [
      {
        path: '',
        name: 'dataResourceDefault',
        // 复用 shared-business 的列表页组件
        component: () => import('@shared-business/modules/dataResourceManage/pages/list.vue')
      },
      {
        path: 'detail/:id',
        name: 'backstageDataResourceDetail',
        // 复用 shared-business 的详情页组件
        component: () => import('@shared-business/modules/dataResourceManage/pages/detail.vue'),
        meta: { menuPath: '/data_resource/admin' } // 宿主应用的路由元信息
      }
    ]
  }
];
(3) 动态路由导入模块方式兼容

由于我的项目,需要根据登录用户的权限动态"解锁"页面路由。所以使用动态路由的方式呈现页面。需要实现,优先从应用内部查找路由模块,再从共享包查找路由模块。

修改前

typescript 复制代码
const modulesFile = import.meta.glob([
    '@/modules/**/*.vue',
    '!**/{login,mobile,poc,portal,workbench}'
  ]),
  processSystemRoute = (tree: PermissionTreeType, module?: string | symbol) => {
    if (!tree) return [];
    const result: RouteRecordRaw[] = [];
    tree.forEach((item) => {
      if (!item.metaData) return;
      const { component = '', name } = item.metaData,
        newItem = {
          ...item.metaData,
          children: processSystemRoute(item.childrens, module || name)
        };
      newItem.component = modulesFile[`/src/modules/${String(module || name)}/${component}`];
      result.push(newItem);
    });
    return result;
  };

修改后

typescript 复制代码
// 同时扫描应用内部的 modules 和共享包的 modules
const modulesFile = import.meta.glob([
  '/src/modules/**/*.vue',
  '../../../../packages/shared-business/src/modules/**/*.vue',
  '!**/{login,mobile,poc,portal,workbench}'
]);

// console.log('Found modules:', Object.keys(modulesFile));
const processSystemRoute = (tree: PermissionTreeType, module?: string | symbol) => {
  if (!tree) return [];
  const result: RouteRecordRaw[] = [];
  for (const item of tree) {
    if (!item.metaData) return;
    const { component = '', name } = item.metaData,
      newItem = {
        ...item.metaData,
        children: item.childrens ? processSystemRoute(item.childrens, module || name) : []
      };
    if (component) {
      // 优先从应用内部查找,再从共享包查找
      const internalPath = `/src/modules/${String(module || name)}/${component}`;
      const sharedPath = `../../packages/shared-business/src/modules/${component}`;

      newItem.component = modulesFile[internalPath] || modulesFile[sharedPath];
    }

    result.push(newItem as any);
  }
  return result;
};
(4) 添加 App.vue
typescript 复制代码
<template>
  <ConfigProvider :update-at-scroll="true">
    <router-view />
  </ConfigProvider>
</template>

<script setup lang="ts">
import { ConfigProvider } from '@arco-design/web-vue';
import { provide } from 'vue';
import { request } from './src/utils/request';

provide('request', request);
</script>

2. 依赖注入(Dependency Injection):解决环境差异

方案概述

这是改造的核心环节 。由于不同宿主应用(如 backstage、portal)的 axios 实例可能存在差异(基础 URL、拦截器、认证头不同),共享模块不能硬编码依赖。通过 Vue 3 Provide/Inject API,让宿主应用提供全局 request 实例,共享模块注入使用,实现环境适配。

实现细节

(1)改造 shared-business 中的 API 定义

将 API 函数从"直接执行请求"改为"接收 request 实例并返回请求函数"的高阶函数,剥离固定依赖:

typescript 复制代码
// 改造前(硬编码依赖):backstage/src/api/dataResource.ts
// import request from '@/utils/request'; // 依赖宿主应用的 request
// export const queryOperationRecord = (params) => request.get('/xxx', { params });

// 改造后(动态依赖):shared-business/src/api/dataResource.ts
import type { HttpService } from 'common-utils'; // 引入通用 Http 类型定义

/**
 * 高阶函数:接收宿主应用的 request 实例,返回真正的请求函数
 * @param request - 宿主应用提供的 axios 实例
 * @returns 接收参数并发起请求的函数
 */
export const queryOperationRecord = (request: HttpService['wrappedAxiosInstance']) => {
  return (params: { dataId: string; page?: number; size?: number }) => 
    request.get('/harbor/api/v1/dataResource/operation/record', { params });
};
(2)宿主应用提供 request 实例

在宿主应用的顶层组件(如 App.vue)中,通过 provide 暴露全局 request 实例:

vue 复制代码
<!-- backstage/src/App.vue -->
<script setup lang="ts">
import { provide } from 'vue';
import request from '@/utils/request'; // 宿主应用自身的 axios 实例

// 提供 request 实例,供共享模块注入
provide('request', request);
</script>
(3)共享组件注入并使用 request

在 shared-business 的组件中,通过 inject 获取宿主提供的 request 实例,初始化 API 并调用:

vue 复制代码
<!-- shared-business/src/modules/dataResourceManage/pages/operations.vue -->
<script setup lang="ts">
import { inject } from 'vue';
import { useTableQuery } from 'common-utils'; // 通用表格查询 Hook
import { queryOperationRecord } from '../../../api/dataResource'; // 改造后的高阶 API
import type { OperationRecordParams } from '../../../types/dataResource'; // 类型定义

// 1. 注入宿主应用提供的 request 实例
const request = inject<HttpService['wrappedAxiosInstance']>('request');
if (!request) throw new Error('宿主应用未提供 request 实例');

// 2. 初始化 API 请求函数(传入 request 实例)
const getOperationRecords = queryOperationRecord(request);

// 3. 调用 API(结合通用 Hook 处理表格数据)
const { tableData, loading, pagination } = useTableQuery<OperationRecordParams>(
  getOperationRecords,
  { dataId: props.id, page: 1, size: 10 } // 初始参数
);
</script>

3. 视图区分(View Differentiation):适配多场景

方案概述

共享模块需根据宿主应用上下文(如"管理员视图"vs"普通用户视图")展示不同 UI 或调用不同 API。通过判断路由路径标识,动态切换业务逻辑。

实现细节

(1)判断视图类型

通过路由路径中的特征标识(如 /admin),创建计算属性 isAdminView 区分视图类型:

vue 复制代码
<!-- shared-business/src/modules/dataResourceManage/pages/list.vue -->
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { getDataResourceList, getAmsDataResourceList } from '../../../api/dataResource';

const route = useRoute();

// 计算属性:根据路由路径判断是否为管理员视图(路径含 /admin 标识)
const isAdminView = computed(() => {
  return route.path.includes('/data_resource/admin');
});
</script>
(2)动态切换 API 与 UI

基于 isAdminView 动态选择 API 或渲染差异化 UI(如管理员专属按钮):

vue 复制代码
<!-- shared-business/src/modules/dataResourceManage/pages/list.vue -->
<script setup lang="ts">
// 1. 动态选择 API(管理员用 AMS 接口,普通用户用基础接口)
const fetchResourceList = isAdminView.value 
  ? getAmsDataResourceList(request) 
  : getDataResourceList(request);

// 2. 调用 API 获取数据(逻辑复用,仅数据源不同)
const { tableData, loading } = useTableQuery(fetchResourceList, { page: 1, size: 10 });
</script>

<template>
  <div class="data-resource-list">
    <!-- 表格内容(通用) -->
    <el-table v-loading="loading" :data="tableData">...</el-table>

    <!-- 差异化 UI:仅管理员显示"批量审批"按钮 -->
    <el-button 
      v-if="isAdminView" 
      type="primary" 
      @click="handleBatchApprove"
    >
      批量审批
    </el-button>
  </div>
</template>

三、改造总结

shared-business 包的改造是Monorepo 架构下微前端业务复用的经典实践,通过三大核心策略实现了通用业务模块的解耦与复用:

  1. 模块共享:通过路径别名与路由集成,直接复用完整业务组件,消除代码冗余;
  2. 依赖注入:基于 Vue Provide/Inject 解决多应用环境差异,让共享模块适配不同宿主;
  3. 视图区分:通过路由标识动态切换逻辑与 UI,满足多场景业务需求。

改造后,shared-business 成为独立、可维护、高复用的业务核心包,显著提升了 dvp 系列应用的开发效率与一致性,为后续业务扩展奠定了基础。

四、Q&A

1、 解析 Vite 中 import.meta.glob 的路径差异:输入路径 vs 输出 Key

详细描述:修改模块加载逻辑时,发现 import.meta.glob 内部的 ../../../../packages/shared-business/src/modules/**/*.vue 是相对于当前的位置,而打印出来的 Found modules 的key,shared-business 下的模块页面,都只有 两层 即:../../packages/shared-business/src/modules/...

import.meta.glob ../../../../packages/... 输入路径../../packages/... 输出 Key 差异,本质是 Vite 中 import.meta.glob 对「输入路径」和「输出 Key」采用了 不同的相对基准点。以下将逐步拆解这一核心工作机制。

1.1 先明确前提:项目目录结构

关键目录位置如下(后续路径计算均基于此):

  • 当前源文件/apps/portal/src/stores/index.ts(即调用 import.meta.glob 的文件)
  • 目标模块/packages/shared-business/src/modules/**/*.vue(需匹配的共享组件)
  • Vite 项目根目录(root) :通常是 vite.config.ts 所在目录,此处为 /apps/portal/(这是 Vite 内部计算路径的核心基准)

1.2 输入路径:为什么是 ../../../../packages/...?

import.meta.glob输入路径相对于当前源文件(index.ts 解析的,作用是"告诉 Vite 从哪里开始查找目标文件"。

我们从 index.ts 出发,一步步拆解 ../../../../ 的导航逻辑:

  1. 初始位置:/apps/portal/src/stores/index.ts
  2. 第 1 个 ../:从 stores 目录上移到 src 目录 → /apps/portal/src/
  3. 第 2 个 ../:从 src 目录上移到 portal 目录 → /apps/portal/
  4. 第 3 个 ../:从 portal 目录上移到 apps 目录 → /apps/
  5. 第 4 个 ../:从 apps 目录上移到 项目根目录/

此时已到达项目根目录,再拼接 packages/shared-business/src/modules/**/*.vue,就能精准定位到所有共享组件。

简言之:输入路径是"以当前文件为起点的导航指令",必须通过足够的 ../ 上移到能覆盖目标文件的目录层级。

1.3 输出 Key:为什么是 ../../packages/...?

import.meta.glob 执行后返回的对象中,Key(即打印的"Found modules"路径)相对于 Vite 项目根目录(/apps/portal/ 生成的,作用是"作为模块的唯一标识符,供 Vite 内部映射和引用"。

我们从 Vite 根目录(/apps/portal/)出发,计算到目标模块的相对路径:

  1. 初始位置:/apps/portal/(Vite 根目录)
  2. 第 1 个 ../:从 portal 目录上移到 apps 目录 → /apps/
  3. 第 2 个 ../:从 apps 目录上移到 项目根目录/

此时拼接 packages/shared-business/src/modules/**/*.vue,得到的路径就是 ../../packages/shared-business/src/modules/...,这正是您在 console.log 中看到的结果。

简言之:输出 Key 是"以 Vite 根目录为基准的登记地址",确保 Vite 在整个项目上下文里能统一识别模块位置。

1.4 核心差异总结表

路径类型 相对基准点 示例路径 核心作用
import.meta.glob 输入 当前源文件(/apps/portal/src/stores/index.ts ../../../../packages/... 指引 Vite 查找目标文件的"导航路径"
import.meta.glob 输出 Key Vite 项目根目录(/apps/portal/ ../../packages/... 标识模块的"唯一登记地址"

1.5 一句话概括本质

import.meta.glob输入路径是给 Vite"带路"的 (从当前文件出发找目标),输出 Key 是给 Vite"登记"的(从项目根目录出发标位置)------ 两者的参考点不同,因此路径层级必然存在差异,这是 Vite 为适配不同目录深度文件、保证模块引用一致性而设计的机制。

相关推荐
我是天龙_绍2 小时前
什么时候用ref,什么时候用reactive?
前端
AndyLaw2 小时前
<a>标签下载文件 download 属性无效?原来问题出在这里
前端·javascript
我是日安2 小时前
从零到一打造 Vue3 响应式系统 Day 19 - Reactive:reactive 的基础实现
前端·vue.js
TZOF2 小时前
TypeScript的新类型(二):unknown
前端·后端·typescript
caicai_lf_niuniu2 小时前
VUE3+element plus 实现表格行合并
前端
李宏伟~2 小时前
uniapp生成二维码组件全能组件复制即用
前端·uni-app
TZOF2 小时前
TypeScript的新类型(三):never
前端·后端·typescript
余防2 小时前
文件上传漏洞(二)iis6.0 CGI漏洞
前端·安全·web安全·网络安全
毕业设计制作和分享2 小时前
springboot523基于Spring Boot的大学校园生活信息平台的设计与实现
前端·vue.js·spring boot·后端·生活