From bcd4aca4030910124a639dcefdb54e4a19fae24a Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Mon, 5 Oct 2026 12:04:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=BE=AE=E5=89=8D=E7=AB=AF):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E8=BF=9C=E7=AB=AF=E7=BB=84=E4=BB=B6=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E5=99=A8=E4=B8=8E=E5=8A=A8=E6=80=81=E8=B7=AF=E7=94=B1=EF=BC=88?= =?UTF-8?q?PoC=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补上宿主侧微前端基础设施:main.ts 启动时经 router.addRoute 注入 /remote-app/:code;remoteLoader 支持工厂/模块联邦/直接导出三种远端约定并把宿主运行时(Vue/Router/Pinia/Element Plus)传给远端以共享单例;新增 RemoteAppHost 宿主页与 public/remote-demo 演示远端模块;AppFrontendModule 增加 remote 类型与 RemoteEntry/RemoteModule 字段。仅登记演示应用,不接后端清单。 --- public/remote-demo/entry.js | 94 +++++++++++++++++++++++++++++++++++++ src/apps/index.ts | 13 ++++- src/apps/remoteLoader.ts | 87 ++++++++++++++++++++++++++++++++++ src/apps/remoteRoutes.ts | 42 +++++++++++++++++ src/main.ts | 3 ++ src/views/RemoteAppHost.vue | 68 +++++++++++++++++++++++++++ 6 files changed, 305 insertions(+), 2 deletions(-) create mode 100644 public/remote-demo/entry.js create mode 100644 src/apps/remoteLoader.ts create mode 100644 src/apps/remoteRoutes.ts create mode 100644 src/views/RemoteAppHost.vue diff --git a/public/remote-demo/entry.js b/public/remote-demo/entry.js new file mode 100644 index 0000000..412420e --- /dev/null +++ b/public/remote-demo/entry.js @@ -0,0 +1,94 @@ +/** + * 远端微前端 PoC —— 模拟"由子服务器托管、运行时加载"的远端组件 + * + * 真实场景:这份文件由子服务器构建并托管(如 https://sub.example.com/remoteEntry.js), + * 宿主通过 URL 动态 import 加载(跨源需远端提供 CORS)。 + * 本 PoC 放在 public/ 下由宿主自己托管,仅用于验证宿主侧的 + * 「远端组件加载器 + router.addRoute 动态路由」基础设施是否跑通。 + * + * 契约(工厂式):export function createComponent(Host) -> Vue 组件 + * Host = { Vue, VueRouter, Pinia, ElementPlus }(宿主传入 → 共享同一份运行时, + * 避免双 Vue 实例;正式接 Module Federation 时 shared: singleton 等价) + * + * 说明:远端只拿到宿主传入的运行时,因此这里用渲染函数(render function)写组件; + * 远端若自行打包 Vue SFC,同样可以(由远端构建决定),宿主加载器无需改动。 + */ +export function createComponent(Host) { + const { h, ref, computed, onMounted } = Host.Vue + const { ElCard, ElButton, ElTag, ElDivider, ElMessage } = Host.ElementPlus + + return { + name: 'RemoteDemoPage', + setup() { + const count = ref(0) + const doubled = computed(() => count.value * 2) + const renderedAt = ref('') + + onMounted(() => { + renderedAt.value = new Date().toLocaleTimeString() + }) + + return () => + h('div', { style: 'padding: 4px' }, [ + h( + ElCard, + { shadow: 'never' }, + { + header: () => + h('div', { style: 'display: flex; align-items: center; gap: 8px' }, [ + h('b', '远端组件演示(PoC)'), + h(ElTag, { type: 'success', effect: 'plain', size: 'small' }, () => '运行时从外部加载并渲染'), + ]), + default: () => + h('div', [ + h( + 'div', + { style: 'font-size: 13px; color: var(--el-text-color-secondary); line-height: 2' }, + [ + h('div', '这个组件不在 GERP 前端仓库里编译,而是运行时从外部 URL 动态加载后原生渲染(不是 iframe)。'), + h('div', ['宿主共享的 Vue(', h('code', 'ref / computed / h'), ')可用']), + h('div', ['宿主共享的 Element Plus(', h('code', 'ElCard / ElButton / ElTag'), ')可用']), + ], + ), + h(ElDivider), + h('div', { style: 'font-size: 13px; line-height: 2' }, [ + h('div', ['计数器:', h('b', String(count.value)), ',两倍 = ', h('b', String(doubled.value))]), + h('div', ['远端组件挂载时间:', String(renderedAt.value)]), + ]), + h('div', { style: 'margin-top: 12px; display: flex; gap: 8px' }, [ + h( + ElButton, + { + type: 'primary', + onClick: () => { + count.value += 1 + }, + }, + () => '点我 +1', + ), + h( + ElButton, + { + onClick: () => { + count.value = 0 + }, + }, + () => '重置', + ), + h( + ElButton, + { + onClick: () => { + ElMessage.success('远端组件成功调用了宿主 Element Plus 的 ElMessage') + }, + }, + () => '调用 ElMessage', + ), + ]), + ]), + }, + ), + ]) + }, + } +} diff --git a/src/apps/index.ts b/src/apps/index.ts index fe3c7b9..f6c09d0 100644 --- a/src/apps/index.ts +++ b/src/apps/index.ts @@ -50,8 +50,13 @@ export interface AppFrontendModule { Title: string Icon: string FrontendRoute: string - FrontendType: 'vue' | 'legacy' + /** vue=本仓库编译的组件 / legacy=iframe 直载 / remote=运行时加载远端组件(微前端) */ + FrontendType: 'vue' | 'legacy' | 'remote' LegacySrc: string + /** remote:远端入口 URL(如 https://sub.xxx/remoteEntry.js),由 remoteLoader 动态加载 */ + RemoteEntry?: string + /** remote:远端导出的模块名(默认 default;工厂约定 createComponent 时忽略) */ + RemoteModule?: string /** 远端注入(MSYNC):提供方服务名(打开远端 legacy 页面时签发用户令牌用) */ ServiceName?: string /** 应用自定义配置页:存在时替换配置页的通用「配置项」表单 */ @@ -60,8 +65,12 @@ export interface AppFrontendModule { ConfigPanels?: AppConfigPanel[] } -/** 打开应用的跳转目标(vue 走前端路由;legacy 走对应 LegacyPage 路由) */ +/** 打开应用的跳转目标(vue 走前端路由;legacy 走对应 LegacyPage 路由;remote 走远端应用宿主路由) */ export function appOpenTarget(mod: AppFrontendModule): RouteLocationRaw { + // 远端 micro-frontend(运行时加载远端组件):走 /remote-app/:code,由 RemoteAppHost 动态渲染 + if (mod.FrontendType === 'remote') { + return { path: `/remote-app/${encodeURIComponent(mod.AppCode)}` } + } // 远端 legacy(绝对 URL,子服务器托管):走 /remote/:code,带 src+svc 供令牌透传 if (mod.FrontendType === 'legacy' && /^https?:\/\//.test(mod.LegacySrc)) { return { diff --git a/src/apps/remoteLoader.ts b/src/apps/remoteLoader.ts new file mode 100644 index 0000000..9fbb3f8 --- /dev/null +++ b/src/apps/remoteLoader.ts @@ -0,0 +1,87 @@ +/** + * 远端微前端加载器(PoC) + * + * 用途:把"别人构建、托管在外部 URL"的 Vue 组件在运行时加载进 GERP 宿主并原生渲染 + * (不是 iframe),配合 `router.addRoute` 即成为远端应用的原生页面。 + * + * 共享运行时:加载远端前把宿主的 Vue / Vue Router / Pinia / Element Plus 作为参数 + * 传给远端工厂,避免出现两份 Vue 实例(双实例会导致响应式、插槽、组件注册全部失效)。 + * 正式切到 Module Federation 时,把 shared 设为 singleton 即可,本加载器无需改动。 + * + * 支持的远端模块约定(按顺序探测): + * 1) 工厂:`export function createComponent(Host) => Vue 组件` + * 2) Module Federation 容器:`default.init()` + `default.get(module) => () => { default }` + * 3) 直接导出:`export default 组件` 或 `export const = 组件` + * + * 注意:动态 import 的是运行期字符串 URL,跨源需远端提供 CORS;子服务器离线时 + * 加载失败由宿主页展示错误(不阻塞宿主)。 + */ + +import type { Component } from 'vue' +import * as Vue from 'vue' +import * as VueRouter from 'vue-router' +import * as Pinia from 'pinia' +import * as ElementPlus from 'element-plus' + +/** 宿主运行时(传给远端工厂,实现公共依赖共享) */ +export interface RemoteHostRuntime { + Vue: typeof Vue + VueRouter: typeof VueRouter + Pinia: typeof Pinia + ElementPlus: typeof ElementPlus +} + +let cachedRuntime: RemoteHostRuntime | null = null + +/** 取宿主运行时单例(惰性构造,避免模块加载期就绑定整套依赖) */ +export function GetRemoteHostRuntime(): RemoteHostRuntime { + if (!cachedRuntime) { + cachedRuntime = { Vue, VueRouter, Pinia, ElementPlus } + } + return cachedRuntime +} + +/** + * 加载远端组件 + * + * @param RemoteEntry 远端入口 URL(如 https://sub.xxx/remoteEntry.js 或 /remote-demo/entry.js) + * @param ExportName 导出名(默认 default;工厂约定忽略此参数) + * @returns Vue 组件(可直接用于 ) + * @throws 远端不可达 / 未导出可用组件时抛错,由调用方展示 + */ +export async function LoadRemoteComponent( + RemoteEntry: string, + ExportName = 'default', +): Promise { + if (!RemoteEntry) { + throw new Error('远端入口地址为空') + } + // 运行期 URL:@vite-ignore 阻止打包器静态分析(远端不在本仓库内) + const mod: Record = await import(/* @vite-ignore */ RemoteEntry) + + // 约定 1:工厂(宿主把运行时以参数传入) + const factory = mod.createComponent + if (typeof factory === 'function') { + return (factory as (host: RemoteHostRuntime) => Component)(GetRemoteHostRuntime()) + } + + // 约定 2:Module Federation 容器 + const container = mod.default as { init?: (arg: unknown) => Promise; get?: (name: string) => Promise } | undefined + if (container && typeof container.get === 'function') { + if (typeof container.init === 'function') { + await container.init({}) + } + const resolved = (await container.get(ExportName)) as { default?: Component } | Component | undefined + const component = (resolved as { default?: Component })?.default ?? (resolved as Component) + if (component) return component + } + + // 约定 3:直接导出 + const direct = + (ExportName !== 'default' ? (mod[ExportName] as Component | undefined) : undefined) ?? + (mod.default as Component | undefined) + if (!direct) { + throw new Error(`远端模块未导出可用组件:${RemoteEntry}#${ExportName}`) + } + return direct +} diff --git a/src/apps/remoteRoutes.ts b/src/apps/remoteRoutes.ts new file mode 100644 index 0000000..eccec37 --- /dev/null +++ b/src/apps/remoteRoutes.ts @@ -0,0 +1,42 @@ +/** + * 远端微前端应用——路由注入与登记(PoC) + * + * 现状:GERP 前端路由表是静态的、没有任何 `router.addRoute`;本模块补上 + * "远端应用 → 动态路由"的基础设施,供微前端通道使用。 + * + * PoC 范围:只登记一个演示用远端应用(`remote-demo`),不接后端清单。 + * 正式接入时,应用描述由 MSYNC 上报的远端清单/接口下发,调用 `registerRemoteApp()` + * 登记后调用本函数注入路由即可,无需改动宿主页与加载器。 + */ + +import type { Router } from 'vue-router' +import { registerRemoteApp, type AppFrontendModule } from '@/apps' + +/** 演示用远端应用:入口指向 public/remote-demo/entry.js(真实场景为子服务器托管 URL) */ +const REMOTE_DEMO_APP: AppFrontendModule = { + AppCode: 'remote-demo', + Title: '远端组件演示', + Icon: 'Link', + FrontendRoute: '/remote-app/remote-demo', + FrontendType: 'remote', + LegacySrc: '', + RemoteEntry: '/remote-demo/entry.js', + RemoteModule: 'default', + ServiceName: 'poc', +} + +/** + * 安装远端应用路由(宿主启动时调用一次) + * + * 注入 `/remote-app/:code`:由 RemoteAppHost 按 AppCode 动态加载远端组件渲染。 + * 注意路由不带 menuGuarded —— 远端应用暂不参与后端菜单过滤,由清单/接口自行控权限。 + */ +export function InstallRemoteAppRoutes(router: Router): void { + registerRemoteApp(REMOTE_DEMO_APP) + router.addRoute({ + path: '/remote-app/:code', + name: 'RemoteApp', + component: () => import('@/views/RemoteAppHost.vue'), + meta: { title: '远端应用', requiresAuth: true }, + }) +} diff --git a/src/main.ts b/src/main.ts index 1fe8563..210ca62 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,6 +12,7 @@ import '@fortawesome/fontawesome-free/css/all.min.css' import App from './App.vue' import router from './router' import pinia from './stores' +import { InstallRemoteAppRoutes } from './apps/remoteRoutes' import './styles/global.scss' // hash 路由模式兜底:pathname 应恒为 / @@ -57,6 +58,8 @@ for (const [key, component] of Object.entries(ElementPlusIconsVue)) { app.use(pinia) app.use(router) +// 安装远端微前端应用路由(router.addRoute 动态注入;PoC 仅登记演示应用) +InstallRemoteAppRoutes(router) app.use(ElementPlus) app.use(VxeUITable) diff --git a/src/views/RemoteAppHost.vue b/src/views/RemoteAppHost.vue new file mode 100644 index 0000000..129b08a --- /dev/null +++ b/src/views/RemoteAppHost.vue @@ -0,0 +1,68 @@ + + + + +