主题
前端远程应用
这一页解决一件事:把插件的 Vue 页面做成一份能被宿主在运行期装载的 ESM 产物。
它不再属于宿主仓库,而是插件自带的前端工程。读完你应该能:建出一个 frontend/<code>-app、 写出正确的入口契约、用共享构建工厂构建、把产物落到宿主认的目录、并用守门脚本自查。
一、三个位置,别混
| # | 是什么 | 在哪 |
|---|---|---|
| 1 | 前端工程(源码,要构建) | 插件仓库 wujies/wujies-plugins/frontend/<code>-app/ |
| 2 | 构建产物(index.js + CSS) | 默认各工程 dist/;本地联调与打包时指到下面第 3 处 |
| 3 | 产物落位目录(宿主 dev server / 生产的静态资源根) | 宿主仓库 plus-ui/app-market-dist/remote-apps/<code>/ |
工程挪过位置,旧文档里的路径全部作废
2026-09-30 起前端工程从宿主仓库的 app-market-poc/ 搬到了插件仓库的 frontend/。 看到 app-market-poc、plus-ui/app-market-app/*、plugins/ruoyi-<name> 这类路径,都是过时的。
工作区是这样的(plus-ui/ 的 package.json 里 name 写的是 wujies-admin,别被名字骗了):
text
wujies/wujies-plugins/ 插件仓库(三个仓库里唯一放着前端工程的)
├── wujies-hello/ 后端插件:Java + app.json + migrations/
├── frontend/ 远程应用工程,一个 pnpm workspace
│ ├── build/
│ │ ├── vite.ts defineRemoteApp() —— 全部共享构建逻辑
│ │ └── host-source.ts 宿主源码根解析(WUJIES_UI_DIR / 祖先探测)
│ ├── hello-app/
│ │ ├── package.json @wujies-plugins/hello-app
│ │ ├── vite.config.ts 二十来行,只声明本应用真正不同的东西
│ │ ├── app.json 近重复的清单(见第六节警告)
│ │ └── src/
│ │ ├── index.ts 入口契约
│ │ ├── runtime.ts 宿主运行时持有者
│ │ ├── api/ 接口层(随包走)
│ │ └── views/ 页面
│ └── gallery-app/ mall-app/ im-app/ ...
└── package.ps1 打包器(会自己调用这里的 vite 构建)yaml
# wujies/wujies-plugins/pnpm-workspace.yaml
packages:
- 'frontend/*-app'二、最短上手
powershell
cd wujies\wujies-plugins
pnpm install
# 宿主源码根(不是它的 src)。不设也能跑:host-source.ts 会沿祖先目录做结构性探测
$env:WUJIES_UI_DIR = 'C:\Users\TX\Desktop\ruoyi\plus-ui'
# 把产物直接落到宿主 dev server 认的目录,改完刷新浏览器即可
$env:WUJIES_FRONTEND_DIST = 'C:\Users\TX\Desktop\ruoyi\plus-ui\app-market-dist\remote-apps'
pnpm --filter @wujies-plugins/hello-app run build| 环境变量 | 作用 | 不设会怎样 |
|---|---|---|
WUJIES_UI_DIR | 宿主仓库根(含 package.json / src/main.ts 的那层) | 探测布局;探不到就明确报错,不会让你在半路撞上一堆"找不到模块" |
WUJIES_FRONTEND_DIST | 产物根目录,工厂会把 <appCode> 追加在后面 | 产物落在工程自己的 dist/,本地联调时宿主取不到 |
WUJIES_FRONTEND_DIR | 覆盖 frontend/ 工程根的探测(给守门脚本与打包器用) | 按插件仓库的 <插件仓库>/frontend/ 推导(宿主侧默认从 plus-ui/ 往 ../wujies/wujies-plugins/frontend 找) |
只有 standalone: true 的工程不需要宿主
hello-app 的配置里写了 standalone: true —— 它刻意不 import 宿主任何源码,所以没有宿主仓库也能构建。 其余应用都要读宿主的 src/utils、src/enums 等,缺了宿主会直接构建失败。
三、入口契约
src/index.ts 默认导出一个安装函数,接收宿主运行时,返回 { manifest, views }。 下面的骨架照自真实范例的结构(wujies/wujies-plugins/frontend/hello-app/src/index.ts); 注意 hello-app 是唯一保留 manifest.routes 的例外(3 条 /remote-demo/** 路由), 骨架里刻意用 routes: [] 展示挂菜单应用的常规写法:
ts
import DemoHome from './views/DemoHome.vue';
import { setRemoteRuntime } from './runtime';
const manifest = {
code: 'hello',
name: '示例远程应用',
version: '0.1.0',
// 见下:挂到宿主菜单上的应用,这里必须是空数组
routes: []
};
export default function install(runtime: any) {
setRemoteRuntime(runtime);
return {
manifest,
views: {
// 约定:加载器返回 { default: Component }
'demo/home': () => ({ default: DemoHome })
}
};
}| 字段 | 必填 | 说明 |
|---|---|---|
code | 是 | 应用编码,必须与后端 app.json 的 code 一致 |
name | 是 | 应用名 |
version | 是 | 版本号;仅供参考,宿主以服务端清单为准 |
routes | 是(数组) | 挂菜单的应用请写 [] |
views | 是 | 视图键 → 加载器 |
manifest 只是前端侧的运行时清单,它不被服务端校验(服务端只看后端那份 app.json), 所以前端 manifest.version 与后端 app.json 的版本可以不一致而不报错。
加载器的返回值必须能被解包成 { default: Component };直接返回组件本体也兼容, 但推荐统一写 () => ({ default: X }) —— 与 import() 的语义一致。 宿主侧解析代码在 plus-ui/src/plugins/appmarket/loader.ts。
3.1 视图键(view key)约定
后端 app.json 的菜单里,component 写 remote:<appCode>/<视图键>:
json
{ "key": "hello-probe", "type": "C", "path": "probe", "component": "remote:hello/demo/home" }remote:hello= 应用编码hello;demo/home= 视图键,必须与views的键逐字符一致(hello-app/app.json与hello-app/src/index.ts就是一组对照)。
对不上的后果是悬空菜单:菜单是清单声明的,所以全新安装也会照建, 点开只得到控制台一句「component 解析不出来,已跳过该路由」。hello 历史上就踩过 (清单写 remote:hello/hello/list,而前端从没注册过 hello/list)。
3.2 routes 为什么留空
manifest.routes 是另一个东西:它是远程应用自己往宿主 router 上注册的顶级路由。 宿主的 loader.ts 在路由没有 parentName 时就 router.addRoute(route) —— 注册出来的是 没有 Layout 的裸路由。
而菜单管线会为同一个 path 再生成一条包在 Layout 里的路由。两条路径相同,裸的那条抢先匹配, 表现就是页面渲染出来了,但左侧菜单、顶栏、标签页全没了。
唯一的例外是
hello-app:它故意保留了三条/remote-demo/**路由(hello的后端清单给页面 用的path是hello/probe这类相对路径,拼出来的不是/remote-demo,恰好没撞上)。 它是"清单自己带路由"这个用法的活样例;照着它抄之前先确认你的菜单path会不会撞。
路径、标题、图标、权限本来就该由菜单系统统一负责,清单只需提供 views 的映射。
四、共享构建工厂 frontend/build/vite.ts
各工程的 vite.config.ts 只有二十来行,只声明真正不同的东西:
ts
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineRemoteApp } from '../build/vite.js';
const root = path.dirname(fileURLToPath(import.meta.url));
export default defineRemoteApp({
appCode: 'gallery',
root,
useSharedRequest: true,
// ⚠️ 只列**仍需从宿主 SDK 取**的组件,且每个应用各不相同
hostSdkComponents: ['KCard', 'KDictTag', 'KTable', /* ... */],
hostAliases: []
});| 选项 | 说明 |
|---|---|
appCode | 产物目录名,必须等于后端 app.json 的 code |
root | 工程根(vite.config.ts 所在目录) |
hostSdkComponents | 仍要从宿主 SDK 取的组件白名单,刻意没有默认值 |
hostAliases | 额外需要指向宿主 src 的 @/xxx |
useSharedRequest | 把 @/utils/request 精确改写成 @dromara/request |
standalone | 完全不依赖宿主源码(hello-app 用了它) |
outDir | 覆盖产物目录;不传则由 WUJIES_FRONTEND_DIST 决定 |
extraExternals / extraPlugins | 少数应用需要时追加 |
工厂固定做了这些事(别在工程里再抄一遍):
| 内置 | 内容 |
|---|---|
| 插件顺序 | hostSdkShim → autoImport → Components → vue |
| 共享 external | vue / vue-router / pinia / element-plus / @dromara/request / @dromara/components / @dromara/modal |
publicDir: false | 否则会把宿主 public 复制进产物 |
define | process.env.NODE_ENV 静态替换成 "production" |
build.lib | 入口 src/index.ts,ESM,文件名固定 index.js |
build | emptyOutDir: true、minify: false、sourcemap: true |
| 资源命名 | assets/[name][extname] |
4.1 共享依赖一律 external
external 掉的裸模块说明符,由宿主的 import map 在运行期映射到运行时垫片, 垫片再转发到 window.__APP_MARKET_RUNTIME__ 上宿主正在用的那一份实例 (plus-ui/index.html 里声明 import map,垫片由 npm run gen:runtime 生成到 public/app-runtime/)。
这就是"双 Vue / 双 Element Plus"问题的正解:不是避免重复打包,而是根本只有一份实例。
不要把 Element Plus 解析成子路径
工厂的 el-* 解析器刻意返回裸 'element-plus',不用 ElementPlusResolver —— 后者生成 element-plus/es/components/xxx/index 这类子路径,不在 external 列表里, Rollup 会把第二份 Element Plus 打进产物,于是弹窗挂到错误的 app 上下文、样式重复。
同理,插件不要引 Element Plus 的样式:宿主已经全量引过, 再引一份反而会用默认 CSS 变量覆盖宿主主题。
4.2 产物里混着宿主源码,所以别名要逐条声明
远程产物会把宿主源码(@/utils/**、@/store/**、@/api/system/**)按别名原样打进来, 所以构建期必须能读到宿主的 src。别名清单在工厂的 HOST_MODULE_ALIASES (frontend/build/vite.ts),规则有两条:
- 重叠目录只能按具体子路径声明归属。宿主和插件都有
src/api/—— 把@/api整目录指给任何一边,另一边的模块就找不到了(实测两种都炸过)。 所以写@/api/system、@/api/login,而不是@/api。 - 只在宿主里真的存在时才注册(
hostModuleExists会连.ts/.tsx/.js/.vue/.json后缀一起探)。不存在就不注册,于是"真写错了路径"仍然正常报错, 不会因为顺手加了个别名而静默指向空目录。
@ 始终指向本工程 src,并且必须排在别名列表最后。
plugins/appmarket/** 刻意不在这份清单里
那是宿主"应用市场前端"的内部实现。把它指过去等于让插件又依赖宿主的市场模块,与"插件可独立"矛盾。
4.3 别写"薄垫片"绕 SDK
@/utils/request 用正则精确匹配改成 @dromara/request:
ts
// 工厂里就是这么写的
{ find: /^@\/utils\/request$/, replacement: '@dromara/request' }不能写成字符串 '@/utils/request' —— 字符串 alias 是前缀匹配, 会把 @/utils/requestCache 一起吃进去(@/utils/request + Cache),构建直接失败。
⚠️ 也不要在插件里自己复刻一份 src/utils/request.ts:那种垫片会 import { getToken } from '@/utils/auth',等于把宿主源码拉进产物(im 就是这么炸的)。
4.4 宿主组件白名单
宿主里 KCard / KTable / KSearchBar 这类组件是靠 unplugin-vue-components 扫 src/components 构建期自动注册的,插件工程刻意没有打开目录扫描 (Components({ dirs: [] }) —— 否则会把宿主整个 src/components 拉进产物)。 于是模板里"只在标签上用、没有显式 import"的 <KTable> 就成了未注册标签。
Vue 对未注册标签不报错、不白屏:Failed to resolve component 只是一条警告, 页面只剩插槽里的文字。实测症状:exam 的「新增 / 折叠 / 删除」按钮变成了纯文字 (<KPlainButton> 不在白名单里)。
所以 hostSdkComponents 必须按实际用到逐项声明,它对应 plus-ui/src/plugins/appmarket/runtime.ts 里 SHARED_COMPONENTS 的键名。 注意两点:
- 不能图省事求并集。组件一旦随插件搬进本工程,就必须从白名单里摘掉 —— 否则会被转发到
@dromara/components,而那里已经没有这个导出了(实测:图床的BatchUpload/ImageViewer/WaterfallLayout/ImageDetail搬进来后,白名单没摘干净就白屏)。 - 宿主在
plus-ui/src/plugins/components.ts里app.component(...)全局注册过的名字 (DictTag/Pagination/RightToolbar/FileUpload/SvgIcon等)不需要进白名单 —— 远程组件挂在宿主 app 实例上渲染,全局组件解析得到。
npm run check:remote 会拿产物里实际用到的标签名与白名单、SHARED_COMPONENTS 对账。
4.5 自动导入必须与宿主逐项一致
宿主开了 unplugin-auto-import(plus-ui/vite/plugins/auto-import.ts: vue / vue-router / pinia / @vueuse/core + Element Plus resolver), 所以宿主源码里 ref(...) / useStorage(...) 没有 import 语句 —— 而这些源码会被打进远程产物。工厂的 autoImport() 已经按同一份清单配好了。
少一项的后果是运行期 ReferenceError、整个应用装载失败(菜单点开是「远程视图不可用」)。 实测:im 的产物里混进宿主 src/utils/auth.ts,它用 useStorage,而工程 imports 里没有 @vueuse/core → ReferenceError: useStorage is not defined。构建期不报错 (Rollup 把它当全局变量),所以只能靠守门脚本兜。
4.6 路径判断用正则,不要字面量
hostSdkShim 里识别 @/plugins/modal 与 @/router 的兜底路径判断,必须写成 /[/\\]plugins[/\\]modal$/ 这种正则。Vite 的 alias 解析在 Windows 上产出的是 C:\...\app\src/plugins/modal(src 前是反斜杠),字面量比对本就永远不命中。
五、运行时注入
宿主装载时执行 install(runtime),runtime 是 window.__APP_MARKET_RUNTIME__ 上的那个对象 (宿主 main.ts 调 installAppMarketRuntime() 挂上去)。 视图组件不 import 宿主的任何源码,只从本地持有者读它:
ts
// src/runtime.ts(照抄 wujies/wujies-plugins/frontend/hello-app/src/runtime.ts)
let remoteRuntime: any = null;
export function setRemoteRuntime(runtime: any): void {
remoteRuntime = runtime;
}
export function getRemoteRuntime(): any {
if (!remoteRuntime) {
throw new Error('[hello] 宿主运行时尚未注入,请确认应用通过 install(runtime) 装载');
}
return remoteRuntime;
}抛错而不是静默返回 undefined —— 明确提示"宿主运行时未安装"比让它以 undefined.push 的形式报错好排查得多。
运行时上有什么,权威定义在宿主 plus-ui/src/plugins/appmarket/types.ts 的 AppMarketRuntime:
| 字段 | 内容 |
|---|---|
vue / router / pinia / elementPlus | 宿主正在用的命名空间(注意是命名空间,不是 Vue 实例) |
request | 宿主 axios 实例,已带 token / 租户头 / 统一错误处理(可调用,且挂了 globalHeaders 等具名导出) |
routerInstance | 宿主 vue-router 实例 |
components | 共享 UI 组件字典(= SHARED_COMPONENTS) |
modal | 宿主 modal 插件(msgSuccess / confirm / loading 等) |
app | 宿主 Vue 应用实例 —— 注册指令要用它 |
platformVersion | 平台版本,可据此做兼容降级 |
5.1 视图里 import router from '@/router' 怎么办
两种都可以:
- 由工厂的
hostSdkShim把@/router改写成globalThis.__APP_MARKET_RUNTIME__.routerInstance(推荐,不用自己写); - 或在本工程写一个
src/router.ts垫片,形态照抄wujies/wujies-plugins/frontend/gallery-app/src/router.ts(同样抛错而不是返回undefined)。
⚠️ 别给 @/router 加指向宿主的别名 —— 那会把宿主整个 router(连 layout/index.vue、 views/login.vue)拉进产物。
宿主侧垫片的导出面必须覆盖产物真正 import 的名字
产物把 vue / element-plus / @dromara/* external 掉,运行期由 import map 指到运行时垫片。 垫片少一个具名导出,浏览器在链接期整块报错、页面完全打不开:
text
SyntaxError: The requested module '@dromara/request' does not provide an export named 'globalHeaders'vue / element-plus 的垫片是自动枚举的;@dromara/* 是虚拟说明符、只能静态声明 —— 所以要加一个具名导出,得同时改两处:plus-ui/scripts/gen-runtime-shims.mjs 的 staticNames 与 runtime.ts 里对应 slot 挂上去的名字。改完跑 npm run check:runtime 对账。
六、用宿主没有全局注册的指令
组件能在构建期解决(工厂的 el-* 解析器把 <el-table> 转成具名 import), 但指令不能:v-loading 被编译成运行期的 resolveDirective("loading"), 它只在 app 的全局指令表里查。
宿主没有 app.use(ElementPlus)(按需导入),也没有 app.directive('loading', ...), 所以应用要自己在安装时补注册 —— 范例见 gallery-app/src/index.ts:
ts
const elLoading = runtime?.elementPlus?.ElLoadingDirective;
if (elLoading) {
runtime.app.directive('loading', elLoading);
}漏了不报错,只是转圈永远不出现。只注册自己真正用到的,别替宿主做决定。
hello-app 里还有一份 app.json
它在 wujies/wujies-plugins/frontend/hello-app/app.json,与后端插件目录里的那份是近重复: 实测两份目前除前端那份没有 assets.backend 外基本一致(版本都是 1.0.0,三个 C 菜单 各带一个 F 按钮)。⚠️ 别把它与 src/index.ts 里的前端 manifest.version 混起来 —— 0.1.0 是那个的值,它不被服务端校验(见第三节),与 app.json 无关。 改菜单时两份 app.json 一起改。
七、本地联调:产物要落到宿主认的目录
宿主 dev server 只从 <宿主根>/app-market-dist/remote-apps/ 提供 /remote-apps/** (plus-ui/vite/plugins/appMarketStatic.ts)。产物不在那里,就落到 Vite 的 SPA 回退上, 浏览器把它当 index.html 解析,控制台报:
text
Failed to fetch dynamically imported module: /remote-apps/<code>/index.js两种办法:设 WUJIES_FRONTEND_DIST(见第二节),或直接把产物指到宿主目录 (打包时 package.ps1 就是这么做的,它会自己设这个变量,并调用 pnpm --filter @wujies-plugins/<code>-app run build)。
宿主解析入口的顺序是静态清单优先(VITE_APP_MARKET_REMOTE_APPS 环境变量, 开发环境另有内置的 demo/gallery),其余交给后端 GET /app/runtime/remote-apps, 一次会话只问一次、失败不缓存。
同版本号下浏览器可能吃缓存,必要时硬刷新。完整的联调流程见 本地联调。
八、打包与构建后自检
在插件仓库根打包(脚本会自己构建前端、组装 frontend/、剔除 sourcemap、签名):
powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\package.ps1 `
-Plugin wujies-hello -SigningKey keys/private.pem单独构建某个工程:
powershell
cd wujies\wujies-plugins
pnpm --filter @wujies-plugins/hello-app run build8.1 构建后核对四件事
| # | 核对 | 写错的后果 |
|---|---|---|
| 1 | 入口文件名与 assets.frontend 一致(frontend/index.js 与 index.js 等价) | 服务端把入口拼成 /remote-apps/<code>/<文件名>,对不上就是 404 → 菜单点开是「远程视图不可用」 |
| 2 | manifest.styles 与产物里实际的 CSS 文件名一致 | 不报错,只是页面没样式 |
| 3 | 产物目录名 == appCode | 菜单永远解析不出来 |
| 4 | 产物里没有 .map 残留(打包时剔除,单独构建时会生成) | 包体变大 |
第 1 条只需要文件名对:落位时脚本按 assets.frontend 的目录前缀(frontend/)整棵释放, 入口再取该路径的文件名 —— 所以 frontend/index.js 与 index.js 都能跑。
CSS 的实际文件名由工程 package.json 的 name 决定,落在 assets/ 下 —— gallery-app 产出 assets/gallery-app.css。 manifest.styles 要写宿主 URL 空间里的路径, 例如 /remote-apps/gallery/assets/gallery-app.css。核对办法是直接看已构建的其他工程的 dist/assets/,别凭记忆写。
8.2 守门脚本(在宿主仓库 plus-ui/ 里跑)
| 命令 | 检查什么 |
|---|---|
npm run check:remote | 模板里的宿主组件能否解析 + 白名单名字是否真在 SHARED_COMPONENTS 里 + 产物里有没有 process.env 残留 + manifest.styles 指向的文件是否存在 |
npm run check:globals | 产物里有没有"宿主自动导入宇宙"里未绑定的标识符 |
npm run check:runtime | 远程产物真正 import 的名字与运行时垫片导出对账 |
npm run audit:menus | 库里所有 C 菜单的 component 能否落到真实页面(含 remote:<code>/<viewKey>) |
它们默认从插件仓库的 frontend/(宿主侧按 plus-ui/../wujies/wujies-plugins/frontend 推导)找工程,也可以用 WUJIES_FRONTEND_DIR 覆盖。
check:globals 也是验尸工具
npm run check:globals -- --dir=<某个解包后的 frontend 目录> 可以直接复查已发布的历史包,不用重新构建。
相关阅读
- 跟着 hello 读源码 —— 范例应用的五个演示点与真实文件名
- 应用清单 app.json ——
assets与menus的权威字段表 - 本地联调
- 插件运行时(架构) —— 宿主那一侧的装载机制
- 故障排查