前言
在同时包含门户(运营方)和后管(审批方)的微前端项目中进行需求迭代时,遇到了这么一个场景:
某业务模块同时存在于门户和后管项目中,页面几乎一模一样,代码却需要维护两套。
为了解决这个问题,我尝试将包含页面和请求的具体业务模块抽到门户(apps/portal)和后管(apps/backstage)的外面,package/shared-business,在此记录具体实现。
一、改造核心目标
shared-business 包的核心定位是抽象并复用跨应用的通用业务模块,通过改造实现以下目标:
- 消除 backstage、portal 等多应用间的代码冗余;
- 统一各应用的业务逻辑与用户体验;
- 降低后续开发与维护成本,提升整体工程化效率。
二、关键改造策略与实现细节
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 架构下微前端业务复用的经典实践,通过三大核心策略实现了通用业务模块的解耦与复用:
- 模块共享:通过路径别名与路由集成,直接复用完整业务组件,消除代码冗余;
- 依赖注入:基于 Vue Provide/Inject 解决多应用环境差异,让共享模块适配不同宿主;
- 视图区分:通过路由标识动态切换逻辑与 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
出发,一步步拆解 ../../../../
的导航逻辑:
- 初始位置:
/apps/portal/src/stores/index.ts
- 第 1 个
../
:从stores
目录上移到src
目录 →/apps/portal/src/
- 第 2 个
../
:从src
目录上移到portal
目录 →/apps/portal/
- 第 3 个
../
:从portal
目录上移到apps
目录 →/apps/
- 第 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/
)出发,计算到目标模块的相对路径:
- 初始位置:
/apps/portal/
(Vite 根目录) - 第 1 个
../
:从portal
目录上移到apps
目录 →/apps/
- 第 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 为适配不同目录深度文件、保证模块引用一致性而设计的机制。