Wujies 无界平台
平台功能(自研)架构设计

前端远程应用(宿主侧)

后端那一半解决的是「插件 jar 怎么装进来」(见 插件运行时); 这一页解决另一半:点了这个菜单,宿主该去加载哪份 JS,以及那份 JS 凭什么能在宿主上下文里跑起来。

如果你要写一个远程应用(建工程、写入口契约、构建产物),看 前端远程应用工程;这一页讲的是宿主做对了什么 —— 改 plus-ui/src/plugins/appmarket/**、排查「菜单点开是远程视图不可用」、 或核对某条断言该信哪份文件时读它。

三个名词先对齐
  • 应用编码 appCode:app.json 的 code,同时是产物目录名、落位目录名、入口 URL 里那一段;
  • 视图键 viewKey:应用 views 字典的键,形如 demo/home;
  • 入口 entry:产物里那个 ESM 文件的 URL,形如 /remote-apps/hello/index.js?v=1.0.0。

一、整条链路

后端 app.json 的 menus[].component = "remote:<appCode>/<视图键>"
    │  AppMenuPlanner 幂等 upsert 进 sys_menu.component(只是个字符串)
    ▼
前端登录后拉菜单树 → permission.ts 的 loadView()
    │  命中 remote: 前缀 → parseRemoteView() 拆出 appCode 与视图键
    ▼
createRemoteViewComponent(视图键, appCode)      ← 只返回一个懒加载函数,此刻还没有 JS
    │  路由真正渲染该视图时:本地视图索引里没有 → 问入口解析器要 entry
    ▼
GET /app/runtime/remote-apps                    ← 本租户已启用应用的入口清单
    │  entry = /remote-apps/<appCode>/<文件名>?v=<版本号>
    ▼
loadRemoteApp(entry):import(entry) → 调用安装函数(runtime) → 拿到 { manifest, views }
    │  索引视图键 → 注册路由 → 注入样式
    ▼
渲染出真正的页面

每一段落在哪个文件:

环节实现
菜单声明 componentwujies/wujies-plugins/wujies-<app>/app.json 的 menus[](范例见 wujies-hello/app.json)
前缀常量(前端)plus-ui/src/plugins/appmarket/constants.ts 的 REMOTE_VIEW_PREFIX = 'remote:'
前缀常量(后端)wujies-platform/wujies-modules/wujies-app-market/.../constant/AppMarketConstants.java 的 REMOTE_VIEW_PREFIX = "remote:"
视图键解析plus-ui/src/plugins/appmarket/loader.ts 的 parseRemoteView()
挂到路由plus-ui/src/store/modules/permission.ts 的 filterAsyncRouter() → loadView()
入口清单 API.../controller/AppRuntimeController.java 的 GET /app/runtime/remote-apps
产物落位.../service/AppPackageDeployService.java
装载与注册plus-ui/src/plugins/appmarket/loader.ts、runtime.ts、bootstrap.ts

二、视图键:remote:<appCode>/<视图键>

解析规则很直白(loader.ts 的 parseRemoteView()):去掉 remote: 前缀后, 第一段(到第一个 / 为止)是 appCode,剩下全部是视图键 —— 视图键自己可以带 /。

菜单里的 component解析结果
remote:hello/demo/homeappCode hello,视图键 demo/home
remote:exam/exam/settingsappCode exam,视图键 exam/settings
remote:hello非法:没有 / → 打一条 WARN,loadView() 返回 undefined
remote:/home非法:appCode 为空
remote:hello/非法:视图键为空

视图键必须与该应用 src/index.ts 里 views 对象的键逐字符一致 (wujies-hello/app.json 的 remote:hello/demo/home 对 hello-app/src/index.ts 的 views['demo/home'] 就是一组对照)。

两种「点不开」要分清

它们的文案、根因、修法都不同,别混:

现象什么时候发生根因修法
控制台 [permission] 菜单「X」的 component 解析不出来,已跳过该路由:remote:…构建路由树时(登录/刷新),loadView() 返回 undefined视图键格式非法(上表后三行)。permission.ts 会把这条路由整个跳过 —— 侧边栏可能还有菜单,点开是空白改 app.json 的 component
控制台 远程视图不可用: <视图键>(应用 X 未安装或入口地址未配置)渲染该视图时,懒加载函数抛错格式合法,但① 本租户没装/没启用这个应用,或② 产物在但里面没有这个视图键先看 GET /app/runtime/remote-apps 有没有它,再让该应用升级一次(升级顺带跑菜单同步)
视图键对不上是「悬空菜单」,全新安装也会照样建出来

菜单由清单声明,所以 app.json 里多写一条不存在的视图键,装到任何一台机器上都会生成这条坏菜单; 它只在点开时暴露,构建期一路绿灯。npm run audit:menus 就是为此存在的体检工具(见第九节)。

三、产物落位:/remote-apps/<appCode>/

宿主的 /remote-apps/** 是磁盘上的一个目录,不是某个接口。落位由 AppPackageDeployService 在部署/安装/升级时完成:

包内落位后
assets.backend 那个 jar<pluginDir> 根下(loader.path 不递归子目录)
assets.frontend 的目录前缀整棵(通常是 frontend/)<frontendDir>/<appCode>/,去掉前缀
migrations/*.sql<pluginDir>/migrations/<appCode>/(落位前先清空该目录)

所以包内是 frontend/index.js,落位后就是 <frontendDir>/<appCode>/index.js —— 前缀被剥掉,文件名保留。这正是入口只取文件名的原因(下一节)。

项值在哪
配置键app-market.deploy.frontend-dirwujies-platform/wujies-admin/src/main/resources/application.yml
yml 当前值../wujies-admin/app-market-dist/remote-apps同上(按后端工作目录解析,指向工作区顶层的 wujies-admin/)
注解兜底值../plus-ui/app-market-dist/remote-appsAppPackageDeployService 的 @Value 默认值,与 yml 不一致
dev server 读的位置<plus-ui 仓库根>/app-market-dist/(不可配置)plus-ui/vite/plugins/appMarketStatic.ts 的 sourceDir
前后端分离部署必须显式改 frontend-dir

落位目录与前端服务读的目录是两个物理位置。单体 dev 下它们恰好都指向前端仓库时看不出问题; 一旦分离部署(或照 yml 当前值跑在本机),后端把产物释放到自己的磁盘,前端服务读的是另一份 —— 现象是「安装成功、菜单点开是远程视图不可用」,控制台报 Failed to fetch dynamically imported module: /remote-apps/<code>/index.js。 完整推导与容器卷的做法见 前端部署与 nginx、配置项索引。

目录名必须等于 appCode(四处同一个约定)

#地方谁写
1前端产物目录defineRemoteApp({ appCode }) → resolveOutDir()(wujies/wujies-plugins/frontend/build/vite.ts);设了 WUJIES_FRONTEND_DIST 时是 <它>/<appCode>
2打包时找产物package.ps1 的 -FrontendDist 默认值 app-market-dist/remote-apps/$appCode
3后端落位目录AppPackageDeployService 的 <frontendDir>/<appCode>/
4入口 URLAppRuntimeController.buildEntry() 拼出的 /remote-apps/<appCode>/…

外加菜单里的 component = remote:<appCode>/… —— 一共五处是同一个名字。 历史上 demo-app 的 outDir 写成了 remote-apps/demo(少个 -app),后果是库里两条 remote:demo-app/demo/* 菜单永远解析不出来。该应用现已从应用目录里去掉, 示例应用改由 hello 承担(见 示例应用)。

本地联调怎么把产物落到 dev server 认的目录(WUJIES_FRONTEND_DIST)、 以及「后端落位目录 ≠ dev server 目录」这一条的两种解法,见 本地联调。

四、入口清单:GET /app/runtime/remote-apps

宿主在构建期不可能知道租户装了哪些应用(这正是「应用外置」的意义),所以入口只能运行期问服务端。 AppRuntimeController.remoteApps() 的答复体是 AppRemoteEntryVo 列表:

[
  { "appCode": "hello", "version": "1.0.0", "entry": "/remote-apps/hello/index.js?v=1.0.0" }
]

拼接公式就在 buildEntry() 里:

entry = "/remote-apps/" + appCode + "/" + assets.frontend 的「文件名」 + "?v=" + 版本号

只取文件名:落位时剥掉的是 assets.frontend 最后一个 / 之前的全部(dirPrefix()), 所以文件名就是落位后的相对路径。因此 frontend/index.js 与 index.js 两种写法等价。

接口有五条刻意的设计,每一条都对应一类踩过的坑:

设计原因
只列 AppInstallStatus.ENABLED(enabled)的安装记录停用/卸载的应用不该被装载
入口带 ?v=<版本号>不带的话升级后浏览器吃旧缓存,表现是「升级成功了、页面还是老样子」
不加权限注解看得到应用菜单的用户不一定有市场权限(菜单按角色授权),而这里返回的信息与他自己菜单里的等价 —— 否则「能看到菜单却打不开页面」又回来了
纯后端应用不出现清单没写 assets.frontend 时 buildEntry() 返回 null,直接跳过(不是故障)
单条坏数据只 WARN 跳过目录里查不到那个版本时 ServiceException 被吞成一条日志,不让整份清单为空 —— 否则一个应用出错会让所有应用打不开

目录读取走 IAppCatalogService(安装侧的规矩),不是直接注入 Mapper —— 远程目录模式下 本地根本没有 app_version / app_info。

相邻接口:GET /app/runtime/enabled-apps

同样不加权限注解,返回本租户已启用应用的编码列表(["member","mall"]), 不要求有前端产物。它回答的是另一个问题:「这个应用现在能不能用」—— 工作台的签到卡片调 member 接口前先 await isAppEnabled('member'),不该发的请求就不发。 前端封装在 plus-ui/src/plugins/appmarket/appAvailability.ts (一次会话只问一次、按 token 失效、并发去重、取不到清单时一律当「可用」)。 两者都定义在 plus-ui/src/api/appstore/runtime/index.ts。

前端侧的解析与缓存(bootstrap.ts)

入口有两个来源,刻意分优先级:

  1. 静态清单优先 —— VITE_APP_MARKET_REMOTE_APPS,格式 code=url,code2=url2 (只写 url 时只能预加载、不能按需装载)。三个 .env.* 文件里都没有定义它, 临时指向别的产物时才写,改完要重启 dev server;
  2. 后端 GET /app/runtime/remote-apps —— 正式路径。

开发环境在没有静态清单时另有内置兜底 POC_REMOTE_APPS(demo-app / gallery 两条); ⚠️ 其中 demo-app 已从应用目录里去掉、它的前端工程也早已并入 hello (wujies-plugins/frontend/ 下没有 demo-app),所以这一条内置声明是死条目, 只影响 dev 下的控制台输出。它写在 plus-ui/src/plugins/appmarket/bootstrap.ts 里, 等 hello 的产物构建出来之后应当把它换成 hello。

缓存策略:后端答复一次会话只问一次,并发去重;失败不缓存(刷新或下次点击还能重试); 预加载时单个应用失败不影响其它应用。

五、宿主怎么装载(loader.ts)

loadRemoteApp(entry) 用运行期 import()(显式带 /* @vite-ignore */,不参与宿主打包), 同一个 entry 并发调用复用同一个 Promise。拿到模块后:

  1. 取 module.default ?? module.install 作为安装函数,调用它并传入宿主运行时;
  2. 校验 manifest 的必填字段 code / name / version,且 routes 必须是数组;
  3. 索引视图键(views 的键):与别的应用冲突时 WARN 并覆盖;
  4. 注册路由(manifest.routes):meta 固定带 { appCode, remote: true }; 有 parentName 且该父路由存在时挂到父路由下,否则注册为顶级路由; def.view 不在 views 里时直接抛错(应用 X 的路由 Y 引用了未声明的视图 Z);
  5. 注入远程应用自带的样式 <link data-app-market="<code>">(ESM 产物里的 CSS 不会随 import() 生效)。

同一个应用重复加载(升级)时先 unloadRemoteApp 再装。views 的加载器必须返回 { default: Component },否则抛 视图 <键> 的加载器必须返回 { default: Component }。

「路由先建、应用后到」

菜单渲染时远程应用可能还没加载,所以 createRemoteViewComponent() 返回的是懒加载函数: 渲染时若视图未注册,先通过入口解析器取入口、按需装载,再解析组件。 宿主静态路由也用它 —— plus-ui/src/router/index.ts 里 exam / mall / member / enterprise 的 详情页、编辑页(如 /enterprise/company/detail/:id)就是这么挂的,不依赖菜单。

装载失败时不吞异常:ensureViewLoaded() 会把原始原因包成 RemoteAppLoadError 抛出去, 让调用方看到的是「产物拉不到」而不是含糊的「未安装」; buildLoadFailureHint() 还会针对 Failed to fetch dynamically imported module 直接给出 app-market-dist/remote-apps/<appCode>/ 与 WUJIES_FRONTEND_DIST 的提示。

刻意不用 defineAsyncComponent()

两个原因:

  1. 路由级异步组件应写成 () => import('./X.vue') 那种加载器函数;传 defineAsyncComponent() 包装器会触发 Vue Router 告警,且路由无法预取 / 提取守卫;
  2. 它只对真正的 ES 模块解包 .default(靠 __esModule / Symbol.toStringTag === 'Module'), 而远程应用契约返回的是普通对象 { default: Component },会被当成组件本体 → Component is missing template or render function,渲染空白。所以这里自己解包。

loader.ts 另外导出 hasRemoteView() / listRemoteApps() / unloadRemoteApp() / waitForRemoteView(viewKey, timeoutMs)(默认 REMOTE_VIEW_WAIT_TIMEOUT = 8000,供宿主页面等待注册完成; 菜单主链路不走它)。

六、共享依赖:import map + 运行时垫片

远程产物把 vue / vue-router / pinia / element-plus / @dromara/* 全部 external 掉, 运行期由宿主 plus-ui/index.html 的 import map 指向一组垫片:

/app-runtime/vue.js             /app-runtime/dromara-request.js
/app-runtime/vue-router.js      /app-runtime/dromara-components.js
/app-runtime/pinia.js           /app-runtime/dromara-modal.js
/app-runtime/element-plus.js

垫片是标准 ESM,把自身导出的每个名字转发到宿主运行时对象上的同名属性 —— 于是远程应用的 import { ref } from 'vue' 拿到的就是宿主那一份 Vue(「双 Vue 实例」问题的正解)。

  • 生成:plus-ui/scripts/gen-runtime-shims.mjs,产物落在 plus-ui/app-market-dist/app-runtime/ (publicDir 之外,由 appMarketStatic.ts 以 /app-runtime/ 前缀提供;挂在 predev / prebuild:* 上, 也可手动 npm run gen:runtime)。 ⚠️ index.html 的注释与 前端远程应用工程 都写成 public/app-runtime/*.js, 那是过时的路径,以脚本里的 OUT_DIR 为准;
  • 宿主侧把实例挂到 globalThis.__APP_MARKET_RUNTIME__:plus-ui/src/plugins/appmarket/runtime.ts 的 installAppMarketRuntime()(main.ts 里调用);
  • 共享 UI 组件字典 SHARED_COMPONENTS(KCard / KTable / ImageUpload / … 共 13 个) 与 @dromara/components 垫片的导出名必须完全一致。
垫片少一个具名导出,整个应用在链接期就炸

实测报错:

SyntaxError: The requested module '@dromara/request' does not provide an export named 'globalHeaders'

vue / element-plus 的垫片会自动枚举导出;@dromara/* 是虚拟说明符(Node 里 import 不到), 只能静态声明。所以加一个具名导出要同时改两处:

  • plus-ui/scripts/gen-runtime-shims.mjs 里对应 target 的 staticNames;
  • plus-ui/src/plugins/appmarket/runtime.ts 里对应 slot 挂上去的名字(request 槽当前挂 download / globalHeaders / isRelogin,用的是 Object.assign(request, …), 因为产物里既有默认导入也有具名导入)。

改完跑 npm run check:runtime 对账(拿产物真正 import 的名字去比垫片导出)。

七、自动导入清单必须与宿主逐项一致

远程产物里混着宿主源码(@/utils/**、@/store/**、@/api/system/** 被别名原样打进产物), 而宿主开了 unplugin-auto-import,所以宿主源码里 ref(...) / useStorage(...) 没有 import 语句。 插件工程少列一个 preset,它们在产物里就是裸全局 —— 构建期不报错(Rollup 当它是全局变量), 一装载就 ReferenceError。

两处清单:

位置内容
宿主 plus-ui/vite/plugins/auto-import.tsimports: ['vue', 'vue-router', '@vueuse/core', 'pinia'] + ElementPlusResolver()
插件工程共享工厂 wujies/wujies-plugins/frontend/build/vite.ts 的 autoImport()imports: ['vue', 'vue-router', 'pinia', '@vueuse/core', { 'element-plus': ['ElMessage', 'ElMessageBox', 'ElNotification', 'ElLoading'] }]

四个 preset 必须一致。Element Plus 那几个函数宿主是靠 ElementPlusResolver() 按需注入的(还带样式), 而插件工程不能用那个 resolver(它生成 element-plus/es/components/* 子路径导入,不在 external 列表里, 会把第二份 Element Plus 打进产物),所以工厂把这四个名字静态列出来。

实测:ReferenceError: useStorage is not defined

im 的 ImageUpload 用了 @/utils/propTypes、@/store/modules/user,又带进宿主 src/utils/auth.ts, 而 auth.ts 用的是 @vueuse/core 的 useStorage。im 工程 imports 里当时没有它 → 整个应用装载失败,菜单点开是「远程视图不可用」。各插件工程的 imports 现已全部对齐 (wujies-platform/AGENTS.md §2.8)。

守门脚本:npm run check:globals(ESLint no-undef 扫已构建产物)。 它枚举的「宿主自动导入宇宙」是硬编码在脚本里的两组名字: HOST_PRESETS = ['vue', 'vue-router', 'pinia', '@vueuse/core'] 与 HOST_RESOLVER_NAMES = ['ElMessage', 'ElMessageBox', 'ElNotification', 'ElLoading'] (plus-ui/scripts/check-remote-globals.mjs)。第三方库的环境探测写法(wx / Buffer 等)只打印不判定。 它也是「验尸」工具:npm run check:globals -- --dir=<解包后的 frontend 目录> 可以直接复查已发布的历史包,不用重新构建。

别在插件里写「薄垫片」绕 SDK

自己复刻一份 src/utils/request.ts 并 import { getToken } from '@/utils/auth', 正是把宿主源码拉进产物的那条路径(im 就是这么炸的)。工程配置用 useSharedRequest: true 即可把 @/utils/request 正则精确改写成 @dromara/request —— 字符串别名是前缀匹配,会把 @/utils/requestCache 一起吃掉。

八、卸载与「应用不可用」

卸载/升级时 unloadRemoteApp(appCode) 做三件事:移除 addRoute 句柄返回的路由、 清掉该应用的视图索引、移除它注入的样式 <link>。 不触碰已加载的 JS 模块本身(浏览器没有卸载模块的能力)。

应用被停用/卸载后,它注册的路由被摘掉,但前端手里的页面可能还在调它的接口。宿主给出可区分的提示:

环节实现
认出「这条路径曾经属于谁」PluginHandlerMapping 的路由墓碑 + appCodeFor(path)(卸载时不清)
结合状态措辞AppRouteHintResolver → 已停用 / 未安装或已卸载 / 已启用但产物没加载
业务码AppRouteHintProvider.CODE_APP_UNAVAILABLE = 4610(HTTP 仍是 404 语义)
前端提示plus-ui/src/enums/RespEnum.ts 的 APP_UNAVAILABLE = 4610 + utils/request.ts 里同一应用的消息只提示一次

⚠️ 应用正在跑却 404 → 保持原文案,那是它自己的路径 bug,不能被「应用不可用」盖掉。

治本的做法是别让它发生:宿主页面调某个应用的接口前先 if (!(await isAppEnabled('member'))) return;(appAvailability.ts,见第四节)。

九、发布前的自查命令

改完远程应用或宿主运行时,在 plus-ui 下按需跑(都是 Node 脚本,不启动任何服务):

命令查什么什么时候必跑
npm run gen:runtime生成 /app-runtime/*.js 垫片(predev / prebuild:* 会自动跑)改了 runtime.ts 的 slot 或 gen-runtime-shims.mjs 的 staticNames
npm run check:runtime产物真正 import 的名字 vs 垫片导出,缺口非零退出加了新的具名导入(尤其 @dromara/*)
npm run check:globals产物里有没有「宿主自动导入宇宙」里未绑定的标识符每次重建远程产物;--dir= 复查历史包
npm run check:remote模板组件能否解析(显式 import / hostSdkComponents 白名单 / 宿主 app.component 全局注册三条路)+ 白名单名字是否真在 SHARED_COMPONENTS 里 + 产物有无 process.env 残留 + manifest.styles 指的 CSS 是否真的存在每次重建远程产物
npm run audit:menus -- <菜单导出文件>库里所有 C 菜单的 component 能否落到真实页面(含 remote:<code>/<viewKey>)出现「菜单点不开」或发布前体检

check:remote / check:globals 默认从插件仓库的 frontend/(按 plus-ui/../wujies/wujies-plugins/frontend 推导)找工程,可用 WUJIES_FRONTEND_DIR 覆盖;两者都只读 plus-ui/app-market-dist/remote-apps/ 下已构建的产物。

打包(在插件仓库根,脚本会自己构建前端、组装包内 frontend/、剔除 sourcemap、签名):

cd wujies\wujies-plugins
powershell -NoProfile -ExecutionPolicy Bypass -File .\package.ps1 `
    -Plugin wujies-hello -SigningKey keys/private.pem

单独构建某个工程:pnpm --filter @wujies-plugins/hello-app run build。 assets.frontend 由 app.json 声明;纯前端应用(只写 assets.frontend、不写 assets.backend)也是合法形态, package.ps1 会跳过后端构建。

十、C 端整站不在这条链路上

历史上这一页还写过「C 端整站」(assets.cWeb / app-market.deploy.c-frontend-dir / /apps-c/ 前缀 / GET /app/runtime/c-entries 清单接口)。当前平台源码里没有这些键与接口: application.yml 没有 c-frontend-dir、wujies-app-market 下没有任何 @Value 读它、 AppRuntimeController 只有 /remote-apps 与 /enabled-apps 两个端点、 appMarketStatic.ts 的 URL 前缀列表只有 /app-runtime/ 与 /remote-apps/。

所以别照着做。若确实要 C 端形态,它属于新功能设计,不是「照文档接线」—— reference/config.md 里那一节也仍未同步(见 配置项索引 与 部署配置项 的说法不一致)。

相关阅读

目录