Skip to content

前端远程应用

后端解决了"插件 jar 怎么装进来",前端要解决的是另一个问题: 点了这个菜单,宿主该加载哪份 JS?

为什么必须改前端

改造前,前端的页面是构建期用 glob 静态打进来的:

ts
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 返回本租户已启用应用的入口:

json
[
  { "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

四处是同一个约定:

  1. app-market-poc/<code>-app/vite.config.tsoutDir
  2. package.ps1-FrontendDist 默认值;
  3. 后端落位目录 frontend-dir/<appCode>/
  4. 本接口拼出的 URL。

改一处就要一起改。 历史上 demo-appoutDir 写成了 remote-apps/demo(少个 -app), 后果是库里两条 remote:demo-app/demo/* 菜单永远解析不出来,控制台报 「component 解析不出来,已跳过该路由」。

前端的解析与缓存策略

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

  1. 静态清单优先VITE_APP_MARKET_REMOTE_APPS); 开发环境另有内置的 demo / gallery 清单;
  2. 后端 GET /app/runtime/remote-apps —— 正式路径

静态清单的格式是 code=url,code2=url2(推荐,可驱动按需装载) 或 url1,url2(只能预加载)。

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

加载器做了什么

loadRemoteApp(entry)运行期 import()(带 /* @vite-ignore */,不参与打包), 同一入口并发复用同一个 Promise。

拿到模块后:

  1. module.default ?? module.install 作为安装函数;
  2. 调用它,得到 { manifest, views }
  3. 索引视图键(视图键冲突时 WARN 并覆盖);
  4. 注册路由metaappCoderemote: true;有 parentName 就挂到父路由下);
  5. 注入远程应用自带的样式 <link data-app-market="<code>">

同一个应用重复加载(升级)时先 unloadRemoteApp 再装。

"路由先建、应用后到"的时序

菜单渲染时,远程应用可能还没加载。所以 createRemoteViewComponent 返回的是 一个懒加载函数:渲染时若视图未注册,先通过入口解析器取入口、按需装载

失败时的文案是:

远程视图不可用: <视图键>(应用 <appCode> 未安装或入口地址未配置)

刻意不用 defineAsyncComponent()

两个原因:

  1. 它会触发 Vue Router 告警,且无法预取 / 提取路由守卫;
  2. 只对真正的 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.mjsstaticNames

守门脚本:npm run gen:runtime / npm run check:runtime

三类"构建期全绿、浏览器里才炸"的坑

这三个问题的共同点是:构建不报错,一装载就 ReferenceError 或"看起来正常但没样式"。

1. process.env 残留

echarts → zrendervue-types 这类依赖会在模块顶层process.env.NODE_ENV, 而浏览器里没有 process

修法:每个远程应用工程的 vite.config.ts 都要有

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/coreuseStorage;工程 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) 小表(加载时写,彻底删除也保留当墓碑)。

相关阅读

基于 MIT 协议开源 · 文档与官网由源码生成