import { type RouteRecordRaw } from 'vue-router' import fs from 'node:fs' import path from 'node:path' interface ExposeItem { key: string value: string meta?: { title?: string description?: string category?: string icon?: string tags?: string[] } } // wrapper 文件生成目录(相对于项目根目录) const GENERATED_DIR = './src/.generated' // remote-setup.ts 的路径(相对于 wrapper 文件) const REMOTE_SETUP_RELATIVE = '../remote-setup' /** * 将 expose key(如 ./home/dashboard)转为合法的文件名(如 home_dashboard) */ function exposeKeyToFileName(key: string): string { return key .replace(/^\.\//, '') // 去掉 ./ .replace(/\//g, '_') // / → _ .replace(/-/g, '_') // - → _ } /** * 生成 wrapper 文件并返回 wrapper 的路径 * wrapper 文件内容:import remote-setup + re-export Vue 组件 */ function generateWrapperFile(componentPath: string, fileName: string): string { // 确保生成目录存在 const generatedDir = path.resolve(process.cwd(), GENERATED_DIR) if (!fs.existsSync(generatedDir)) { fs.mkdirSync(generatedDir, { recursive: true }) } // 计算从 wrapper 到 Vue 组件的相对路径 // wrapper 在 src/.generated/xxx.ts,组件在 src/views/XxxView.vue // 所以相对路径是 ../views/XxxView.vue const relativeComponentPath = componentPath .replace(/^\.\//, '../') // ./src/views/Xxx.vue → ../src/views/Xxx.vue .replace(/src\//, '') // ../src/views/Xxx.vue → ../views/Xxx.vue // wrapper 文件内容 const content = [ '// ⚠️ 自动生成文件,请勿手动修改', `import { ensureRemoteSetup } from '${REMOTE_SETUP_RELATIVE}'`, 'ensureRemoteSetup()', `export { default } from '${relativeComponentPath}'`, '', ].join('\n') const wrapperPath = path.join(generatedDir, `${fileName}.ts`) fs.writeFileSync(wrapperPath, content, 'utf-8') // 返回相对于项目根目录的路径,供 exposes 配置使用 return `${GENERATED_DIR}/${fileName}.ts` } /** * 递归遍历路由,提取所有需要暴露的组件 * @param routes 路由配置 * @param parentPath 父路径 * @param onlyLeafNodes 是否只展示叶子节点(默认 true,只展示最底层的子路由) */ function extractExposesFromRoutes( routes: RouteRecordRaw[], parentPath = '', onlyLeafNodes = true ): ExposeItem[] { const exposes: ExposeItem[] = [] routes.forEach((route) => { // 构建当前路径(去掉开头的 / 和结尾的 /) const currentPath = route.path.replace(/^\//, '').replace(/\/$/, '') const fullPath = parentPath ? `${parentPath}/${currentPath}`.replace(/\/+/g, '/') : currentPath // 检查是否有子路由 const hasChildren = route.children && route.children.length > 0 // 判断是否应该暴露当前路由 const shouldExpose = route.component && ( !onlyLeafNodes || // 如果不是只展示叶子节点,所有有组件的路由都暴露 !hasChildren // 如果只展示叶子节点,只有没有子路由的才暴露 ) if (shouldExpose) { // 获取组件路径 const componentPath = getComponentPath(route.component, route.name as string) if (componentPath) { exposes.push({ key: `./${fullPath}`, value: componentPath, meta: route.meta as any, }) } else { console.warn(`⚠️ Cannot extract component path for route: ${route.name} (${fullPath})`) } } // 递归处理子路由 if (hasChildren) { const childExposes = extractExposesFromRoutes(route.children, fullPath, onlyLeafNodes) exposes.push(...childExposes) } }) return exposes } /** * 获取组件路径 */ function getComponentPath( component: any, routeName?: string ): string | null { // 处理异步组件 () => import(...) if (typeof component === 'function') { try { // 尝试从函数中提取 import 路径 const componentStr = component.toString() // 🔍 调试:打印函数字符串(仅在调试时) if (process.env.DEBUG_EXPOSES === 'true' && routeName) { console.log(`\n🔍 ===== Route: ${routeName} =====`) console.log(`🔍 Component function string:`, componentStr) } // 尝试多种匹配模式 let importPath: string | null = null // 模式 1: 标准 import('...') 或 import("...") let importMatch = componentStr.match(/import\s*\(\s*['"](.+?)['"]\s*\)/) if (importMatch && importMatch[1]) { importPath = importMatch[1] } // 模式 2: jitiImport('...') 或其他打包工具转换的形式 if (!importPath) { importMatch = componentStr.match(/jitiImport\s*\(\s*['"](.+?)['"]\s*\)/) if (importMatch && importMatch[1]) { importPath = importMatch[1] } } // 模式 3: __webpack_require__ 或其他类似的模式 if (!importPath) { importMatch = componentStr.match(/\bwebpackImport\s*\(\s*['"](.+?)['"]\s*\)/) if (importMatch && importMatch[1]) { importPath = importMatch[1] } } if (importPath) { // 处理相对路径,确保以 ./src 开头 if (importPath.startsWith('../')) { const result = `./src/${importPath.replace(/\.\.\//g, '')}` if (process.env.DEBUG_EXPOSES === 'true' && routeName) { console.log(`✅ Extracted path: ${importPath} -> ${result}`) } return result } if (importPath.startsWith('./')) { const result = `./src/${importPath.replace('./', '')}` if (process.env.DEBUG_EXPOSES === 'true' && routeName) { console.log(`✅ Extracted path: ${importPath} -> ${result}`) } return result } if (process.env.DEBUG_EXPOSES === 'true' && routeName) { console.log(`✅ Extracted path: ${importPath}`) } return importPath } else { if (process.env.DEBUG_EXPOSES === 'true' && routeName) { console.log(`❌ No import path match found`) } } } catch (error) { console.error(`Error processing component for route ${routeName}:`, error) } } return null } /** * 生成 Module Federation exposes 配置 * @param routes 路由配置 * @param options 配置选项 * @param options.onlyLeafNodes 是否只展示叶子节点(默认 true,只展示最底层的子路由) */ export function generateExposesFromRoutes( routes: RouteRecordRaw[], options: { onlyLeafNodes?: boolean } = {} ) { const { onlyLeafNodes = true } = options const exposeItems = extractExposesFromRoutes(routes, '', onlyLeafNodes) // 转换为 exposes 对象 // ⭐ 关键改动:每个 expose 指向自动生成的 wrapper 文件,而不是直接指向 Vue 组件 const exposes: Record = {} exposeItems.forEach((item) => { const fileName = exposeKeyToFileName(item.key) const wrapperPath = generateWrapperFile(item.value, fileName) exposes[item.key] = wrapperPath }) return { exposes, exposeItems, // 返回详细信息,可用于生成文档或其他用途 } } /** * Rsbuild 插件:自动从路由生成 exposes 并注入到 Module Federation 配置 * @param routes 路由配置 * @param options 配置选项 * @param options.onlyLeafNodes 是否只展示叶子节点(默认 true,只展示最底层的子路由) */ export function generateExposesPlugin( routes: RouteRecordRaw[], options: { onlyLeafNodes?: boolean } = {} ) { return { name: 'generate-exposes-plugin', apply: 'serve', setup(build: any) { // 生成 exposes 配置 const { exposes, exposeItems } = generateExposesFromRoutes(routes, options) // 打印生成的配置(开发时可见) console.log('📦 Auto-generated Module Federation exposes:') console.table( exposeItems.map((item) => ({ 'Expose Key': item.key, 'Component Path': item.value, 'Title': item.meta?.title || '-', })) ) // 初始化钩子:修改 Module Federation 配置 build.onBeforeBuild?.(() => { // 这里可以添加构建前的逻辑 }) // 返回 exposes 配置供外部使用 return exposes }, } }