前端远程应用(宿主侧)
后端那一半解决的是「插件 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 }
│ 索引视图键 → 注册路由 → 注入样式
▼
渲染出真正的页面每一段落在哪个文件:
| 环节 | 实现 |
|---|---|
菜单声明 component | wujies/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/home | appCode hello,视图键 demo/home |
remote:exam/exam/settings | appCode 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-dir | wujies-platform/wujies-admin/src/main/resources/application.yml |
| yml 当前值 | ../wujies-admin/app-market-dist/remote-apps | 同上(按后端工作目录解析,指向工作区顶层的 wujies-admin/) |
| 注解兜底值 | ../plus-ui/app-market-dist/remote-apps | AppPackageDeployService 的 @Value 默认值,与 yml 不一致 |
| dev server 读的位置 | <plus-ui 仓库根>/app-market-dist/(不可配置) | plus-ui/vite/plugins/appMarketStatic.ts 的 sourceDir |
落位目录与前端服务读的目录是两个物理位置。单体 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 | 入口 URL | AppRuntimeController.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)
入口有两个来源,刻意分优先级:
- 静态清单优先 ——
VITE_APP_MARKET_REMOTE_APPS,格式code=url,code2=url2(只写url时只能预加载、不能按需装载)。三个.env.*文件里都没有定义它, 临时指向别的产物时才写,改完要重启 dev server; - 后端
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。拿到模块后:
- 取
module.default ?? module.install作为安装函数,调用它并传入宿主运行时; - 校验
manifest的必填字段code/name/version,且routes必须是数组; - 索引视图键(
views的键):与别的应用冲突时 WARN 并覆盖; - 注册路由(
manifest.routes):meta固定带{ appCode, remote: true }; 有parentName且该父路由存在时挂到父路由下,否则注册为顶级路由;def.view不在views里时直接抛错(应用 X 的路由 Y 引用了未声明的视图 Z); - 注入远程应用自带的样式
<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 的提示。
两个原因:
- 路由级异步组件应写成
() => import('./X.vue')那种加载器函数;传defineAsyncComponent()包装器会触发 Vue Router 告警,且路由无法预取 / 提取守卫; - 它只对真正的 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.ts | imports: ['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 打进产物),所以工厂把这四个名字静态列出来。
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 目录>
可以直接复查已发布的历史包,不用重新构建。
自己复刻一份 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 里那一节也仍未同步(见 配置项索引 与
部署配置项 的说法不一致)。
相关阅读
- 插件运行时 —— 后端那一半:类加载器、子上下文、路由注册
- 应用市场模块 —— 落位与加载为什么拆成两步
- 应用生命周期 —— 部署 / 安装 / 升级 / 停用 / 卸载各做了什么
- 前端远程应用工程 —— 自己写一个远程应用:构建工厂、组件白名单、入口契约
- 本地联调 —— 产物落到 dev server 认的目录、静态清单临时指向
- 前端运行时 API ——
install(runtime)递进去的那个对象的完整字段表 - 前端部署与 nginx ——
/remote-apps/的alias、静态资源正则与^~ - 配置项索引 ——
frontend-dir与前端构建期变量 - 故障排查 —— 「菜单点不开」的全部可能
- 跟着 hello 读源码 —— 范例应用的
install(runtime)与视图键