一佳技术

前端插件开发模式:零依赖、可拆仓的架构实践

把前端按业务域拆成独立插件:kernel 插件运行时、扩展点机制、启动时序与协作规则,让插件之间零代码依赖,最终可拆为独立仓库与 npm 包。

2026-10-10 · 矩阵框架

适用范围:宿主应用 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常量常量来源注册来源声明方消费方
routePOINT_ID_ROUTEkernel 内置expose.routeskernel 内置router/access.ts
localePOINT_ID_LOCALEkernel 内置expose.localeskernel 内置locales/index.ts
storePOINT_ID_STOREkernel 内置expose.storeskernel 内置bootstrap.ts
pulse.headerLeftPOINT_ID_HEADER_LEFT宿主契约层expose.extensions宿主 bootstrap.ts(registerExtensionPoint)layouts/basic.vue
pulse.envTypePOINT_ID_ENV_TYPE宿主契约层expose.extensionsskeleton-plugin(expose.extensionPoints)develop-plugin constants/env.ts

新增扩展点必须遵守(登记规则):

  1. 业务 pointId 常量统一写在宿主契约层 src/contracts/extension-points.ts(内置点由 kernel 声明)。禁止各处写字面量,禁止把业务 pointId 加进 kernel(kernel 必须保持通用、零业务命名),也禁止把契约放到插件目录里让别的插件 import(那是跨插件依赖)。
  2. 自定义扩展点命名统一为 pulse.<domain>.<name>。
  3. 注册方必须声明该扩展点:插件在 expose.extensionPoints 里声明,宿主自有的插槽在 bootstrap.ts 里 registerExtensionPoint 声明。未声明就注册会在控制台告警。
  4. 同一 pointId 只能声明一次:重复声明会 console.warn 并保留首次声明(不静默覆盖)。
  5. 声明时可提供 validate:loader 在注册每条扩展前执行它,返回 false 则丢弃该扩展并告警——这是挡住脏数据的唯一手段。
  6. 新增扩展点必须在上表登记。
  7. 插件之间零依赖、无例外:需要共享 pointId 就从宿主契约层取,需要共享数据就走 kernel 运行时读取或后端 GraphQL(eslint 强制)。

三、启动时序

启动时序:从 main.ts 到首次路由解析的 12 步

蓝紫为插件加载阶段,绿色为消费注册结果的阶段,黄色为懒解析——路由扩展在 app.mount() 之后才被消费。

四个必须记住的时序结论:

  1. 注册发生在 app.mount() 之前,但有两处早于插件注册完成:插件入口模块顶层、宿主 setup()(layout 的 setup 在 mount 前执行)。这两处必须惰性读取扩展点(用 computed 或函数,别直接赋值给常量);懒加载的路由组件 setup 最晚,最安全。
  2. 路由 / 菜单是懒解析的:generateAccess 在导航守卫里(首次访问受控路由时)才执行,不是启动时。
  3. 插件加载顺序 = 路径字典序。实测顺序(浏览器控制台 [PluginLoader] Loading plugin: 日志):develop → govern → infra → operate → skeleton。最后一个加载的插件排尾,因此任何在模块顶层读取扩展点的消费方都会读到空值,必须惰性读取(见「五、3」)。
  4. 插件之间互不拖累:入口逐个 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 / namestring插件标识,id 必须唯一,重复会被 PluginManager 跳过并告警
versionstring?缺省 1.0.0
routesPluginRoute[]见「3. 路由写法」
stores{ id, module }[]module 是异步函数,返回 Pinia 的 useXxxStore;宿主在 mount 前统一执行
localesRecord<lang, object>见「4. 语言包写法」
extensionPointsExtensionPoint[]{ id, desc?, validate? },声明本插件提供的扩展点,loader 据此校验注册合法性
extensionsExtension[]{ 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 字段消费位置
titleaccess.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
}

三条硬约束:

  1. 读取必须发生在 setup() / computed / 函数体内,不能在模块顶层(skeleton-plugin 最后加载)。读不到扩展时 PluginManager 会 console.warn 提示。
  2. pointId 常量统一从宿主契约层 #/contracts/extension-points 导入(内置点除外,从 @vben-core/kernel 导入),禁止两端各自写字面量。
  3. 单值扩展点用 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 APIrequestClient.post('/gql/execute', { api, vars })跨插件服务复用走后端 BFF

2. 严格禁止

  1. import 其它插件的任何文件:from '#/plugins/govern-plugin/...'、from '@govern/...'、或相对路径越界到其他插件目录——没有例外。连「契约文件」也不行:契约一旦放在插件目录里,消费方 import 它就是一条跨插件依赖,拆仓即编译失败。共享 pointId 常量一律从宿主契约层取。
  2. 跨插件 Store:useXxxStore() 读其他插件的 Pinia store。
  3. 动态 import() 跨插件。
  4. 路由 component 硬编码其他插件路径。
  5. 用 #/plugins/<自己>/... 引用自己:今天能跑(# → src),但拆仓后该路径必然失效;插件内引用自己一律用相对路径或 @<plugin>/ 别名。
  6. 裸别名 @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 / 函数惰性读取(禁止直接赋值给常量)

这套插件化架构来自矩阵框架团队的研发平台实践。更多工程细节见矩阵框架使用文档与矩阵框架产品页。