把 React 前端做成「插件市场」:前后端联动的行业包可插拔架构
新增一条业务线,建个文件夹、实现一个接口就行,主路由不用动、布局不用复制、别人的代码不用碰。本文记录我在一个 React 19 + Vite + Spring Boot 项目里怎么做行业包插件化的,踩了几个坑也一并写出来。
技术栈:React 19 · TypeScript · Vite · React Router v7 · Ant Design 6 · zustand · Spring Boot 4.1 · Java 21
一、原来的写法有多难受
项目(零号智工,AI 数字员工 SaaS)要支撑好几个垂直行业:客服、教育、写作、HR、政务、售前、数据分析。每个行业一套独立工作台,侧边栏、页面、主题色、看板都不一样。
一开始就是复制粘贴:
bash
src/features/customer-service/ ← 复制一份 AppLayout + SideNav + TopBar
src/features/education/ ← 再复制一份
src/features/writer/ ← 又复制一份
主路由里手写 <Route path="/customer-service/*" ...>,主题色硬编码在组件 CSS 里。
这么干的问题:
- 布局复制 N 份,改一个顶栏交互要同步改 N 个行业
- 主路由越来越长,每加一个行业都动公共文件,经常冲突
- 主题色散落在各处,换一个颜色要翻一遍
- 所有行业代码打到一个包里,用户用不用都得加载
- 跟 SaaS 按租户订阅开通的模式对不上
- 前端加了包,后端还得手动配路由配模块,两边不同步
目标是把行业抽象成可插拔的 Pack,前端自动发现、后端 SPI 注册、租户按需订阅。
二、整体架构
不是纯前端方案,是前后端联动的三层结构:
scss
┌──────────────────────────────────────────────────────────────┐
│ 前端行业包层(React) │
│ import.meta.glob 自动发现 → packRouteConfigs → 路由自动装配 │
│ PackGuard 订阅守卫 → AppShell 共享外壳 → CSS变量+Antd Token │
├──────────────────────────────────────────────────────────────┤
│ 能力包层(横切复用) │
│ 通用能力包(deep-research 等)+ 行业专属对话能力包 │
│ buildPackCapabilityRoutes() 动态注入到行业包 basePath 下 │
├──────────────────────────────────────────────────────────────┤
│ 后端 SPI 层(Spring Boot) │
│ VerticalPack 接口 → VerticalPackRegistry 自动注册 │
│ @ConditionalOnProperty 按需启停 → /api/v1/packs 暴露注册表 │
│ PackController 处理租户安装/订阅/订单 │
└──────────────────────────────────────────────────────────────┘
概括起来就三点:契约、自动发现、装配。前端行业包实现 PackManifest 接口,后端实现 VerticalPack 接口;前端用 import.meta.glob 扫目录,后端用 Spring 构造器注入 Bean,都不用手工登记;前端把注册表 map 成路由加守卫,后端用 @ConditionalOnProperty 控制模块启停。
目录结构:
bash
features/packs/
├── platform/ ← 平台包(主工作台)
│ ├── manifest.ts
│ └── routes.tsx
├── hr/ ← HR 行业包
│ ├── manifest.ts
│ ├── routes.tsx
│ └── conversational/ ← 行业专属对话能力包
│ └── recruitment-flow/manifest.ts
├── ecommerce/ ← 电商客服
├── education/ ← 教育
├── government/ ← 政务
├── writer/ ← 内容创作
├── data-analyst/ ← 数据分析
├── presales/ ← 售前咨询
└── ...
约定:目录名等于 manifest.code,包内路由文件固定叫 routes.tsx。遵守这两条,注册表自动捡起来。
三、PackManifest 契约
每个行业包对外只暴露一个 manifest.ts,实现 PackManifest:
ts
// features/packs/types.ts
export interface PackManifest {
code: string // 与目录名一致
name: string
nameKey?: string // i18n key
shortName: string
initial: string
gradient: string
desc: string
descKey?: string
basePath: string // 路由挂载点,如 '/super-hr'
defaultPath: string // 包内默认页
navGroups: PackNavGroup[]
globalNav?: PackNavItem[]
globalSettings?: PackNavItem[]
status?: 'ga' | 'beta'
minPlan?: string
capabilities?: string[]
capabilityMenuGroup?: string
defaultCanvas?: CanvasConfig
aiWorkspace?: AiWorkspaceConfig
}
status 做灰度,minPlan 做套餐门槛,capabilities 声明能力包,defaultCanvas 配岗位看板。都是行业包作为商品需要的元数据。
HR 行业包的 manifest 里有这么一段:
ts
// features/packs/hr/manifest.ts
const manifest: PackManifest = {
code: 'hr',
basePath: '/super-hr',
defaultPath: '/super-hr/dashboard',
capabilities: ['deep-research'],
capabilityMenuGroup: '通用能力',
aiWorkspace: { hideEmployeeSelector: true },
defaultCanvas: {
layout: 'dashboard',
widgets: [
{ type: 'stat', title: 'Offer 数', hookName: 'useOfferKpiQuery', span: 8, linkTo: '/super-hr/tools/offers' },
{ type: 'list', title: '待处理 Offer', hookName: 'useOffersQuery', limit: 5, span: 12, linkTo: '/super-hr/tools/offers' },
],
},
}
hookName 存的是字符串而不是直接 import,渲染时通过 widgetRegistry 查找。这样 manifest 是纯数据,不依赖运行时模块,能序列化、能缓存。
i18n 方面,name/desc 可以配 xxxKey,渲染时优先 t(key),locale 缺失回退中文:
ts
export function tManifest(t: TFunction, key: string | undefined, fallback: string): string {
return key ? t(key, { defaultValue: fallback }) : fallback
}
四、import.meta.glob 自动发现
注册表不需要 import 列表,Vite 构建时扫目录:
ts
// features/packs/registry.ts
const manifestModules = import.meta.glob<{ default: PackManifest }>('./*/manifest.ts', {
eager: true,
})
const routeModules = import.meta.glob<{ default: ComponentType }>('./*/routes.tsx')
export const packs: PackManifest[] = Object.values(manifestModules).map((m) => m.default)
export const packMap: ReadonlyMap<string, PackManifest> = new Map(packs.map((p) => [p.code, p]))
export const packRouteConfigs: PackRouteConfig[] = Object.entries(routeModules).flatMap(
([path, loader]) => {
const code = /^\.\/(.+)\/routes\.tsx$/.exec(path)?.[1]
const manifest = code ? packMap.get(code) : undefined
if (!manifest) return []
return [{ code: manifest.code, basePath: manifest.basePath, manifest, component: lazy(loader) }]
},
)
export const verticalPacks = packs.filter((p) => p.code !== 'platform')
几个点:
- manifest 用
eager: true同步加载,因为要做菜单和路由匹配,必须立即可用;routes 用懒加载,页面代码大,按需下载 - 目录名是 key,从
./xxx/routes.tsx提取xxx,去packMap查 manifest。有 routes 没 manifest 的目录直接跳过,半成品包不会影响主应用 packMap用ReadonlyMap,注册表构建完就不可变- 加一个行业包,这段代码不用动
五、路由装配
主路由把 packRouteConfigs map 成路由,不手写任何行业路由:
tsx
// router/routes.tsx
const verticalRoutes = packRouteConfigs.map(({ basePath, manifest, component: Component }) => ({
path: basePath,
element: <ProtectedRoute />,
children: [
{ index: true, element: <Navigate to={manifest.defaultPath} replace /> },
...buildPackCapabilityRoutes(manifest.code),
{
path: '*',
element: (
<ErrorBoundary>
<PackGuard packCode={manifest.code}>
<Suspense fallback={<PageLoading />}>
<Component />
</Suspense>
</PackGuard>
</ErrorBoundary>
),
},
],
}))
访问一个行业包页面要过四层:ProtectedRoute 查登录状态、ErrorBoundary 兜底崩溃、PackGuard 查订阅、Suspense 处理懒加载。
踩坑:basePath 不要加 /*
一开始父路由写了 path: '/super-hr/*',结果能力包子路由死活匹配不上。React Router v7 的 splat 会跟 children 子路径打架。改成 path: '/super-hr' 就好了。另外 buildPackCapabilityRoutes() 必须放在 path: '*' 之前展开,顺序错了路由静默失效,排查了半天。
六、PackGuard 订阅守卫
技术上可插拔还不够,SaaS 要按订阅控制访问:
tsx
// features/packs/PackGuard.tsx
export function PackGuard({ packCode, children }: PackGuardProps) {
const subscribedPacks = useTenantStore((s) => s.subscribedPacks)
if (!subscribedPacks.includes(packCode)) {
return <Navigate to="/workspace/packs" replace />
}
return <>{children}</>
}
subscribedPacks 不是前端硬编码的,是从后端安装记录推导出来的:
ts
// features/packs/useTenantStore.ts
export const OFFICIAL_PACK_PROFESSION: Record<string, string> = {
'customer-service': 'ecommerce',
'education-tutor': 'education',
'hr-recruiter': 'hr',
'content-writer': 'writer',
'presales-consultant': 'presales',
'government-affairs': 'government',
'data-analyst': 'data-analyst',
}
export const useTenantStore = create<TenantState>((set) => ({
subscribedPacks: ['ecommerce', 'government', 'education', 'hr', 'writer', 'data-analyst', 'presales'],
fetchSubscribedPacks: async () => {
try {
const installs = await listPackInstalls()
const codes = installs
.filter((i) => i.status === 'installed')
.map((i) => OFFICIAL_PACK_PROFESSION[i.packId])
.filter((c): c is string => Boolean(c))
set({ subscribedPacks: codes })
} catch {
// 后端不可用时保持初始值,不阻塞前端走查
}
},
}))
这里有个坑:官方插件包 ID 和前端行业包 code 是两套命名。后端记录的是 hr-recruiter,前端守卫查的是 hr,OFFICIAL_PACK_PROFESSION 就是做这个映射的。
还有一个边界问题,员工的 professionCode 字段混杂了三种取值(职业枚举、行业包 code、官方插件包 id),packCodeOfProfession() 负责统一归一,未命中的当通用员工处理,任何行业都能看到。
订阅链路:租户在插件市场装包 → 后端落 pack_install 记录 → 前端登录后拉安装记录映射成 code 列表 → 访问路由时 PackGuard 检查。
七、能力包:包上再挂包
行业之间有通用能力(深度调研、多模态、数据分析),每个行业包写一遍又是复制粘贴。所以加了一层能力包,分两种。
通用能力包(scope = universal)
放 features/capabilities/ 下,行业包在 manifest 里声明 capabilities: ['deep-research'],路由通过 buildPackCapabilityRoutes 注入:
ts
// features/capabilities/CapabilityRouteLoader.tsx
export function buildPackCapabilityRoutes(packCode: string): RouteObject[] {
const pack = packMap.get(packCode)
if (!pack?.capabilities?.length) return []
const routes: RouteObject[] = []
for (const code of pack.capabilities) {
const capability = capabilities.find((c) => c.code === code)
if (!capability) continue
for (const routeConfig of capability.routes) {
const Component = lazyLoadPage(routeConfig.component)
if (!Component) continue
routes.push({
path: routeConfig.path.replace(/^\//, ''),
element: (
<CapabilityGuard capabilityCode={code}>
<ErrorBoundary>
<Suspense fallback={<PageLoading />}><Component /></Suspense>
</ErrorBoundary>
</CapabilityGuard>
),
})
}
}
return routes
}
行业专属对话能力包(scope = industry)
有些能力是特定行业才有的,比如 HR 的招聘流程,不是独立页面,是在对话流里由 Agent 触发的:
ts
// features/packs/hr/conversational/recruitment-flow/manifest.ts
const manifest: CapabilityManifest = {
code: 'recruitment-flow',
scope: 'industry',
hostPack: 'hr',
routes: [],
menuItems: [],
conversational: {
triggerIntents: ['screen_candidates', 'schedule_interview', '筛选候选人'],
renderComponent: 'CandidateCard',
},
}
triggerIntents 定义触发意图,Agent 识别到就调这个能力,在对话流里渲染卡片。
通用能力写一份到处复用,行业专属能力跟着行业包走不污染别人。这是二级插件。
八、后端 SPI
前端自动发现了,后端也得跟上。不然前端有包后端没 API 就是个空壳。用 Spring SPI 做了套镜像设计。
VerticalPack 接口
java
// zero-common-core/.../vertical/VerticalPack.java
public interface VerticalPack {
String getId();
String getName();
String getRoutePrefix();
default String getVersion() { return "1.0.0"; }
default String getDescription() { return ""; }
}
跟前端 PackManifest 一一对应:code 对 getId(),name 对 getName(),basePath 对 getRoutePrefix()。
自动注册
java
// zero-common-core/.../vertical/VerticalPackRegistry.java
@Component
public class VerticalPackRegistry {
private final Map<String, VerticalPack> packs = new ConcurrentHashMap<>();
public VerticalPackRegistry(List<VerticalPack> registeredPacks) {
for (VerticalPack pack : registeredPacks) {
VerticalPack prev = packs.put(pack.getId(), pack);
if (prev != null) {
log.warn("VerticalPack id 冲突: {} 被 {} 覆盖", prev.getName(), pack.getName());
}
log.info("注册垂直行业包: id={}, name={}, route={}",
pack.getId(), pack.getName(), pack.getRoutePrefix());
}
}
public List<VerticalPack> listPacks() { return List.copyOf(packs.values()); }
public Optional<VerticalPack> getPack(String id) { return Optional.ofNullable(packs.get(id)); }
}
Spring 启动时构造器注入所有 VerticalPack Bean,实现了接口加 @Component 就自动注册。跟前端 import.meta.glob 一个思路。
按需启停
java
// zero-module-hr/.../HrVerticalPack.java
@Component
@ConditionalOnProperty(name = "zero.vertical.hr.enabled", havingValue = "true", matchIfMissing = true)
public class HrVerticalPack implements VerticalPack {
@Override public String getId() { return "hr"; }
@Override public String getName() { return "超级 HR"; }
@Override public String getRoutePrefix() { return "/api/v1/hr"; }
@Override public String getDescription() {
return "数字员工 HR 限界上下文:招聘、入职、员工、薪酬、考勤、组织架构";
}
}
matchIfMissing = true 表示不配就默认开。私有部署时客户只买客服模块,配置里把其余模块关掉,启动时连 Bean 都不创建。
暴露给前端
java
// zero-module-system/.../VerticalPackController.java
@RestController
@RequestMapping("/api/v1/packs")
public class VerticalPackController {
@GetMapping
public Result<List<PackInfo>> list() {
List<PackInfo> packs = verticalPackRegistry.listPacks().stream()
.map(PackInfo::from).toList();
return Result.success(packs);
}
@Data
public static class PackInfo {
private String id;
private String name;
private String routePrefix;
private String version;
private String description;
static PackInfo from(VerticalPack pack) { /* ... */ }
}
}
前端调 GET /api/v1/packs 拿后端注册的包列表,跟前端 packMap 交叉校验。后端关了哪个模块前端就不展示哪个。
还有个 PackController(在 zero-module-agent 下)也映射 /api/v1/packs,但管的是租户安装、订阅、订单这些写操作。安装流程:引擎取包详情 → 安全扫描 → 落 pack_install 记录 → 回调引擎 reload。
九、AppShell 共享外壳
所有工作台共用 AppShell,只是 packId 不同:
tsx
{ path: '/workspace', element: <SmartWorkspaceRoute />, children: [
{ path: '*', element: <AppShell packId="platform" />, children: [ /* 平台页面 */ ] },
]}
侧边栏由当前包的 manifest 驱动,navGroups、globalNav、globalSettings 拼出来。切包就是换 manifest,菜单跟着变。
角色守卫单独一套:
tsx
export function ProtectedRoute() {
const { isAuthenticated } = useAuth()
return isAuthenticated ? <Outlet /> : <Navigate to="/login" replace />
}
export function ZeroOpsRoute() {
const { isAuthenticated, user } = useAuth()
if (!isAuthenticated) return <Navigate to="/login" replace />
if (user?.role !== 'platform_admin') return <Navigate to="/workspace" replace />
return <Outlet />
}
十、主题色:CSS 变量 + Antd Token 双通道
每个行业有主色,用了两个通道。Antd Token 层面覆盖 colorPrimary:
ts
// theme/index.ts
export const professionThemes: Partial<Record<ProfessionCode, ProfessionTheme>> = {
ecommerce: { primary: '#16A34A' },
education: { primary: '#0EA5E9' },
hr: { primary: '#DB2777' },
// ...
}
export function getProfessionTheme(code) {
const accent = professionThemes[code]
if (!accent) return zeroWorksTheme
return {
...zeroWorksTheme,
token: { ...zeroWorksTheme.token, colorPrimary: accent.primary, colorLink: accent.primary },
components: {
...zeroWorksTheme.components,
Button: { ...zeroWorksTheme.components?.Button,
primaryShadow: `0 4px 20px ${hexToRgba(accent.primary, 0.18)}` },
},
}
}
CSS 变量层面运行时注入 :root:
ts
// theme/useProfessionTheme.ts
export function useProfessionTheme(professionCode: string | null | undefined): void {
useEffect(() => {
const theme = (professionCode && professionThemes[professionCode]) ?? DEFAULT_THEME
const root = document.documentElement
CSS_VAR_KEYS.forEach((k) => root.style.setProperty(`--profession-${kebab(k)}`, theme[k]))
return () => CSS_VAR_KEYS.forEach((k) => root.style.removeProperty(`--profession-${kebab(k)}`))
}, [professionCode])
}
踩坑:Antd token 不能写 var(--x)
一开始图省事把 colorPrimary 写成 var(--profession-primary),结果 Alert type="info" 渲染成黑块。Antd 的派生色算法算不了 CSS 变量,会退化成黑色。所以 Antd 组件必须给具体 hex 值,自定义样式才用 CSS 变量。
十一、脚手架脚本
手建文件夹容易漏文件,写了个脚本:
bash
node scripts/create-pack.mjs <code> <中文名> [basePath]
# 例: node scripts/create-pack.mjs supply-chain 供应链 /supply-chain
校验 code 是 kebab-case,目录存在会报错。一键生成 7 个文件:manifest、routes、示例页、API 封装、mock 数据。生成的代码能直接跑,有 mock 数据。脚本跑完会提示"registry 自动发现,无需手工注册"。
十二、新增一个行业包要几步
以"供应链"为例。
后端:
- 新建
zero-module-supply-chain模块 - 实现
SupplyChainVerticalPack implements VerticalPack,加@Component+@ConditionalOnProperty - 写业务 API
前端:
- 跑
node scripts/create-pack.mjs supply-chain 供应链 /supply-chain - 填 manifest(菜单、主题、能力包声明)
- 填 routes 和页面
- 在
OFFICIAL_PACK_PROFESSION加映射 - 可选:在
professionThemes加主题色
不改主路由、不改注册表、不复制布局、不动别的包。前端 import.meta.glob 自动发现,后端 Spring 自动注入,PackGuard 自动守卫。
十三、总结
几个核心取舍:
- 插件化的本质是收窄接口。前端只通过
PackManifest跟主应用通信,后端只通过VerticalPack跟框架通信,主应用对包内部零感知 - 前后端镜像 SPI。前端
import.meta.glob扫目录,后端 Spring 注入 Bean,都不手工登记。@ConditionalOnProperty让后端按部署环境启停,前端通过/api/v1/packs交叉校验 - 自动发现比手工登记好。注册从改代码变成放文件夹,冲突面归零,半成品包天然隔离
- 元数据同步加载、页面懒加载。菜单要用元数据所以得同步,页面代码大所以得懒加载
- 技术插件跟商业订阅对齐。
PackGuard+subscribedPacks+status+minPlan,代码插件同时也是商品 - 二级能力包。通用能力跨行业复用,行业专属对话能力跟着行业包走
适合多业务线、多行业、多租户的 SaaS,或者任何主应用加可插拔模块的中大型项目。如果你也在被复制布局、改主路由、硬编码主题折磨,可以参考。