闽公网安备 35020302035485号
在现代大型前端项目开发中,多团队协作时往往面临代码隔离与集成的挑战。为了解决这一问题,我们需要一种能够让各个微前端模块既能独立开发部署,又能作为完整系统一部分的解决方案。基于 Vite 的模块联邦插件@originjs/vite-plugin-federation提供了一种去中心化的微前端架构实现方式,实现了组件、路由的跨应用共享和动态加载。本文将结合实际项目经验,详细介绍如何利用模块联邦技术在 「vue3 生态」中构建去中心化的微前端架构。
{
"vite": "^6.0.2",
"@originjs/vite-plugin-federation": "1.4.1",
}
二、架构设计federation({
name: "pageAModule", // 微前端模块名称
filename: "pageAEntry.js", // 入口文件名
exposes: { // 暴露的模块,此处为路由
'./routes': './src/routes/index.ts'
},
remote: {
menuModule: ModuleUrl, // 引入的远程模块
}
shared: ['vue', 'vue-router', 'pinia'] // 共享依赖
})
// 使用远程模块
import 'menuModule/xxx'
2.2 去中心化微前端架构设计传统微前端架构通常采用 "基座模式",即一个主应用作为基座控制其他子应用的加载和渲染。而去中心化微前端架构没有明确的主应用,各个子应用可以独立部署、独立运行,同时又能无缝协作。
提供公共组件和工具函数
federation({
name: config.projectPrefix + 'Module', // 微前端模块名称
filename: config.projectPrefix + 'Entry.js', // 入口文件名
exposes: {
'./routes': './src/routes/index.ts', // 暴露路由配置(关键)
},
remotes: {
// 堆代码 duidaima.com
// 后台管理系统中需要单独引入菜单模块
menuModule: ModuleUrl,
},
shared: ['vue', 'vue-router', 'pinia', 'dayjs', 'axios', 'sass', 'element-plus'], // 共享依赖
})
3.1.1 关键配置参数解析.生成远程入口文件,处理模块的加载和解析
// 常规的模块联邦导入方法 import Component from 'remoteModule/Component'这种方式存在一个重要限制:「无法使用动态字符串拼接模块名」。例如,以下代码在模块联邦中不起作用:
// 这种写法在模块联邦中不支持
const moduleName = 'remoteModule'
import Component from `${moduleName}/Component` // 不支持!
这样就会导致一个问题就是 在去中心化微前端架构中,每个项目模块在开发时并不知道全局系统中到底有多少个联邦模块,也无法预先确定所有模块的名称和地址。为了支持新增模块的灵活扩展,需要一个动态的机制来发现和加载模块。通常,我们会通过一个配置文件(如 registry.json)来集中管理所有联邦模块的注册信息,允许新模块随时加入系统。然而,官方的模块联邦导入方式,它不支持动态拼接模块名称的字符串。// src/utils/federation.ts
export const getRemoteEntries = async(name ? :string, moduleName ? :string) : Promise < any[] > =>{
try {
// 从注册中心获取所有可用模块的信息
const response = await axios.get(`$ {
baseUrl
}
/federation/registry.json`)
// 定义过滤条件
const filterByName = (distDir: any) = >{
// 过滤掉与当前项目前缀相同的模块
if (distDir.name.toLowerCase().includes(config.projectPrefix.toLowerCase())) {
return false
}
// 如果提供了name参数,则按name过滤
return ! name || distDir.name.includes(name)
}
// 过滤模块
const filteredModules = response.data.filter(filterByName)
// 动态加载匹配的模块
const loadedComponents = []
for (const moduleInfo of filteredModules) {
try {
// 动态构建模块URL
const moduleUrl = `$ {
baseUrl
}
/${moduleInfo.path}/$ {
moduleInfo.name.replace('Module', 'Entry')
}.js`
// 使用模块联邦API动态加载模块
const remote = await import(
/* @vite-ignore */
moduleUrl) const moduleFactory = await remote.get('./' + moduleName) const module = await moduleFactory() loadedComponents.push(module)
} catch(error) {
console.error(`加载模块$ {
moduleInfo.name
}失败: `, error)
}
}
return loadedComponents
} catch(error) {
console.error('获取远程模块失败:', error) return []
}
}
这种动态模块发现机制的优势在于:// src/routes/index.ts
export const createRouterInstance = async() = >{
// 堆代码 duidaima.com
// 存储布局路由
let layoutRoute: RouteRecordRaw | undefined
if (ENV_TYPE === 'federation') {
// 获取远程入口配置
const remoteRoutes = await getRemoteEntries('', 'routes')
// 找到Layout路由并合并
layoutRoute = mergeLayoutChildren(remoteRoutes || [])
}
// 创建路由实例
const router = createRouter({
history: createWebHashHistory(),
routes: ENV_TYPE === 'federation' ? (layoutRoute as any).
default:
baseRoutes,
})
// 全局前置守卫
router.beforeEach(async(to, from, next) = >{
// 路由守卫逻辑...
next()
}) return router
}
4.4 路由合并核心逻辑// 合并路由
const mergeLayoutChildren = (data: RouteRecordRaw[]) = >{
let layoutIndex = -1
// 添加变量记录Layout的索引
const outLayoutItems: {
moduleIndex: number;
route: any
} [] = []
// 存储所有outLayoutItem及其位置
// 首先处理 baseRoutes 中的 outLayout 路由
const baseOutLayoutRoutes = baseRoutes.filter((route) = >route.meta ? .outLayout) baseOutLayoutRoutes.forEach((route) = >{
outLayoutItems.push({
moduleIndex: -1,
route
})
})
// 遍历远程数据处理布局路由和独立路由
data.forEach((item, index) = >{
const defaultRoutes = (item as any).
default const layoutItem = defaultRoutes.find((route: any) = >route.name === 'Layout') const outLayoutItem = defaultRoutes.find((route: any) = >route.meta ? .outLayout) if (layoutItem) {
layoutIndex = index
// 记录Layout所在的索引
}
if (outLayoutItem) {
outLayoutItems.push({
moduleIndex: index,
route: outLayoutItem
})
// 从原数组中移除outLayoutItem避免重复
const outLayoutIndex = defaultRoutes.findIndex((route: any) = >route.meta ? .outLayout) if (outLayoutIndex > -1) {
defaultRoutes.splice(outLayoutIndex, 1)
}
}
})
// 获取Layout路由作为合并基础
const layoutRoute = (data[layoutIndex] as any).
default.find((route:
any) = >route.name === 'Layout') if (layoutRoute) {
// 将所有outLayoutItem添加到data[layoutIndex].default中作为顶层路由
outLayoutItems.forEach((item) = >{; (data[layoutIndex] as any).
default.push(item.route)
}) if (layoutRoute.children) {
// 将主应用中非outLayout的路由添加到Layout的children中
const nonOutLayoutBaseRoutes = baseRoutes.filter((route) = >!route.meta ? .outLayout) layoutRoute.children.push(...nonOutLayoutBaseRoutes)
// 遍历所有其他模块的路由添加到Layout的children中
data.forEach((item, index) = >{
if (index !== layoutIndex) {
const routes = (item as any).
default
if (Array.isArray(routes)) {
layoutRoute.children.push(...routes)
}
}
})
}
}
return data[layoutIndex]
}
五、状态管理与模块通信// stores/modules/cache.ts
import {
defineStore
}
from 'pinia'import {
ref
}
from 'vue'import config from '@/config'interface CacheData {
value: unknown expire ? :number
}
export const useCacheStore = defineStore('cache', () = >{
// 状态定义
const prefix = ref < string > (config.cachePrefix) const cacheData = ref < Record < string,
CacheData >> ({})
// 获取当前时间戳
const getTime = () : number = >{
return Math.round(new Date().getTime() / 1000)
}
// 获取完整的键名
const getKey = (key: string, customPrefix ? :string) : string = >{
return (customPrefix ? ?prefix.value) + key
}
// 设置缓存数据
const set = (key: string, value: unknown, expire ? :number, customPrefix ? :string) = >{
const fullKey = getKey(key, customPrefix) cacheData.value[fullKey] = {
value,
expire: expire ? getTime() + expire: undefined,
}
}
// 获取缓存数据
const get = (key: string, customPrefix ? :string) = >{
const fullKey = getKey(key, customPrefix) const data = cacheData.value[fullKey]
if (!data) return null
if (data.expire && data.expire < getTime()) {
remove(key, customPrefix) return null
}
return data.value
}
// 删除缓存数据
const remove = (key: string, customPrefix ? :string) = >{
const fullKey = getKey(key, customPrefix) delete cacheData.value[fullKey]
}
return {
prefix,
cacheData,
set,
get,
remove,
getKey,
getTime,
}
},
{
persist: {
storage: localStorage,
},
})
5.2 缓存模块的核心特性// 页面A中设置数据
const cacheStore = useCacheStore()
cacheStore.set('sharedData', { count: 1 })
// 页面B中读取数据
const cacheStore = useCacheStore()
const data = cacheStore.get('sharedData')
「基于过期时间的临时共享」:对于临时性数据,设置合适的过期时间// 设置1小时过期的共享数据
cacheStore.set('temporaryData', value, 3600)
「跨应用的全局状态」:通过特定前缀获取不同微前端的状态// 获取user微前端模块的状态
cacheStore.set('userInfo', settings, undefined, 'user')
// 读取user微前端模块的状态
const settings = cacheStore.get('userInfo', 'user')
六、项目结构与配置// vite.config.ts
import {
defineConfig
}
from 'vite'import vue from '@vitejs/plugin-vue'import {
federation
}
from '@originjs/vite-plugin-federation'import path from 'path'import {
config
}
from './config'const baseUrl = process.env.VITE_APP_BASE_URL || ''
// 需要暴露的模块
const exposes: Record < string,
string > ={
// 暴露路由表,供其他应用使用
'./routes': './src/routes/index.ts',
}
export
default defineConfig({
// 基础配置
base:
baseUrl || './',
// 通过构建脚本传入实际静态文件地址,所有联邦模块文件都会遵循这个地址
// 插件配置
plugins: [vue(),
// 模块联邦配置
federation({
name: config.projectPrefix + 'Module',
// 微前端模块名称
filename: config.projectPrefix + 'Entry.js',
// 入口文件名
exposes,
// 暴露的模块
remotes: {
// 只需单独引入menuModule,其他的模块走federation
menuModule: ModuleUrl,
},
// 共享依赖配置
shared: ['vue', 'vue-router', 'pinia', 'dayjs', 'axios', 'sass', ],
})],
// 解析配置
resolve: {
alias: {
'@': path.resolve(__dirname, 'src')
}
},
})
部署与注册机制├── federation/
│ └── registry.json (模块注册信息)
│
├── front-pc-page/ (页面模块)
│ └── pageEntry.js (入口文件)
│
├── front-pc-menu/ (菜单模块)
└── menuEntry.js (菜单模块入口)
registry.json 示例:[
{
"name": "pageModule",
"path": "front-pc-page"
},
{
"name": "menuModule",
"path": "front-pc-menu"
}
]
七、总结