前端远程应用
后端解决了"插件 jar 怎么装进来",前端要解决的是另一个问题: 点了这个菜单,宿主该加载哪份 JS?
为什么必须改前端
改造前,前端的页面是构建期用 glob 静态打进来的:
import.meta.glob('./../../views/**/*.vue')后端 sys_menu.component 只能指向构建时已存在的 .vue 文件。 所以在原有架构下,第三方页面在物理上无法热加载 —— 这是整套改造里绕不过去的前置。
整体机制
后端 app.json 的菜单声明
component = "remote:<appCode>/<视图键>" ← 只是一个"视图键",不是路径
│
▼
前端登录时拿到菜单树,遇到 remote: 前缀就换成 createRemoteViewComponent(视图键, appCode)
│
▼
用户点菜单 → 渲染视图 → 该视图还没注册 → 问后端要入口
│
▼
GET /app/runtime/remote-apps
[{appCode: "exam", version: "1.0.1", entry: "/remote-apps/exam/index.js?v=1.0.1"}]
│
▼
运行期 import() 那份独立 ESM → 拿到 { manifest, views } → 索引视图键、注册路由宿主怎么找到入口
宿主在构建期不可能知道租户装了哪些应用(这正是"应用外置"的意义), 所以入口只能运行期问服务端。
GET /app/runtime/remote-apps 返回本租户已启用应用的入口:
[
{ "appCode": "exam", "version": "1.0.1", "entry": "/remote-apps/exam/index.js?v=1.0.1" }
]拼接公式:
/remote-apps/ + appCode + / + <assets.frontend 的文件名> + ?v=<版本号>几个刻意的设计:
| 设计 | 原因 |
|---|---|
入口带 ?v=<版本号> | 不带的话升级后浏览器吃旧缓存,表现是"升级成功了、页面还是老样子" |
| 不加权限注解 | 看得到应用菜单的用户不一定有市场权限(菜单按角色授权),而这里返回的信息与他自己菜单里的等价 —— 否则"能看到菜单却打不开页面"又回来了 |
| 纯后端应用不出现 | 清单没写 assets.frontend 的直接跳过 |
| 单条坏数据只 WARN 跳过 | 不让整份清单为空 —— 否则一个应用出错会让所有应用打不开 |
远程产物目录名必须等于 appCode
四处是同一个约定:
app-market-poc/<code>-app/vite.config.ts的outDir;package.ps1的-FrontendDist默认值;- 后端落位目录
frontend-dir/<appCode>/; - 本接口拼出的 URL。
改一处就要一起改。 历史上 demo-app 的 outDir 写成了 remote-apps/demo(少个 -app), 后果是库里两条 remote:demo-app/demo/* 菜单永远解析不出来,控制台报 「component 解析不出来,已跳过该路由」。
前端的解析与缓存策略
入口有两个来源,刻意分优先级:
- 静态清单优先(
VITE_APP_MARKET_REMOTE_APPS); 开发环境另有内置的 demo / gallery 清单; - 后端
GET /app/runtime/remote-apps—— 正式路径。
静态清单的格式是 code=url,code2=url2(推荐,可驱动按需装载) 或 url1,url2(只能预加载)。
缓存策略:后端答复一次会话只问一次,并发去重;失败不缓存, 刷新或下次点击还能重试。预加载时单个失败不影响其它应用。
加载器做了什么
loadRemoteApp(entry) 用运行期 import()(带 /* @vite-ignore */,不参与打包), 同一入口并发复用同一个 Promise。
拿到模块后:
- 取
module.default ?? module.install作为安装函数; - 调用它,得到
{ manifest, views }; - 索引视图键(视图键冲突时 WARN 并覆盖);
- 注册路由(
meta带appCode与remote: true;有parentName就挂到父路由下); - 注入远程应用自带的样式
<link data-app-market="<code>">。
同一个应用重复加载(升级)时先 unloadRemoteApp 再装。
"路由先建、应用后到"的时序
菜单渲染时,远程应用可能还没加载。所以 createRemoteViewComponent 返回的是 一个懒加载函数:渲染时若视图未注册,先通过入口解析器取入口、按需装载。
失败时的文案是:
远程视图不可用: <视图键>(应用 <appCode> 未安装或入口地址未配置)刻意不用 defineAsyncComponent()
两个原因:
- 它会触发 Vue Router 告警,且无法预取 / 提取路由守卫;
- 它只对真正的 ES 模块解包
.default,而远程应用契约返回的是普通对象{ default: Component },会被当成组件本体 →Component is missing template or render function,渲染空白。
共享依赖:运行时垫片
远程产物把 vue / element-plus / @dromara/* 等全部 external 掉, 运行期由一个 import map 指向宿主提供的垫片:
app-market-dist/app-runtime/
├── vue.js
├── vue-router.js
├── pinia.js
├── element-plus.js
├── dromara-request.js
├── dromara-components.js
└── dromara-modal.js每个垫片是一个标准 ESM,把自身导出的每个名字转发到宿主运行时对象上的同名属性 —— 于是远程应用的 import { ref } from 'vue' 拿到的就是宿主那一份 Vue。
宿主启动时把真正的实例挂到 window.__APP_MARKET_RUNTIME__, 再由 install(runtime) 注入给每个远程应用。
少一个具名导出,整个应用在链接期就炸
实测报错:
SyntaxError: The requested module '@dromara/request' does not provide an export named 'globalHeaders'vue / element-plus 的垫片会自动枚举导出,但 @dromara/* 是虚拟说明符,只能静态声明。 所以加一个具名导出要同时改两处:
plugins/appmarket/runtime.ts对应 slot 的名单;scripts/gen-runtime-shims.mjs的staticNames。
守门脚本:npm run gen:runtime / npm run check:runtime。
三类"构建期全绿、浏览器里才炸"的坑
这三个问题的共同点是:构建不报错,一装载就 ReferenceError 或"看起来正常但没样式"。
1. process.env 残留
echarts → zrender、vue-types 这类依赖会在模块顶层读 process.env.NODE_ENV, 而浏览器里没有 process。
修法:每个远程应用工程的 vite.config.ts 都要有
define: { 'process.env.NODE_ENV': JSON.stringify('production') }守门:npm run check:remote(会扫已构建产物里有没有 process.env 残留)。
2. 自动导入少一项
宿主开了 unplugin-auto-import,所以宿主源码里 ref(...) / useStorage(...)没有 import 语句。而远程产物里会混进宿主源码(@/store/**、@/utils/**、@/api/system/** 按别名原样打进产物)—— 少列一个 preset,这些标识符在产物里就是裸全局。
实测:im 的某个组件经 @/utils/propTypes 带进了宿主的 src/utils/auth.ts, 而那里用了 @vueuse/core 的 useStorage;工程 imports 里没有它 → ReferenceError: useStorage is not defined → 整个应用装载失败,菜单点开是「远程视图不可用」。
守门:npm run check:globals(用 ESLint 的 no-undef 扫产物, 只对"宿主自动导入宇宙"里的名字报错)。
3. 组件没进白名单
模板里用到的 K* 组件若不在白名单里,Vue 会把它当未知自定义元素 —— 不报错、不渲染,只剩插槽里的文字。
实测:exam 的「新增 / 折叠 / 删除」变成了纯文字,因为 <KPlainButton> 不在白名单。
守门:npm run check:remote(检查模板里用到的 PascalCase 标签能否由 「显式 import / 白名单 / 宿主全局注册」三条路之一解析)。
卸载时做了什么
- 移除
addRoute句柄返回的路由; - 清视图索引;
- 移除样式
<link>。
不触碰已加载的 JS 模块本身(浏览器没有卸载模块的能力)。
应用不可用时的提示
应用被卸载/停用后,它注册的路由被摘掉,但前端手里的页面可能还在调它的接口。 宿主会给出可区分的提示,而不是笼统的"请求地址不存在":
- 路由墓碑记下"这条路径曾经属于谁",卸载时不清;
- 结合"插件是否已加载"与"本租户安装记录",把措辞分成 已停用 / 未安装或已卸载 / 已启用但产物没加载;
- 应用正在跑却 404 → 保持原文案(那是它自己的路径 bug,不能被"应用不可用"盖掉)。
HTTP 仍是 404 语义,但 body 用独立业务码 4610,前端才能可靠区分 "应用不可用 / 真 404",而不是去 match 中文。
治本:别让它发生
- 后端
GET /app/runtime/enabled-apps返回本租户已启用应用编码(不要求有前端产物); - 前端
appAvailability.ts一次会话只问一次、按 token 失效、并发去重, 且取不到清单时一律当"可用"(一次网络抖动不该把功能藏起来); - 宿主页面守卫:调某个应用的接口前先
if (!(await isAppEnabled('member'))) return;。
墓碑是内存态的
它覆盖"本次运行期间卸载/停用"(就是登录着被卸掉的场景)。 重启之后应用已卸载、页面也不该存在,通常够用。
要连"彻底删除之后很久"的请求也认账,需要落一张 app_route_index(app_code, pattern) 小表(加载时写,彻底删除也保留当墓碑)。