把 React 前端做成「插件市场」:前后端联动的行业包可插拔架构

把 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 里。

这么干的问题:

  1. 布局复制 N 份,改一个顶栏交互要同步改 N 个行业
  2. 主路由越来越长,每加一个行业都动公共文件,经常冲突
  3. 主题色散落在各处,换一个颜色要翻一遍
  4. 所有行业代码打到一个包里,用户用不用都得加载
  5. 跟 SaaS 按租户订阅开通的模式对不上
  6. 前端加了包,后端还得手动配路由配模块,两边不同步

目标是把行业抽象成可插拔的 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 的目录直接跳过,半成品包不会影响主应用
  • packMapReadonlyMap,注册表构建完就不可变
  • 加一个行业包,这段代码不用动

五、路由装配

主路由把 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,前端守卫查的是 hrOFFICIAL_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 一一对应:codegetId()namegetName()basePathgetRoutePrefix()

自动注册

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 驱动,navGroupsglobalNavglobalSettings 拼出来。切包就是换 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 自动发现,无需手工注册"。


十二、新增一个行业包要几步

以"供应链"为例。

后端:

  1. 新建 zero-module-supply-chain 模块
  2. 实现 SupplyChainVerticalPack implements VerticalPack,加 @Component + @ConditionalOnProperty
  3. 写业务 API

前端:

  1. node scripts/create-pack.mjs supply-chain 供应链 /supply-chain
  2. 填 manifest(菜单、主题、能力包声明)
  3. 填 routes 和页面
  4. OFFICIAL_PACK_PROFESSION 加映射
  5. 可选:在 professionThemes 加主题色

不改主路由、不改注册表、不复制布局、不动别的包。前端 import.meta.glob 自动发现,后端 Spring 自动注入,PackGuard 自动守卫。


十三、总结

几个核心取舍:

  • 插件化的本质是收窄接口。前端只通过 PackManifest 跟主应用通信,后端只通过 VerticalPack 跟框架通信,主应用对包内部零感知
  • 前后端镜像 SPI。前端 import.meta.glob 扫目录,后端 Spring 注入 Bean,都不手工登记。@ConditionalOnProperty 让后端按部署环境启停,前端通过 /api/v1/packs 交叉校验
  • 自动发现比手工登记好。注册从改代码变成放文件夹,冲突面归零,半成品包天然隔离
  • 元数据同步加载、页面懒加载。菜单要用元数据所以得同步,页面代码大所以得懒加载
  • 技术插件跟商业订阅对齐。PackGuard + subscribedPacks + status + minPlan,代码插件同时也是商品
  • 二级能力包。通用能力跨行业复用,行业专属对话能力跟着行业包走

适合多业务线、多行业、多租户的 SaaS,或者任何主应用加可插拔模块的中大型项目。如果你也在被复制布局、改主路由、硬编码主题折磨,可以参考。

相关推荐
光影少年1 小时前
RN Bridge 原理
前端·react native·react.js
daols881 小时前
vue vxe-context-menu 通用右键菜单组件使用
前端·javascript·vue.js·vxeui
程序员黑豆1 小时前
鸿蒙应用开发实战:轻松实现列表上拉加载更多
前端·华为·harmonyos
hamber2 小时前
GoGBA 上架半年记
前端
IT_陈寒2 小时前
Python的finally居然不等同于Go的defer,差点坑惨我
前端·人工智能·后端
恋猫de小郭2 小时前
给 AI 的 Agent 实现指南,可控 Agent 的关键
前端·人工智能·ai编程
米码收割机2 小时前
【Python】Flask+SQLite_web 宠物领养系统 (源码+文档)【独一无二】
前端·python·flask
寒水馨2 小时前
macOS下载、安装 Tailwind CSS-v4.3.3(附安装包tailwindcss-macos-arm64)
前端·css·macos·tailwind css·utility-first·css 框架·实用优先
shawxlee2 小时前
vue3+axios挑战最简洁实用的配置封装+接口调用:请求拦截器、响应拦截器、通用请求方法(单个请求/并发请求/下载文件)、统一接口管理
前端·经验分享·vue·接口·axios·api