适用范围:宿主应用
web-admin+ 插件运行时@vben-core/kernel+src/plugins/*业务插件。文中所有机制均经生产验证。
把前端按业务域拆成独立插件,每个插件最终会拆分为独立 Git 仓库与 npm 包——这条路线对架构纪律的要求极高。本文沉淀我们在一线业务系统中的完整做法。
一、为什么要插件化
前端按业务域拆成独立插件(develop / govern / infra / operate / skeleton …),每个插件最终会拆分为独立 Git 仓库与 npm 包,各自编译、打包、发布。因此有一条不可动摇的红线:
插件之间禁止任何代码级依赖(详见「五、插件间协作规则」)。
插件与宿主之间只通过插件运行时(@vben-core/kernel)通信:插件在自己的 index.ts 里把路由、菜单、语言包、store、自定义扩展点「注册」到运行时,宿主再从运行时「读取」。插件不 import 宿主业务代码,宿主也不 import 插件业务代码。
二、架构总览
自左向右三层:业务插件 → 宿主应用 → 插件运行时。kernel 是所有插件的基石——插件把 expose 注册进运行时,宿主从运行时读取,宿主与插件互不 import 对方业务代码。
kernel 的四个组件
| 组件 | 职责 |
|---|---|
Kernel | 对外门面:init / getExtensions / getExtensionTree / registerExtensionPoint;导出全局单例 kernel |
PluginManager | 遍历 import.meta.glob 结果,跳过无 expose 的模块;持有扩展点声明表与已加载插件集合;按插件隔离故障(单个插件注册失败只跳过该插件) |
PluginLoader | 把 expose 的字段按 pointId 拆开写入注册表;校验 extensions 的 pointId 是否已声明,并执行声明方的 validate(不通过则丢弃该扩展) |
ExtensionRegistry | 基于 Vue ref 的注册表,按 pointId 存扩展,读取时按 priority 排序 |
扩展点(pointId)清单
常量归属规则:内置点在 kernel,业务点全部集中在宿主契约层。 kernel 是通用运行时(要能被其他项目复用),只提供机制、不含任何业务 pointId——内置点由
expose的固定字段隐式声明。所有跨方共享的业务 pointId 常量统一放src/contracts/extension-points.ts,宿主与插件都用#/contracts/extension-points引用(与#/locales同属宿主提供的框架层依赖)。为什么不放「声明方插件自己的 contracts」:那样消费方就得 import 另一个插件,构成跨插件依赖——而这条红线没有例外,拆仓时必然编译失败。把常量放在宿主的契约层,插件之间就是零依赖。
| pointId | 常量 | 常量来源 | 注册来源 | 声明方 | 消费方 |
|---|---|---|---|---|---|
route | POINT_ID_ROUTE | kernel 内置 | expose.routes | kernel 内置 | router/access.ts |
locale | POINT_ID_LOCALE | kernel 内置 | expose.locales | kernel 内置 | locales/index.ts |
store | POINT_ID_STORE | kernel 内置 | expose.stores | kernel 内置 | bootstrap.ts |
pulse.headerLeft | POINT_ID_HEADER_LEFT | 宿主契约层 | expose.extensions | 宿主 bootstrap.ts(registerExtensionPoint) | layouts/basic.vue |
pulse.envType | POINT_ID_ENV_TYPE | 宿主契约层 | expose.extensions | skeleton-plugin(expose.extensionPoints) | develop-plugin constants/env.ts |
新增扩展点必须遵守(登记规则):
- 业务 pointId 常量统一写在宿主契约层
src/contracts/extension-points.ts(内置点由 kernel 声明)。禁止各处写字面量,禁止把业务 pointId 加进 kernel(kernel 必须保持通用、零业务命名),也禁止把契约放到插件目录里让别的插件 import(那是跨插件依赖)。 - 自定义扩展点命名统一为
pulse.<domain>.<name>。 - 注册方必须声明该扩展点:插件在
expose.extensionPoints里声明,宿主自有的插槽在bootstrap.ts里registerExtensionPoint声明。未声明就注册会在控制台告警。 - 同一 pointId 只能声明一次:重复声明会
console.warn并保留首次声明(不静默覆盖)。 - 声明时可提供
validate:loader 在注册每条扩展前执行它,返回false则丢弃该扩展并告警——这是挡住脏数据的唯一手段。 - 新增扩展点必须在上表登记。
- 插件之间零依赖、无例外:需要共享 pointId 就从宿主契约层取,需要共享数据就走 kernel 运行时读取或后端 GraphQL(eslint 强制)。
三、启动时序
蓝紫为插件加载阶段,绿色为消费注册结果的阶段,黄色为懒解析——路由扩展在 app.mount() 之后才被消费。
四个必须记住的时序结论:
- 注册发生在
app.mount()之前,但有两处早于插件注册完成:插件入口模块顶层、宿主setup()(layout 的 setup 在 mount 前执行)。这两处必须惰性读取扩展点(用computed或函数,别直接赋值给常量);懒加载的路由组件 setup 最晚,最安全。 - 路由 / 菜单是懒解析的:
generateAccess在导航守卫里(首次访问受控路由时)才执行,不是启动时。 - 插件加载顺序 = 路径字典序。实测顺序(浏览器控制台
[PluginLoader] Loading plugin:日志):develop → govern → infra → operate → skeleton。最后一个加载的插件排尾,因此任何在模块顶层读取扩展点的消费方都会读到空值,必须惰性读取(见「五、3」)。 - 插件之间互不拖累:入口逐个
import并各自try/catch,单个插件入口顶层抛错、或expose结构非法导致注册失败,只跳过该插件(控制台[Bootstrap] Plugin entry failed to import/[PluginManager] Failed to load plugin),其余插件与宿主照常启动。
四、如何写一个插件
1. 目录结构约定
src/plugins/<name>-plugin/
├── index.ts # 必需。导出 expose,插件唯一入口
├── api/ # 接口层(GraphQL 调用)
│ ├── index.ts # barrel
│ └── xxx.ts
├── components/ # 业务组件(包装技术组件并绑定本插件 API)
├── constants/ # 业务枚举单一数据源
│ ├── index.ts # barrel
│ └── xxx.ts # 字典 + toOptions() 派生选项
├── locales/
│ ├── zh-CN/page.json
│ └── en-US/page.json
├── store/ # Pinia store(可选)
└── views/ # 页面
插件目录是完全自包含的,不对外暴露任何子路径:其它插件不得 import 本插件目录下的任何文件(包括「契约」文件,见「五、2」)。跨方共享的扩展点常量在宿主契约层
src/contracts/。
index.ts的文件名不能改。宿主用import.meta.glob('./plugins/*-plugin/index.ts')扫描(只匹配<name>-plugin目录下的index.ts),文件名不对插件不会被加载,且无任何报错——这是最容易踩的静默失效。
2. expose 契约
以 develop-plugin 为例:
import { POINT_ID_HEADER_LEFT } from '#/contracts/extension-points';
import ProductSelector from './components/ProductSelector.vue';
import { $t } from '#/locales';
import zhCN from './locales/zh-CN/page.json';
import enUS from './locales/en-US/page.json';
export const expose = {
id: 'develop-plugin', // 必需,全局唯一
name: 'Develop Plugin', // 必需
version: '1.0.0', // 可选,默认 1.0.0
routes: [ /* 见下 */ ], // 路由,注册到 pointId=route
stores: [ // Pinia store,注册到 pointId=store
{ id: 'product', module: async () => (await import('./store/product')).useProductStore },
],
locales: { 'zh-CN': zhCN, 'en-US': enUS }, // 注册到 pointId=locale
extensionPoints: [], // 本插件"提供"的扩展点声明(可选,见「5」)
extensions: [ // 往扩展点注册内容,注册到 pointId=<pointId>
{ pointId: POINT_ID_HEADER_LEFT, content: ProductSelector },
],
};
字段说明:
| 字段 | 类型 | 说明 |
|---|---|---|
id / name | string | 插件标识,id 必须唯一,重复会被 PluginManager 跳过并告警 |
version | string? | 缺省 1.0.0 |
routes | PluginRoute[] | 见「3. 路由写法」 |
stores | { id, module }[] | module 是异步函数,返回 Pinia 的 useXxxStore;宿主在 mount 前统一执行 |
locales | Record<lang, object> | 见「4. 语言包写法」 |
extensionPoints | ExtensionPoint[] | { id, desc?, validate? },声明本插件提供的扩展点,loader 据此校验注册合法性 |
extensions | Extension[] | { pointId, content, priority? },往扩展点注册内容,供其他插件 / 宿主运行时读取 |
已废弃字段:
menus(菜单来源就是路由)与apis(插件页面直接 import 自己的 api,无需注册)已从契约中删除——这两个字段曾把全部 API 模块静态拉入首屏 chunk,且没有任何消费方。
3. 路由写法(component 是字符串,不是组件)
routes: [
{
path: '/develop', // 一级路由必须以 / 开头
name: 'Develop',
meta: { icon: LayoutDashboard, order: 10, title: $t('page.developplugin.title') },
children: [
{
name: 'DevelopReleaseSpaces',
path: 'spaces', // 子路由不带 /
component: '../plugins/develop-plugin/views/release/spaces/index.vue',
meta: { icon: LayoutGrid, title: $t('page.developplugin.spaces') },
},
],
},
]
解析规则(router/access.ts):
- 宿主用
import.meta.glob('../plugins/**/*.vue', { eager: false })建立懒加载组件表(页面按需分 chunk,这是好设计)。 - 建表时 key 已被
normalizeViewMap归一化为/plugins/xxx形式,getComponent里component字符串同样归一化后一次命中,无需再尝试多种前缀。 - 写
'../plugins/<你的插件>/views/...'这个形式即可正常工作;查不到会console.error('[Access] Component not found:', path)并回退到 404 页。
meta 字段消费位置:
| meta 字段 | 消费位置 |
|---|---|
title | access.ts 里 $t(route.meta.title) 做翻译;菜单 / 标签页 / 面包屑均取此值 |
icon | 由 generateAccessible 生成菜单时读取 |
order | 菜单排序 |
hideInMenu | 菜单隐藏 |
4. 语言包写法
// locales/zh-CN/page.json
{ "developplugin": { "title": "研发", "spaces": "空间管理" } }
合并逻辑(locales/index.ts):按 lang 匹配 expose.locales[lang],deepMerge 进宿主语言包。所以页面里用 $t('page.developplugin.spaces')——page 是宿主 i18n 的命名空间前缀,插件 JSON 里不要再写一层 page。
注意:语言包是启动时一次性合并的,注册表后续变化不会自动重载。这也是「启动时序」结论 1 的一个具体体现。
5. 自定义扩展点(业务数据跨插件共享的正规通道)
场景:环境枚举(DEV / TEST / PROD / DR)需要被多个插件使用,但不能跨插件 import。
定义方(skeleton-plugin):声明扩展点 + 只声明数据 + 注册到运行时。
// skeleton-plugin/constants/env.ts —— 数据唯一定义处
export const ENV_TYPE_CONTENT = { labels: ENV_TYPE_LABEL, abbrs: ENV_TYPE_ABBR };
// skeleton-plugin/index.ts —— 声明扩展点,再注册内容
import { POINT_ID_ENV_TYPE } from '#/contracts/extension-points'; // 常量来自宿主契约层
extensionPoints: [{ id: POINT_ID_ENV_TYPE, desc: '环境枚举标签与缩写' }],
extensions: [{ pointId: POINT_ID_ENV_TYPE, content: ENV_TYPE_CONTENT }],
消费方(develop-plugin):只从宿主契约层取常量 + 运行时读取内容,不 import 定义方插件的任何文件。
// develop-plugin/constants/env.ts —— 运行时读取入口
import { pluginManager } from '@vben-core/kernel';
import { POINT_ID_ENV_TYPE } from '#/contracts/extension-points';
function envContent() {
return pluginManager.getExtensionTree<EnvTypeContent>(POINT_ID_ENV_TYPE)[0];
}
export function envTypeLabel(env?: null | string): string {
if (!env) return '';
return envContent()?.labels?.[env] ?? env; // 未登记的环境回退为原始 code
}
三条硬约束:
- 读取必须发生在
setup()/computed/ 函数体内,不能在模块顶层(skeleton-plugin 最后加载)。读不到扩展时PluginManager会console.warn提示。 pointId常量统一从宿主契约层#/contracts/extension-points导入(内置点除外,从@vben-core/kernel导入),禁止两端各自写字面量。- 单值扩展点用
getExtensionTree(...)[0];多值扩展点(如route)直接map全量。
6. 消费扩展点的两种 API
import { pluginManager } from '@vben-core/kernel';
// 取原始 Extension[](含 pluginId / priority 元信息)
const exts = pluginManager.getExtensions<MyContent>('pulse.xxx');
// 取 content 数组,可选传入转换函数(宿主生成路由就是这么做的)
const contents = pluginManager.getExtensionTree<MyContent, MyView>('pulse.xxx', convertor);
// 响应式版本(返回 computed)—— 当前无人使用,注册表启动后不再变化
const extsRef = pluginManager.useExtensions<MyContent>('pulse.xxx');
五、插件间协作规则
1. 允许的依赖
| 来源 | 示例 | 说明 |
|---|---|---|
| 插件内部相对路径 | ../../api/spaces、./tabs/XxxTab.vue | 自包含,拆仓后整体搬运 |
| 本插件自己的根别名 | @dev/api/user、@govern/constants/... | 由宿主 vite.config.ts + tsconfig.json 定义(@dev / @govern / @infra / @operate / @skeleton),只能指向自己;拆仓时需随插件一起迁移 |
| 宿主的契约层 | #/contracts/extension-points | 所有跨方共享的扩展点 pointId,与 #/locales 同属宿主提供的框架层依赖 |
| 应用框架层全局依赖 | @vben/common-ui、#/adapter/vxe-table、#/api/request、#/locales | 宿主提供 |
| 插件运行时 | @vben-core/kernel | 跨插件数据交互的唯一通道 |
| 公共 npm 依赖 | vue、ant-design-vue、@lucide/vue | 从 registry 安装 |
| 后端 GraphQL API | requestClient.post('/gql/execute', { api, vars }) | 跨插件服务复用走后端 BFF |
2. 严格禁止
import其它插件的任何文件:from '#/plugins/govern-plugin/...'、from '@govern/...'、或相对路径越界到其他插件目录——没有例外。连「契约文件」也不行:契约一旦放在插件目录里,消费方 import 它就是一条跨插件依赖,拆仓即编译失败。共享 pointId 常量一律从宿主契约层取。- 跨插件 Store:
useXxxStore()读其他插件的 Pinia store。 - 动态
import()跨插件。 - 路由
component硬编码其他插件路径。 - 用
#/plugins/<自己>/...引用自己:今天能跑(#→src),但拆仓后该路径必然失效;插件内引用自己一律用相对路径或@<plugin>/别名。 - 裸别名
@other直接拉对方插件入口(@other/**匹配不到裸别名,故 eslint 另用paths拦截)。
强制机制(首选):pl-webapps/eslint.config.mjs 里已按插件逐个生成 no-restricted-imports 规则:@other/**、**/other-plugin/**、\#/plugins/** 全部为 error,无任何放行;裸别名 @other 用 paths 拦截。IDE 实时标红,且 lefthook pre-commit 会 eslint --fix {staged_files},提交即拦截,不需要人工记得跑自检。
# 只查跨插件 import(本地快速核对,CI 由 eslint 兜底),输出应为空
cd pl-webapps
pnpm exec eslint "apps/web-admin/src/plugins/**/*.{ts,vue}" 2>&1 | grep no-restricted-imports
自检命令(补充项,eslint 未覆盖的场景):
cd pl-webapps/apps/web-admin
grep -rn "use.*Store" src/plugins/<your-plugin>/
grep -rn "from ['\"]\.\./\.\./\.\./\.\./" src/plugins/<your-plugin>/
一个 gitignore 语义的坑:
group由ignore包实现,行首#会被当成注释,所以规则里写的是\#/plugins/**(转义),不是#/plugins/**——不转义规则会静默失效。
3. 跨插件数据交互的正确姿势
| 需求 | 正确做法 |
|---|---|
| 用户 / 产品编码等上下文 | URL query 传递(?productCode=PRD001)或后端查询参数;不要读别的插件的 store |
| 同一份后端数据被多插件使用 | 各自独立调 GraphQL endpoint,由 BFF 编排;前端不共享 API 模块 |
| 一份前端枚举 / 配置被多插件使用 | 定义方注册为扩展点,消费方运行时读取(见「四、5」) |
| 通用组件复用 | 抽到宿主 src/components/ 或发布公共 npm 包 |
六、正向设计(值得保留)
- 页面级懒加载:
import.meta.glob('../plugins/**/*.vue', { eager: false }),每个页面独立 chunk,这块做对了。 - 扩展点机制本身:
ExtensionRegistry基于ref,天然支持useExtensions()响应式读取;priority+ 注册序号保证顺序稳定。 - 环境枚举的统一路径:业务数据定义留插件、共享通道走运行时,是「插件零代码依赖」约束下可持续复制的范式。
expose.stores用异步module():避免把插件 store 静态拉入首屏。
七、验证方式
cd pl-webapps
# 类型检查(kernel 有独立 typecheck 门禁,随 turbo 一起跑)
pnpm check:type
# 跨插件 import 强制检查:输出应为空(#/contracts/** 是宿主依赖,不算跨插件)
pnpm exec eslint "apps/web-admin/src/plugins/**/*.{ts,vue}" 2>&1 | grep no-restricted-imports
# kernel 通用性检查:业务 pointId 不得回流到 kernel,输出应为空
grep -rnE "pulse\.|POINT_ID_HEADER_LEFT|POINT_ID_ENV_TYPE" packages/@core/kernel/src/
# 构建并观察初始 chunk 体积
cd apps/web-admin && pnpm build
# 启动开发服务器,浏览器控制台会打印插件加载情况
pnpm dev
# [PluginManager] Found 5 plugins ← 等于插件数
# [PluginLoader] Loading plugin: xxx ← 顺序即加载顺序
# [PluginManager] Extension point registered: ... ← 扩展点声明
# [PluginManager] Total loaded plugins: 5
# 若出现 [Bootstrap] Plugin entry failed to import / [PluginManager] Failed to load plugin
# → 说明某个插件被故障隔离跳过了,按日志里的路径定位该插件
附:新插件接入 checklist
- 目录名
<name>-plugin,入口文件必须叫index.ts(否则不会被 glob 命中,且无任何报错) - 导出
expose,id全局唯一 - 路由
component写成'../plugins/<name>-plugin/views/xxx/index.vue' - 语言包 key 不带
page前缀,页面里用$t('page.<plugin>.xxx') - store 用
module: async () => (await import('./store/xxx')).useXxxStore - 需要跨插件共享的枚举 / 配置:在
expose.extensionPoints声明扩展点、用expose.extensions注册内容;pointId 常量写进宿主契约层(别往 kernel 加,也别放插件目录里让别的插件 import),消费方一律从#/contracts/extension-points引用,并在扩展点清单登记;建议同时提供validate - 不 import 任何其他插件的文件(含契约,无例外);插件内引用自己用相对路径或
@<plugin>/别名,不要写#/plugins/<自己>/;跨插件上下文用 URL query 或后端查询 - 跨插件 import 强制检查无输出(见「五、2」);提交时 lefthook
pre-commit也会自动拦截 - 顶层不读取任何扩展点;宿主
setup()里也要用computed/ 函数惰性读取(禁止直接赋值给常量)