feat(微前端): 新增远端组件加载器与动态路由(PoC)

补上宿主侧微前端基础设施:main.ts 启动时经 router.addRoute 注入 /remote-app/:code;remoteLoader 支持工厂/模块联邦/直接导出三种远端约定并把宿主运行时(Vue/Router/Pinia/Element Plus)传给远端以共享单例;新增 RemoteAppHost 宿主页与 public/remote-demo 演示远端模块;AppFrontendModule 增加 remote 类型与 RemoteEntry/RemoteModule 字段。仅登记演示应用,不接后端清单。
This commit is contained in:
TermiNexus committed 2026-10-05 12:04:29 +08:00
1 parent 70cce18b23
commit bcd4aca403
6 files changed
+305 -2

No files matched your search

+94
View File
@@ -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',
),
]),
]),
},
),
])
},
}
}
+11 -2
View File
@@ -50,8 +50,13 @@ export interface AppFrontendModule {
Title: string Title: string
Icon: string Icon: string
FrontendRoute: string FrontendRoute: string
FrontendType: 'vue' | 'legacy' /** vue=本仓库编译的组件 / legacy=iframe 直载 / remote=运行时加载远端组件(微前端) */
FrontendType: 'vue' | 'legacy' | 'remote'
LegacySrc: string LegacySrc: string
/** remote:远端入口 URL(如 https://sub.xxx/remoteEntry.js),由 remoteLoader 动态加载 */
RemoteEntry?: string
/** remote:远端导出的模块名(默认 default;工厂约定 createComponent 时忽略) */
RemoteModule?: string
/** 远端注入(MSYNC):提供方服务名(打开远端 legacy 页面时签发用户令牌用) */ /** 远端注入(MSYNC):提供方服务名(打开远端 legacy 页面时签发用户令牌用) */
ServiceName?: string ServiceName?: string
/** 应用自定义配置页:存在时替换配置页的通用「配置项」表单 */ /** 应用自定义配置页:存在时替换配置页的通用「配置项」表单 */
@@ -60,8 +65,12 @@ export interface AppFrontendModule {
ConfigPanels?: AppConfigPanel[] ConfigPanels?: AppConfigPanel[]
} }
/** 打开应用的跳转目标(vue 走前端路由;legacy 走对应 LegacyPage 路由) */ /** 打开应用的跳转目标(vue 走前端路由;legacy 走对应 LegacyPage 路由;remote 走远端应用宿主路由) */
export function appOpenTarget(mod: AppFrontendModule): RouteLocationRaw { 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 供令牌透传 // 远端 legacy(绝对 URL,子服务器托管):走 /remote/:code,带 src+svc 供令牌透传
if (mod.FrontendType === 'legacy' && /^https?:\/\//.test(mod.LegacySrc)) { if (mod.FrontendType === 'legacy' && /^https?:\/\//.test(mod.LegacySrc)) {
return { return {
+87
View File
@@ -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 <ExportName> = 组件`
*
* 注意:动态 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 组件(可直接用于 <component :is>)
* @throws 远端不可达 / 未导出可用组件时抛错,由调用方展示
*/
export async function LoadRemoteComponent(
RemoteEntry: string,
ExportName = 'default',
): Promise<Component> {
if (!RemoteEntry) {
throw new Error('远端入口地址为空')
}
// 运行期 URL:@vite-ignore 阻止打包器静态分析(远端不在本仓库内)
const mod: Record<string, unknown> = 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<void>; get?: (name: string) => Promise<unknown> } | 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
}
+42
View File
@@ -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 },
})
}
+3
View File
@@ -12,6 +12,7 @@ import '@fortawesome/fontawesome-free/css/all.min.css'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import pinia from './stores' import pinia from './stores'
import { InstallRemoteAppRoutes } from './apps/remoteRoutes'
import './styles/global.scss' import './styles/global.scss'
// hash 路由模式兜底:pathname 应恒为 / // hash 路由模式兜底:pathname 应恒为 /
@@ -57,6 +58,8 @@ for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.use(pinia) app.use(pinia)
app.use(router) app.use(router)
// 安装远端微前端应用路由(router.addRoute 动态注入;PoC 仅登记演示应用)
InstallRemoteAppRoutes(router)
app.use(ElementPlus) app.use(ElementPlus)
app.use(VxeUITable) app.use(VxeUITable)
+68
View File
@@ -0,0 +1,68 @@
<script setup lang="ts">
/**
* RemoteAppHost —— 远端微前端应用宿主页(PoC)
*
* 由 `/remote-app/:code` 路由驱动:
* 按 AppCode 取远端应用描述 → 运行时加载远端组件 → 原生渲染(非 iframe)。
* 远端加载失败只影响本页(展示错误提示),不阻塞宿主其余功能。
*/
import { ref, shallowRef, watch, type Component } from 'vue'
import { useRoute } from 'vue-router'
import { getAppModule } from '@/apps'
import { LoadRemoteComponent } from '@/apps/remoteLoader'
const route = useRoute()
const loading = ref(false)
const error = ref('')
const remoteComponent = shallowRef<Component | null>(null)
async function load(Code: string): Promise<void> {
loading.value = true
error.value = ''
remoteComponent.value = null
const mod = getAppModule(Code)
if (!mod || mod.FrontendType !== 'remote' || !mod.RemoteEntry) {
error.value = `未注册的远端应用:${Code || '(空)'}`
loading.value = false
return
}
try {
remoteComponent.value = await LoadRemoteComponent(mod.RemoteEntry, mod.RemoteModule || 'default')
} catch (e) {
error.value = `远端组件加载失败:${(e as Error).message}`
} finally {
loading.value = false
}
}
watch(
() => route.params.code,
(Code) => {
void load(String(Code || ''))
},
{ immediate: true },
)
</script>
<template>
<div class="remote-app-host">
<el-alert
v-if="error"
type="error"
:closable="false"
show-icon
:title="error"
/>
<el-skeleton v-else-if="loading" :rows="6" animated />
<component :is="remoteComponent" v-else-if="remoteComponent" />
</div>
</template>
<style scoped>
.remote-app-host {
padding: 16px;
box-sizing: border-box;
height: 100%;
}
</style>