Skip to content

前端远程应用 ​

这一页解决一件事:把插件的 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
共享 externalvue / vue-router / pinia / element-plus / @dromara/request / @dromara/components / @dromara/modal
publicDir: false否则会把宿主 public 复制进产物
defineprocess.env.NODE_ENV 静态替换成 "production"
build.lib入口 src/index.ts,ESM,文件名固定 index.js
buildemptyOutDir: 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),规则有两条:

  1. 重叠目录只能按具体子路径声明归属。宿主和插件都有 src/api/ —— 把 @/api 整目录指给任何一边,另一边的模块就找不到了(实测两种都炸过)。 所以写 @/api/system、@/api/login,而不是 @/api。
  2. 只在宿主里真的存在时才注册(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 build

8.1 构建后核对四件事 ​

#核对写错的后果
1入口文件名与 assets.frontend 一致(frontend/index.js 与 index.js 等价)服务端把入口拼成 /remote-apps/<code>/<文件名>,对不上就是 404 → 菜单点开是「远程视图不可用」
2manifest.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 目录> 可以直接复查已发布的历史包,不用重新构建。

相关阅读 ​

我们坚信,即使再复杂的技术,也可以用清晰、干练、易懂的文字描述清楚。如果你在阅读时有难以理解的章节,那一定是我们还没有优化好它 —— 欢迎反馈。