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:
1 parent
70cce18b23
commit
bcd4aca403
6 files changed
+305
-2
No files matched your search
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in new issue
Block a user