generateExposesPlugin.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. import { type RouteRecordRaw } from 'vue-router'
  2. import fs from 'node:fs'
  3. import path from 'node:path'
  4. interface ExposeItem {
  5. key: string
  6. value: string
  7. meta?: {
  8. title?: string
  9. description?: string
  10. category?: string
  11. icon?: string
  12. tags?: string[]
  13. }
  14. }
  15. // wrapper 文件生成目录(相对于项目根目录)
  16. const GENERATED_DIR = './src/.generated'
  17. // remote-setup.ts 的路径(相对于 wrapper 文件)
  18. const REMOTE_SETUP_RELATIVE = '../remote-setup'
  19. /**
  20. * 将 expose key(如 ./home/dashboard)转为合法的文件名(如 home_dashboard)
  21. */
  22. function exposeKeyToFileName(key: string): string {
  23. return key
  24. .replace(/^\.\//, '') // 去掉 ./
  25. .replace(/\//g, '_') // / → _
  26. .replace(/-/g, '_') // - → _
  27. }
  28. /**
  29. * 生成 wrapper 文件并返回 wrapper 的路径
  30. * wrapper 文件内容:import remote-setup + re-export Vue 组件
  31. */
  32. function generateWrapperFile(componentPath: string, fileName: string): string {
  33. // 确保生成目录存在
  34. const generatedDir = path.resolve(process.cwd(), GENERATED_DIR)
  35. if (!fs.existsSync(generatedDir)) {
  36. fs.mkdirSync(generatedDir, { recursive: true })
  37. }
  38. // 计算从 wrapper 到 Vue 组件的相对路径
  39. // wrapper 在 src/.generated/xxx.ts,组件在 src/views/XxxView.vue
  40. // 所以相对路径是 ../views/XxxView.vue
  41. const relativeComponentPath = componentPath
  42. .replace(/^\.\//, '../') // ./src/views/Xxx.vue → ../src/views/Xxx.vue
  43. .replace(/src\//, '') // ../src/views/Xxx.vue → ../views/Xxx.vue
  44. // wrapper 文件内容
  45. const content = [
  46. '// ⚠️ 自动生成文件,请勿手动修改',
  47. `import { ensureRemoteSetup } from '${REMOTE_SETUP_RELATIVE}'`,
  48. 'ensureRemoteSetup()',
  49. `export { default } from '${relativeComponentPath}'`,
  50. '',
  51. ].join('\n')
  52. const wrapperPath = path.join(generatedDir, `${fileName}.ts`)
  53. fs.writeFileSync(wrapperPath, content, 'utf-8')
  54. // 返回相对于项目根目录的路径,供 exposes 配置使用
  55. return `${GENERATED_DIR}/${fileName}.ts`
  56. }
  57. /**
  58. * 递归遍历路由,提取所有需要暴露的组件
  59. * @param routes 路由配置
  60. * @param parentPath 父路径
  61. * @param onlyLeafNodes 是否只展示叶子节点(默认 true,只展示最底层的子路由)
  62. */
  63. function extractExposesFromRoutes(
  64. routes: RouteRecordRaw[],
  65. parentPath = '',
  66. onlyLeafNodes = true
  67. ): ExposeItem[] {
  68. const exposes: ExposeItem[] = []
  69. routes.forEach((route) => {
  70. // 构建当前路径(去掉开头的 / 和结尾的 /)
  71. const currentPath = route.path.replace(/^\//, '').replace(/\/$/, '')
  72. const fullPath = parentPath
  73. ? `${parentPath}/${currentPath}`.replace(/\/+/g, '/')
  74. : currentPath
  75. // 检查是否有子路由
  76. const hasChildren = route.children && route.children.length > 0
  77. // 判断是否应该暴露当前路由
  78. const shouldExpose = route.component && (
  79. !onlyLeafNodes || // 如果不是只展示叶子节点,所有有组件的路由都暴露
  80. !hasChildren // 如果只展示叶子节点,只有没有子路由的才暴露
  81. )
  82. if (shouldExpose) {
  83. // 获取组件路径
  84. const componentPath = getComponentPath(route.component, route.name as string)
  85. if (componentPath) {
  86. exposes.push({
  87. key: `./${fullPath}`,
  88. value: componentPath,
  89. meta: route.meta as any,
  90. })
  91. } else {
  92. console.warn(`⚠️ Cannot extract component path for route: ${route.name} (${fullPath})`)
  93. }
  94. }
  95. // 递归处理子路由
  96. if (hasChildren) {
  97. const childExposes = extractExposesFromRoutes(route.children, fullPath, onlyLeafNodes)
  98. exposes.push(...childExposes)
  99. }
  100. })
  101. return exposes
  102. }
  103. /**
  104. * 获取组件路径
  105. */
  106. function getComponentPath(
  107. component: any,
  108. routeName?: string
  109. ): string | null {
  110. // 处理异步组件 () => import(...)
  111. if (typeof component === 'function') {
  112. try {
  113. // 尝试从函数中提取 import 路径
  114. const componentStr = component.toString()
  115. // 🔍 调试:打印函数字符串(仅在调试时)
  116. if (process.env.DEBUG_EXPOSES === 'true' && routeName) {
  117. console.log(`\n🔍 ===== Route: ${routeName} =====`)
  118. console.log(`🔍 Component function string:`, componentStr)
  119. }
  120. // 尝试多种匹配模式
  121. let importPath: string | null = null
  122. // 模式 1: 标准 import('...') 或 import("...")
  123. let importMatch = componentStr.match(/import\s*\(\s*['"](.+?)['"]\s*\)/)
  124. if (importMatch && importMatch[1]) {
  125. importPath = importMatch[1]
  126. }
  127. // 模式 2: jitiImport('...') 或其他打包工具转换的形式
  128. if (!importPath) {
  129. importMatch = componentStr.match(/jitiImport\s*\(\s*['"](.+?)['"]\s*\)/)
  130. if (importMatch && importMatch[1]) {
  131. importPath = importMatch[1]
  132. }
  133. }
  134. // 模式 3: __webpack_require__ 或其他类似的模式
  135. if (!importPath) {
  136. importMatch = componentStr.match(/\bwebpackImport\s*\(\s*['"](.+?)['"]\s*\)/)
  137. if (importMatch && importMatch[1]) {
  138. importPath = importMatch[1]
  139. }
  140. }
  141. if (importPath) {
  142. // 处理相对路径,确保以 ./src 开头
  143. if (importPath.startsWith('../')) {
  144. const result = `./src/${importPath.replace(/\.\.\//g, '')}`
  145. if (process.env.DEBUG_EXPOSES === 'true' && routeName) {
  146. console.log(`✅ Extracted path: ${importPath} -> ${result}`)
  147. }
  148. return result
  149. }
  150. if (importPath.startsWith('./')) {
  151. const result = `./src/${importPath.replace('./', '')}`
  152. if (process.env.DEBUG_EXPOSES === 'true' && routeName) {
  153. console.log(`✅ Extracted path: ${importPath} -> ${result}`)
  154. }
  155. return result
  156. }
  157. if (process.env.DEBUG_EXPOSES === 'true' && routeName) {
  158. console.log(`✅ Extracted path: ${importPath}`)
  159. }
  160. return importPath
  161. } else {
  162. if (process.env.DEBUG_EXPOSES === 'true' && routeName) {
  163. console.log(`❌ No import path match found`)
  164. }
  165. }
  166. } catch (error) {
  167. console.error(`Error processing component for route ${routeName}:`, error)
  168. }
  169. }
  170. return null
  171. }
  172. /**
  173. * 生成 Module Federation exposes 配置
  174. * @param routes 路由配置
  175. * @param options 配置选项
  176. * @param options.onlyLeafNodes 是否只展示叶子节点(默认 true,只展示最底层的子路由)
  177. */
  178. export function generateExposesFromRoutes(
  179. routes: RouteRecordRaw[],
  180. options: { onlyLeafNodes?: boolean } = {}
  181. ) {
  182. const { onlyLeafNodes = true } = options
  183. const exposeItems = extractExposesFromRoutes(routes, '', onlyLeafNodes)
  184. // 转换为 exposes 对象
  185. // ⭐ 关键改动:每个 expose 指向自动生成的 wrapper 文件,而不是直接指向 Vue 组件
  186. const exposes: Record<string, string> = {}
  187. exposeItems.forEach((item) => {
  188. const fileName = exposeKeyToFileName(item.key)
  189. const wrapperPath = generateWrapperFile(item.value, fileName)
  190. exposes[item.key] = wrapperPath
  191. })
  192. return {
  193. exposes,
  194. exposeItems, // 返回详细信息,可用于生成文档或其他用途
  195. }
  196. }
  197. /**
  198. * Rsbuild 插件:自动从路由生成 exposes 并注入到 Module Federation 配置
  199. * @param routes 路由配置
  200. * @param options 配置选项
  201. * @param options.onlyLeafNodes 是否只展示叶子节点(默认 true,只展示最底层的子路由)
  202. */
  203. export function generateExposesPlugin(
  204. routes: RouteRecordRaw[],
  205. options: { onlyLeafNodes?: boolean } = {}
  206. ) {
  207. return {
  208. name: 'generate-exposes-plugin',
  209. apply: 'serve',
  210. setup(build: any) {
  211. // 生成 exposes 配置
  212. const { exposes, exposeItems } = generateExposesFromRoutes(routes, options)
  213. // 打印生成的配置(开发时可见)
  214. console.log('📦 Auto-generated Module Federation exposes:')
  215. console.table(
  216. exposeItems.map((item) => ({
  217. 'Expose Key': item.key,
  218. 'Component Path': item.value,
  219. 'Title': item.meta?.title || '-',
  220. }))
  221. )
  222. // 初始化钩子:修改 Module Federation 配置
  223. build.onBeforeBuild?.(() => {
  224. // 这里可以添加构建前的逻辑
  225. })
  226. // 返回 exposes 配置供外部使用
  227. return exposes
  228. },
  229. }
  230. }