从单体仓库到 10 个子应用独立部署——大型企业级前端 Monorepo 架构设计与实践

一个团队维护着 10 条产品线的前端。它们共享同一套请求封装、同一批业务组件、同一套 lint 规则,但每条线各自迭代、各自发布。你会怎么组织这些代码?

把业务都塞进一个应用,公共代码好找了,发布节奏却绑在一起;拆成多个仓库,发布边界清楚了,共享包升级和工程规范又需要跨仓库协调。我们要的是共同维护代码的便利,发布节奏仍然各自独立。

这篇文章拆解的是一个真实落地的选择:一个 pnpm workspace,10 个子应用 + 6 个共享包,源码直引、独立部署。核心思路很简单——需要一起维护的代码放在一起,需要分别发布的应用保留为独立构建单元。

技术栈是 UmiJS 4、React 18、TypeScript 和 Tailwind CSS。仓库拓扑、职责分层、依赖治理这些思路可以迁移到别的工具链;但构建配置、权限初始化,以及部分共享业务模块依赖 Umi 的能力,不能直接照搬到 Vite 或 Next.js。下面会把这两类边界分开讲。

本文所有代码示例已做脱敏处理:组织 scope 统一写作 @yourorg/*(内部共享包)与 @design-system/*(第三方设计体系),子应用以业务域泛化命名,内网地址与私源 Token 一律用环境变量占位。架构结论与真实项目一致。


第一幕:为什么走上 Monorepo

为什么不是单体?

先澄清一个容易混淆的词:Monorepo 本身也是单仓库。这里说的「单体」,指多个业务域挤在一个应用、一个构建和发布单元里;Monorepo 虽然也在同一个仓库,但各项目的构建和发布彼此独立,两者讨论的维度不同。

只有一条产品线时,公共逻辑抽个 utils/ 目录就够了。产品线多起来后,问题变成了:共享代码怎么维护,业务应用又怎么分别发布?如果只是复制目录,一个日期格式化函数就可能在 A、B、C 三条线里逐渐长出不同版本。

真正难受的是修复的时候。你得找到所有副本,判断哪些差异是业务需要,哪些只是漏同步。复制粘贴省下的是写那几分钟,还的是往后每一次维护。

那拆成多仓库呢?这条路我们也认真算过账。十条产品线十个 Git 仓库,边界是清爽了,但换来三笔新开销:

  • 工程规范需要额外同步。即使把 ESLint、Prettier 等配置发布成公共包,各仓库也要分别升级,才能避免规则漂移。
  • 共享代码如果通过 npm 私有包分发,改动就要经过「改代码 → 构建 → 发版 → 调用方升级依赖」。这条链路有利于版本隔离,却增加了高频联调的步骤。
  • 跨仓库的原子提交不存在。一个同时修改公共组件和调用方的特性,需要协调多个提交与发布顺序。

Monorepo 想收敛的正是这两头的账:一套配置管住所有应用,共享代码源码直引,跨应用的改动一个提交就能落地。

什么时候值得上 Monorepo?我自己拍板就看三个量的乘积:

$$
\text{Monorepo 收益} \approx \text{共享代码量} \times \text{产品线数} \times \text{迭代频率}
$$

  • 共享代码量高:如果各产品线几乎没有共享逻辑,Monorepo 只是把无关代码硬凑在一起,收益为负。
  • 产品线数多:单条产品线不需要 Monorepo,一个普通仓库足矣。
  • 迭代频率高:如果共享包一年才改一次,Polyrepo 的发版成本可以接受;如果每周都在动共享逻辑,源码直引的即改即生效才有意义。

这是判断方向的经验式,不是代入数字计算的收益模型。在这个项目里,10 个应用共同消费 6 个共享包,复用范围从请求封装一直延伸到项目管理、成员管理等完整功能——共享已经不只是几个工具函数,才值得专门设计仓库边界。

仓库拓扑设计——10 应用 + 6 共享包

落到仓库里,第一个要拍的决定是项目放在哪里。工作区只纳入两类目录:

1
2
3
4
# pnpm-workspace.yaml
packages:
- 'packages/*'
- 'apps/*'

两行配置就划出了整个仓库的物理边界:apps/* 放要独立部署的应用,packages/* 放被复用的共享包。新人第一天拉下代码,不用问也知道哪些是产品、哪些是零件。

apps/ 下的 10 个子应用按业务域切,一个应用对应一条能独立部署的产品线。切分标准其实很土:同一业务域的页面、路由、service 收进同一个应用,跨域要复用的就往 packages/ 提。一段代码该放哪,我们就问一句:它只服务某一条产品线,还是多条线都要用?

先把目录和发布边界对齐,整体结构是这样的(应用名已泛化):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.
├── apps/
│ ├── app-analysis/ # 业务分析应用
│ ├── app-requirements/ # 需求管理应用
│ └── ... # 共 10 个独立应用
├── packages/
│ ├── utils/
│ ├── constants/
│ ├── request/
│ ├── hooks/
│ ├── components/
│ └── modules/
├── static/ # 待分发的公共静态资源
├── umi.config.ts # 各应用继承的构建基线
├── install-script.js # 静态资源分发脚本
└── pnpm-workspace.yaml

packages/ 下的 6 个共享包按职责划分:

共享包 负责什么 不该混入什么
utils 通用工具、参数处理与样式辅助 具体页面流程
constants 跨应用常量与枚举 网络请求与交互逻辑
request Axios 实例、鉴权与错误处理 页面布局
hooks 数据获取、树形交互、列状态等逻辑复用 完整业务页面
components 表格、表单等可复用 UI 能力 某个应用的路由编排
modules 项目管理、成员管理等跨应用业务功能 只服务单个应用的功能

分层期望是上层依赖下层。下图只画主要关系,箭头从使用方指向被依赖方,不是执行顺序:

graph TD
    Apps["apps/*"] --> Modules["modules"]
    Apps --> Components["components"]
    Apps --> Request["request"]
    Modules --> Components
    Modules --> Hooks["hooks"]
    Modules --> Request
    Components --> Hooks
    Components --> Request
    Hooks --> Request
    Request --> Utils["utils"]
    Hooks --> Utils
    Components --> Utils
    Modules --> Utils
    Components --> Constants["constants"]
    Modules --> Constants

包清单里,utils 不依赖其他内部包,request 依赖 utilshooks 依赖 requestutilsmodules 则依赖其余 5 个内部包。应用可以直接使用底层能力,不必为了调用工具函数逐层转发。

画出分层不等于约束自动生效。当前 components 的清单中还存在对自身的依赖声明;包内部是否形成循环,也不能只靠这张图判断。「希望维持的依赖方向」和「已经验证过的依赖关系」是两件事,后者需要工具检查。

这套方案属于多应用 Monorepo——各应用有自己的入口、路由和构建产物,共享发生在源码与工程配置层,和把 10 个应用装进同一个页面的运行时微前端是两回事。后文会介绍的开发加速能力 MFSU,虽然使用了 Module Federation,也不承担这里的业务应用装配。

包管理选型——为什么是 pnpm workspace

先分清工具解决的问题。包管理、任务编排和版本发布可以配合使用,不是选了其中一个就必须放弃另外几个:

要解决的问题 对应能力 本项目的做法
安装依赖、连接本地包 npm / Yarn / pnpm 的 workspace 能力 pnpm workspace
执行构建、复用任务结果 Turborepo / Nx 等任务编排工具 根脚本按应用定向调用,未引入额外编排工具
管理共享包版本与变更日志 Changesets 等发布工具 当前共享包以源码形式随应用使用

pnpm 适合这里,主要有三点。

首先是依赖文件复用。pnpm 的内容寻址存储与链接机制减少了相同依赖的重复存放,实际磁盘收益受版本组合和安装配置影响。

其次,依赖边界更清楚。非扁平的依赖布局减少了幽灵依赖——代码用了没在所属包里声明的模块。

不过这两点都是辅助。真正每天联调最直接的收益是第三点:用 workspace:^ 明确要求解析到工作区里的本地包。

1
2
3
4
5
6
7
// apps 或 packages 的 package.json
{
"dependencies": {
"@yourorg/components": "workspace:^",
"@yourorg/utils": "workspace:^"
}
}

这里要拆开两个机制:workspace:^ 负责找到本地包;包入口指向 src/index.ts,才让应用读到 TypeScript 源码。后者会在下一节展开。只配置 workspace 并不会自动获得源码直引。

依赖治理还有两个细节。

一是类型版本收敛。根 package.jsonresolutions 固定了 React 类型版本:

1
2
3
4
5
6
{
"resolutions": {
"@types/react": "18.2.79",
"@types/react-dom": "18.2.25"
}
}

需要注意的是,resolutions 常见于 Yarn;pnpm 项目里还要核对 overrides 配置与锁文件结果,确认全局覆盖是否真正生效。另外,类型包版本一致和 React 运行时只有一个实例是两件事,要分别检查。

二是把公共工程能力放在根层,但不把根目录当成业务依赖的兜底。根清单有 2 个 dependenciesumipdfjs-dist)和 16 个 devDependencies,后者主要是 ESLint、Prettier、Husky、Tailwind 等工具。pdfjs-dist 是一个具体例外:安装脚本会从根依赖中取 PDF 字符映射和 worker,再分发到各应用。各应用和共享包仍应声明自己直接使用的依赖。

pnpm 用一套工作区和锁文件把本地包连接、依赖安装与按应用执行脚本串起来。源码直引并非它独有的能力,依赖治理也不会因为选了它就自动完成。


第二幕:核心实践

共享包设计——源码直引的取舍

前面「源码直引」这个词反复出现,它具体怎么落地的?看每个共享包的 package.json 就明白了:

1
2
3
4
5
6
7
// packages/utils/package.json(6 个包结构一致)
{
"name": "@yourorg/utils",
"type": "module",
"main": "src/index.ts", // ← 入口直接指向 TS 源码
"sideEffects": false
}

关键是那行 "main": "src/index.ts"。正常的 npm 包这里会指向 dist/index.js,也就是构建产物;而我们这 6 个共享包的入口全部直指 src/index.ts 源码。也就是说,共享包自己根本不构建,应用直接吃它的 TypeScript 源码,跟着应用自己的构建器(UmiJS 底下是 webpack)一起编译。

这么做开发时很直接:改共享包的源码,由正在运行的应用开发服务器重新编译、热更新,不必先构建和发布共享包。生产环境则不同:共享源码只有进入某个应用的新构建产物、完成部署,才会在线上生效。已经部署的其他应用不会因为仓库里改了一行代码就自动变化。

包里的 sideEffects: false 是一项约定:告诉构建器可以按无副作用模块分析未使用的导入。如果后续加入样式导入或模块加载时的初始化逻辑,需要重新检查这项声明。

共享包最容易从一个大杂烩 common/ 长出来,什么都往里塞。我们用 utilshookscomponentsmodules 区分不同层次的复用。其中最值得展开的是请求基础设施和完整业务模块:前者统一跨页面的行为,后者决定应用页面究竟能薄到什么程度。

request 这个包有意思的地方在于,它没有导出一个万能的 request,而是按业务域拆成了若干个独立的 axios 实例:

1
2
3
4
5
6
7
// packages/request/src/index.ts
export { default as requestForBizV1 } from './requestForBizV1';
export { default as requestForInterface } from './requestForInterface';
export { default as requestForOperation } from './requestForOperation';
export { default as requestForSystem } from './requestForSystem';
export { default as requestForTest } from './requestForTest';
export { default as requestForWorkflow } from './requestForWorkflow';

入口实际导出 9 个业务请求实例,上面只列出其中一部分。它们按服务域设置 baseURL,鉴权逻辑并不各自复制:请求头注入、通用响应处理和 HTTP 错误提示主要复用公共拦截器,少数响应格式不同的服务再单独适配。

请求包内部有三类职责:

  • auth/ 管理令牌的存取与刷新。
  • requestInterceptors/ 在请求发送前注入令牌。
  • responseInterceptors/ 处理业务错误、许可证失效、权限变化和 HTTP 异常,并包含 401 刷新与请求排队分支。

这里有个容易被封装掩盖的边界:HTTP 请求成功不等于业务成功。公共响应类型里仍保留 successdataerrCodeerrMessage;拦截器提示了业务错误,Promise 也不一定转成 rejected。页面与服务层仍要按接口契约处理结果,不能把所有失败都交给一个 catch

这也说明 request 是面向当前浏览器应用的基础设施——它依赖设计体系的消息提示组件,使用浏览器存储和事件,不是拿去任何运行环境都能直接使用的纯网络库。

业务模块怎样让应用页面变薄

modules 装的是带完整业务语义的功能,而不只是几个 UI 组件。项目列表、成员管理、变更请求、文件库等各有自己的目录,由根 src/index.ts 统一导出;功能域内部再组织页面、服务和类型。

例如,业务分析应用里的项目列表页面,核心工作就是接入共享模块(应用标识已泛化):

1
2
3
4
5
import { ProjectList } from '@yourorg/modules';

export default function ProjectListPage() {
return <ProjectList appName="ANALYSIS" />;
}

共享的 ProjectList 内部组合 AntTable、列状态 Hook、下载 Hook 和服务请求,负责分页、搜索、操作反馈;应用页面只保留路由入口和应用身份。业务差异不再表现为复制整页代码,而是由模块接收上下文后选择相应行为。

读一个功能时,可以沿着这条路径往下找:

1
2
3
4
5
应用路由 → 应用页面壳 → 共享业务模块
├── components / hooks:界面与交互复用
└── services + types:业务接口与数据约定

request:鉴权与 HTTP 访问

不过复用越完整,对宿主的要求也越多。当前 ProjectList 会调用 Umi 的 useModel('@@initialState')history,读取当前用户、切换当前项目,并调用宿主提供的 fetchPermissions 刷新权限。它不是一个只要传入 appName 就能放进任意 React 工程的独立组件——宿主还必须提供约定的运行时状态,这是共享模块的真实接入成本。

源码直引省掉了发布步骤,也绑定了演进节奏

代价主要有两条:

  1. 共享修改扩大了验证范围。 类型、接口和行为变化可能影响多个调用方,但具体影响到哪些应用,要看实际引用。类型检查通过也不等于生产打包一定通过,不能把一个共享类型错误直接等同于「10 个应用同时构建失败」。
  2. 缺少独立选择共享包版本的机制。 包清单里虽然有 version 字段,同一工作树中的 workspace 引用仍然指向同一份源码,不能靠修改版本号让两个应用分别消费不同历史版本。应用级回滚则是另一回事:从历史提交重建,或在保留旧产物的前提下回滚部署,仍然可行。

这个选择适合共享包和应用紧密联调的阶段。若以后确实需要让调用方长期停留在不同共享包版本,才需要补上独立构建、发布和版本管理。即便补上这些,模块对 Umi 状态和路由的依赖也不会自动消失。

构建配置体系——根配置继承与覆盖

如果 10 个应用各写各的构建配置,等于把多仓库时代的「配置漂移」原样搬进 Monorepo。我们的解法是一套根配置、子应用继承再覆盖。(这一节和下面的权限那节,是全文最吃 UmiJS 的地方,不用 Umi 的话,看思路就够了。)

根配置 umi.config.ts 定义全仓库统一的构建基线:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// umi.config.ts(根,节选)
import { defineConfig } from 'umi';

export default defineConfig({
hash: true,
plugins: [
'@umijs/plugins/dist/access', // 权限
'@umijs/plugins/dist/initial-state', // 全局初始状态
'@umijs/plugins/dist/model', // 数据流
'@umijs/plugins/dist/styled-components',
'@umijs/plugins/dist/tailwindcss',
],
monorepoRedirect: { peerDeps: true }, // 见下文
mfsu: { strategy: 'normal' }, // 见下文
codeSplitting: { jsStrategy: 'granularChunks' },
npmClient: 'pnpm',
});

子应用的 config.ts 用一行展开继承它,再叠加自己的路由和代理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// apps/<app>/config/config.ts
import { defineConfig } from 'umi';
import config from '../../../umi.config';
import proxy from './proxy';
import routes from './routes';

const { REACT_APP_ENV = 'dev' } = process.env;

export default defineConfig({
...config, // ← 继承根配置全部字段
esbuildMinifyIIFE: true,
routes, // ← 覆盖:本应用路由
proxy: proxy[REACT_APP_ENV as keyof typeof proxy], // ← 覆盖:本应用代理
});

...config 把根配置摊开,再用 routesproxy 覆盖应用差异。共性上提、差异下沉,插件集、分包策略和 MFSU 就有了统一基线。

合并方式遵循 JavaScript 对象的浅合并语义。子应用重新声明 plugins 数组或 mfsu 对象会直接替换根层对应字段,需要保留其中一部分时必须显式合并。统一配置减少了重复,也意味着修改根配置前要检查各应用的覆盖情况。

graph LR
    A["umi.config.ts<br/>根配置 · 全仓库基线"] -->|"...config 展开"| B["apps/&lt;app&gt;/config/config.ts<br/>覆盖 routes + proxy"]
    B --> C["UmiJS 运行时<br/>最终生效配置"]

根配置里有三个值得单独说明的键,各解决各的问题:

  • monorepoRedirect: { peerDeps: true }:启用 Monorepo 依赖重定向,把 peer dependencies 也纳入处理。它需要和共享包的 React peer 声明、应用的 React 版本配合使用。
  • mfsu: { strategy: 'normal' }:MFSU 即 Module Federation Speed Up,通过依赖预打包和缓存加速开发。依赖不变且缓存命中时效果明显。
  • codeSplitting: { jsStrategy: 'granularChunks' }:细粒度分包策略,影响生成的 JavaScript chunk,服务于产物拆分与浏览器缓存。

独立构建与部署——选择哪个应用,就构建哪个应用

独立部署不仅是目录上的区分,也体现在命令入口上。根脚本只负责把构建请求转交给选定应用:

1
2
3
4
5
6
7
// 根 package.json,应用名已泛化
{
"scripts": {
"build:app-analysis": "pnpm --filter=./apps/app-analysis run build",
"build:app-requirements": "pnpm --filter=./apps/app-requirements run build"
}
}

子应用的 build 再调用 umi build。Jenkins 中选择部署哪个项目,就只构建那个项目,产物落在对应应用的 dist/,不会串成一次全仓库构建。

选定应用引用的共享源码会参与这次构建,没有选中的应用不会因此自动发布。这里要区分两件事:Jenkins 的项目选择解决了「构建谁」,但共享修改影响哪些调用方的验证并不自动完成。

缓存策略也分层:开发环境使用 Umi 的缓存能力,生产构建有意不启用缓存。是否调整生产策略,需要另行评估实际构建耗时和收益。

子应用间的导航与可插拔

10 个子应用独立部署,但它们不是彼此孤立的。用户在业务分析应用里看到一个关联需求,可能要跳到需求管理应用去处理;系统管理员也可能从系统管理后台切到许可证后台。子应用间存在频繁的相互跳转。

更现实的问题是:客户不一定购买全部 10 个子应用。有的客户只买了 2 个,有的买了 7 个。每个子应用必须具有可插拔能力——部署几个就能独立运行几个,不会因为缺少某个兄弟应用就报错或页面空白。

共享组件包里有两个组件专门解决这个导航问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// packages/components/src/PlatformSwitcher/index.tsx(简化)
type PlatformItem = {
appId: number;
key: string;
name: string;
url: string;
status: boolean; // ← 当前用户是否已购/有权访问
message?: string; // ← 未购时的提示文案
};

const PlatformSwitcher = ({ data }) => {
// 抽屉式切换器,嵌入各子应用侧栏
// status=true 渲染 <a href={url}>,status=false 灰显 + Tooltip
};

PlatformSwitcher 是嵌入各子应用侧栏的抽屉式切换器,用户点击后看到所有子应用列表。PlatformPage 则是门户首页,以卡片网格展示同样的应用入口。两者的逻辑一致:后端返回的 data 里每个平台有 status 字段,已购的渲染为可点击链接,未购的灰显并附带提示文案。

跨应用跳转走 <a href={url}> 全页导航——各应用有独立的域名路径或上下文前缀,彼此通过 URL 连接。

这套机制让子应用的可插拔不是空话:应用列表是动态的,由后端按客户的购买情况返回;前端不硬编码「10 个应用都在」的假设,缺少任何一个兄弟应用不影响当前应用的运行。

多环境与代理管理

这里的「环境」涵盖两件事:本地开发服务器连哪套后端,以及前端是不是在做生产构建。proxy.tsdevtestprod 保存代理目标,应用配置用 REACT_APP_ENV 选择其中一组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// apps/<app>/config/proxy.ts,节选;地址改为环境变量占位
export default {
dev: {
'/api/v1': {
target: process.env.DEV_API_HOST,
changeOrigin: true,
},
},
test: {
'/api/v1': {
target: process.env.TEST_API_HOST,
changeOrigin: true,
},
},
prod: {
'/api/v1': {
target: process.env.PROD_API_HOST,
changeOrigin: true,
},
},
};

一个具体例子是子应用的 start:demo:它设置 REACT_APP_ENV=prodUMI_ENV=dev,实际执行的仍是 umi dev——用来连生产后端做联调,使用时要注意真实业务数据的操作风险。

Umi 的 proxy 只服务于开发服务器,不会随静态产物变成线上反向代理。部署后的接口转发由实际网关或 Web 服务器承担。

原配置中的 pathRewrite: { '^': '' } 也容易被误读成「去掉接口前缀」——实际上 ^ 只匹配路径起点,替换为空不会移除 /system/api/v1。要剥离前缀需要明确匹配该前缀,并核对后端需要的路径。

上面的环境变量是文章的脱敏表达,实践中可以通过受控环境配置注入地址。

共享样式——代码复用了,样式也要进入应用构建

共享组件使用 Tailwind 时,还有一条不太显眼的依赖:应用构建必须扫描到共享包里的类名。业务分析应用的配置不仅扫描自己的页面、组件和布局,也扫描内部组件包、业务模块包和流程图组件的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// apps/<app>/tailwind.config.js,扫描与基础样式配置节选
module.exports = {
content: [
'./src/pages/**/*.tsx',
'./src/components/**/*.tsx',
'./src/layouts/**/*.tsx',
'./node_modules/@yourorg/components/src/**/*.tsx',
'./node_modules/@yourorg/modules/src/**/*.tsx',
'./node_modules/@design-system/xflow/**/*.{js,ts,jsx,tsx}',
],
corePlugins: {
preflight: false,
},
};

这和 workspace 的连接方式是配套的:共享包通过本地链接进入应用的依赖目录,Tailwind 再从这些路径收集类名。如果只扫描 src/pages,共享组件的 JavaScript 可能已经打进产物,只有共享组件使用的工具类却没有生成。

视觉规范来自同一套设计体系:全局样式引入 @design-system/colors 和基础组件样式,Tailwind 将其中的颜色映射成 primaryerror 等语义色。关闭 Preflight 是为了减少 Tailwind 默认样式重置与已有组件体系的冲突。

这也给新增共享包提了个醒:接入不仅是补一条依赖。新包如果携带 Tailwind 类名,要检查消费应用的扫描范围;改了色彩或基础样式,也要验证实际页面,而不只是确认 TypeScript 编译通过。

权限架构设计——用户、项目与页面状态一起初始化

企业级中后台的权限经常细到「这个按钮你能不能点」,还会随着当前项目变化。以业务分析应用为例,权限建立在用户和项目上下文上,切换项目就要重新获取:

graph TD
    Start["进入非登录相关页面"] --> User["获取当前用户"]
    Start --> Projects["获取最近项目列表"]
    Projects --> Current["结合 URL 与本地记录确定当前项目"]
    User --> Permissions["按用户与当前项目获取权限"]
    Current --> Permissions
    Permissions --> State["getInitialState 返回初始状态"]
    State --> Access["access.ts:权限串转布尔标志"]
    Access --> Route["路由权限检查"]
    Access --> Button["按钮级控制"]

getInitialState() 先并行获取用户信息和最近项目列表,再确定当前项目,最后根据用户 ID 与项目标识请求权限。权限请求依赖前两步的结果,因此不是三个请求一起并行发出。

后端用 resource:action 形式描述权限点,例如 order:listorder:save。初始化结果除了用户、项目、权限数据,还包含 fetchUserInfofetchUserProjectsfetchPermissions 等刷新方法,供后续交互使用。前面共享项目列表切换项目后重新获取权限,依赖的就是这份宿主约定。

access.ts 负责把字符串翻译成布尔开关,把「用户有哪些权限串」转换成「一组语义化的布尔标志」,页面里判断权限就不用再跟字符串较劲:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/access.ts(节选,脱敏)
export default function (initialState: InitialState) {
const { permissions } = initialState ?? {};

return {
// AC_M_ 前缀 = 菜单/页面级权限
AC_M_ORDER_MANAGE: !!permissions?.includes('ordermanage:list'),
// AC_B_ 前缀 = 按钮/操作级权限
AC_B_ORDER_SAVE: !!permissions?.includes('order:save'),
AC_B_ORDER_DELETE: !!permissions?.includes('order:delete'),
AC_B_ORDER_DOWNLOAD: !!permissions?.includes('order:download'),
// 其余权限映射省略
};
}

命名上有个小约定:AC_M_* 是菜单/页面级,AC_B_* 是按钮/操作级。看到前缀就知道这个权限管的是「能不能进这个页面」还是「能不能点这个按钮」,维护时不用回去翻定义。

链路的最后一段是 wrappers 路由守卫。路由配置里挂上守卫,进页面前先校验权限:

1
2
3
4
5
6
7
// routes.ts
{
path: '/order/manage',
component: './Order/Manage',
access: 'AC_M_ORDER_MANAGE',
wrappers: ['@/wrappers/access'], // ← 进入前校验 AC_M_ORDER_MANAGE
}

access 字段给出当前路由的权限键,wrapper 通过 useRouteProps() 读取它,再用 useAccess() 检查是否允许渲染 Outlet。按钮级控制则在页面内读取 access.AC_B_ORDER_DELETE,决定是否展示操作入口。

维护成本也在这里:新增一个受控操作,需要同步后端权限定义、access.ts 映射、路由或按钮使用处。项目切换和权限刷新也要一起考虑,避免用户已经切换上下文,界面仍沿用旧权限。

最后一条边界:前端权限决定界面如何展示,接口仍需校验当前用户是否有权操作目标项目或资源,隐藏按钮替代不了服务端鉴权。

工程规范落地——提交流水线

10 个应用共同维护,代码风格和提交规范不能只靠口头约定。根目录用 Husky、lint-staged 和 Commitlint 把检查接到本地提交流程中:

graph LR
    A["暂存变更、准备提交信息<br/>可使用 Commitizen"] --> B["开始 Git 提交"]
    B --> C["pre-commit:lint-staged"]
    C --> D["按文件类型执行格式化与 lint"]
    D --> E["commit-msg:Commitlint"]
    E --> F["检查通过后完成提交"]

钩子挂了两个:pre-commit 触发 lint-staged,管代码质量;commit-msg 触发 commitlint,管提交信息的质量。

lint-staged 只碰暂存区里的文件,不做全量扫描,所以快,并且按扩展名走不同的检查链:

1
2
3
4
5
6
7
// .lintstagedrc
{
"*.{md,json}": ["prettier --cache --write"],
"*.{js,jsx}": ["umi lint --fix --eslint-only", "prettier --cache --write"],
"*.ts?(x)": ["umi lint --fix --eslint-only", "prettier --cache --parser=typescript --write"],
"*.{css,less}": ["umi lint --fix --stylelint-only", "prettier --cache --write"]
}

Markdown 和 JSON 只走 prettier;JS/TS 先用 eslint 修一遍再 prettier;样式交给 stylelint 加 prettier。按类型分发,该查的查到,不相干的不乱动。

提交这一步,我们封装成了一条命令:

1
2
3
4
5
6
7
// package.json
{
"scripts": { "commit": "git add . && cz" },
"config": {
"commitizen": { "path": "./node_modules/cz-conventional-changelog" }
}
}

pnpm run commit 会先 git add .,再唤起 cz 的交互式命令行,一步步引导你选 type(feat/fix/docs…)、填 scope、写 subject,生成规范的 Conventional Commits 提交信息。

直接执行 git commit 时,commit-msg 也会按 @commitlint/config-conventional 校验提交信息,具体是否要求 scope 等细节取决于配置规则。

这条便捷命令还有一个前提:git add . 会把当前目录下的变更一并暂存,提交前要确认没有混入无关修改。本地钩子降低了漏检查的概率,但不能替代服务端或 CI 门禁;lint、类型检查、测试和构建各自覆盖不同的问题。

静态资源与私源治理

最后聊两个容易被忽略、但在 Monorepo 里挺磨人的细节:静态资源怎么共享,私有 npm 源怎么配。

公共静态资源集中放在根 static/,包括鉴权页面、首屏 loading 脚本、图片、模板和编辑器定制资源。但最终发布的是每个应用自己的 public/ 与构建产物。

应用的 postinstall.js 会检查 CI_MODULE:未设置时执行本应用初始化;设置后只在与当前应用匹配时执行:

1
2
3
4
5
6
7
8
const cp = require('child_process');
const copyAssets = require('../../install-script');

const app = 'app-analysis';
if (process.env.CI_MODULE === undefined || process.env.CI_MODULE === app) {
cp.spawn('umi', ['setup']);
copyAssets(app);
}

这个条件控制安装后的初始化和资源复制,和依赖安装范围、应用构建选择无关——后者由前面的构建命令决定。

install-script.js 再按应用用途装配资源:

资源 来源 分发范围
鉴权页、favicon、脚本、图片 static/ 所有应用
文档模板 static/template/ 2 个相关应用
TinyMCE 及语言、定制插件 应用依赖 + 根 static/ 7 个相关应用
Monaco Editor 应用的 node_modules/ 2 个相关应用
PDF 字符映射和 worker pdfjs-dist 依赖 所有应用

这里的细节很重要:编辑器本体来自 npm 依赖,不是所有资源都放在 static/。源码集中维护,发布前按应用复制,让每个应用都能带着所需资源独立部署。相应的维护成本是,公共资源更新后需要重新执行分发,并重建、部署受影响的应用。

我们的第三方设计体系组件(@design-system/*)托在企业私有 npm registry 上,靠 .npmrc 按 scope 配置源和鉴权:

1
2
3
4
# .npmrc
registry=https://registry.npmmirror.com/
@design-system:registry=https://<企业私有 registry>/
//<企业私有 registry>/:_authToken=${NPM_TOKEN}

要点是只把 @design-system 这个 scope 指到私源,其余照走公共镜像,两个 registry 各管各的。_authToken 一定要用环境变量注入,不要硬编码进仓库——真实项目里这一行就是最常见的凭证泄露点。

这里的 ${NPM_TOKEN} 同样是脱敏后的安全示例。安装时报 401 Unauthorized,先确认失败的包和 registry,再检查 Token 是否注入、是否过期、是否有对应包的读取权限。


第三幕:反思与演进

演进反思与下一步

这套架构已经把源码复用和独立发布接在了一起。接下来更值得问的,是共享边界能否继续承受业务变化。

首先是 modules 的职责。它同时承载多个业务域,又消费宿主的状态和路由,维护成本不只是「包有多大」,还包括修改一个领域时需要了解多少其他上下文。比起直接拆成更多 npm 包,更靠前的工作是看清各功能域的公共入口、宿主依赖和调用方,必要时把宿主状态、导航或服务能力显式传入,减少隐藏约定。

其次是共享修改的验证范围。只部署一个应用,不意味着修改共享模块时只需检查这个应用;但反过来,验证多个调用方也不等于必须一起发布。类型、接口、权限切换和共享样式,都应按实际使用关系选择验证范围。

如果继续演进,我会按需求出现的顺序考虑:

  1. 先把边界变得可检查。 清理自依赖等异常声明,检查跨层导入,明确共享模块需要哪些宿主能力;对关键调用方补足相应验证。
  2. 需要独立版本时,再独立发布。 如果不同应用需要长期停留在不同的共享包版本,再评估构建产物发布与 Changesets。单有版本号,或单独加一个构建脚本,都不足以获得版本隔离。
  3. 构建耗时成为瓶颈时,再评估缓存。 当前按 Jenkins 所选项目单独构建,开发有 Umi 缓存,生产构建有意不启用缓存。Turborepo 或 Nx 是否值得引入,应基于实际耗时、可复用任务和生产策略,而不是应用数量。
  4. 客户组合需求推动可插拔演进时,评估微前端。 当前子应用通过 PlatformSwitcherPlatformPage 做全页跳转,已经具备按客户购买情况动态组合的能力。但如果后续需要在同一个页面内嵌入其他应用的模块、共享登录态或统一布局,就需要认真评估微前端方案——届时 MFSU 已经用到的 Module Federation 机制可以作为基础之一,但运行时应用加载、样式隔离和跨应用通信都需要另外设计。

这些演进方向按需求出现,不是预先排好的迁移计划。源码直引加统一配置支撑了当前协作,后续补什么取决于哪种代价真正开始影响开发和发布。

回到开头那个问题:10 条产品线的前端到底怎么组织?我们的答案是,把共享源码和工程约定收进同一个工作区,把路由、构建产物和部署选择留给各应用。真正需要持续维护的,是这两条边界之间的接口:共享模块要求宿主提供什么,改动影响谁,以及哪些应用需要重新验证和发布。工具可以继续换,这些问题不会替我们消失。