前端项目为什么需要一个配置单一真相源?
上一篇,我们把 Monorepo 里"代码放哪里"聊完了:shared、services、ui 的边界,由它为什么变化、知道多少上下文、允许依赖谁决定。
顺手交代一句:构建工具那篇已经跳过一次票了。这次先把配置聊完,下一篇还债。
代码放好之后,项目里还有另一类东西,一直在问同一个问题,只是很少有人把它当成一个问题------配置。
- 端口写在
package.json的 dev script 里; - 应用名写在构建配置和
index.html里; - 页面入口隐式写在
src/pages的目录结构里; - 环境信息写在每个应用各自的
.env里; - 依赖版本散落在每个子包的
package.json里。
这些写法,单独看都没有问题。
但某一天你想把 admin 改名成 console,会发现这个"应用名"同时活在六个文件里;想升级 Vue,会发现三个子包挂着三个版本;想删掉一个下线页面,CI 里没有任何 diff 提示你入口还登记在别处。
这篇文章不讲配置中心平台,也不讲运维侧的配置管理。
我想结合 LYStack 中真实存在的几个文件------app.config.ts、page.config.ts、pnpm catalog 和 env 收口------聊清楚一件事:
配置的问题,从来不是"写在哪里",而是"同一个事实被写了几遍"。写了两遍,项目就开始依赖人肉同步;写了三遍,人肉同步也救不了。
一、先数一数,你的项目里藏着几份"同一个事实"
从一个最小的场景开始。
团队决定把管理后台从 admin 改名为 console。听起来只是改个名字。
但这个"名字"散落在这些地方:
text
apps/admin/package.json "name": "admin"
apps/admin/index.html <title>admin</title>
apps/admin/vite.config.ts build.outDir = 'dist/admin'
CI 部署脚本 deploy ./apps/admin/dist
上报 SDK 初始化 appName: 'admin'
本地存储 keys = ['admin-token', 'admin-theme', ...]
改名的 PR 会动六个文件。
Review 的时候,谁也说不清还有没有第七处。
再看端口。
dev 脚本里写着 --port 5173;另一个应用的 proxy 配置里写着 target: 'http://localhost:5173';E2E 的配置里写着 baseURL: 'http://localhost:5173'。
某天端口冲突,你把 5173 改成 5273,本地起来了,E2E 全红。
最后是依赖版本。
packages/ui 的 Vue 是 3.5.13,apps/admin 的 Vue 是 3.4.21------半年前脚手架生成时锁的,一直没人动。
平时相安无事。某天两个包在同一个页面里相遇,两个 Vue 实例共存,provide/inject 悄悄失灵。
这三类问题,每一个单独看都是小事:
- 改名多改几个文件,忍一下;
- 端口错了改回来就是;
- 版本不一致是历史遗留。
但它们的根因是同一个:
同一个事实,被写在了多个地方。
事实的第一份写在哪里,没人说得清;哪一份是最新的,靠记忆;有没有抄漏,靠运气。
于是项目开始依赖人肉同步,而人肉同步的可靠性,会随着团队人数线性下降。
判断一个项目的配置健不健康,其实只需要问一句:
改一个事实,要动几个文件?
答案是一,配置是健康的。答案是"看情况",配置已经开始腐化了。
二、单一真相源统一的是"事实",不是"文件"
"单一真相源"这个词很容易被理解歪。
它不是"把所有配置塞进一个文件",而是:
每一个事实,只在一个地方被定义;其他地方通过引用获得它,而不是再写一遍。
这里的关键区分是事实 和派生物。
端口是事实。dev script 里的 --port、proxy 的 target、E2E 的 baseURL,都是它的派生物。
应用名是事实。index.html 的 title、构建产物目录、部署路径、上报参数,都是它的派生物。
Vue 的版本是事实。二十个子包 package.json 里的 "vue": "^3.5.13",都是它的派生物。
派生物没问题,问题出在派生的方式。
配置腐化只有一条路径:复制。
text
健康: 腐化:
端口(事实) 端口(事实)
▲ ▲ ▲ │ 复制 │ 复制 │ 复制
│ │ │ ▼ ▼ ▼
dev proxy e2e dev proxy e2e
(引用同一处) (各自为政,迟早发散)
引用的时候,改一处,处处生效。
复制的时候,改一处,其他几处变成陈旧的谎言。
所以单一真相源的本质,不是某个具体的文件,而是一条纪律:
事实只定义一次,派生物必须指向定义处,而不是抄写定义处的值。
这也是为什么 LYStack 的真相源都用 .ts 文件,而不是再发明一套 YAML 或 JSON:
ts
import { resolveAppPort } from '../../app.config';
const port = resolveAppPort('example-rsbuild');
配置即代码。它可以被 import,可以被类型检查,可以被 IDE 跳转,改错了编译器直接报红。
一份抄在 yaml 里的端口号,改错了要到运行时才发现;一份 import 进来的端口号,改错了编辑器当场画波浪线。
还有一种更常见的误解,是把"单一"理解成"集中所有"。
一个大文件装下端口、入口、版本、环境、文案、开关,几百行,所有人只往里加,没人敢改。
这不是单一真相源,这是单一拥堵源。
一个真相源应该只回答一类问题。这个我们后面单独说。
三、app.config.ts:把端口收进一张登记表
先看 LYStack 仓库根目录的 app.config.ts(节选):
ts
/**
* 各应用开发服务端口表。
*
* 集中登记避免端口冲突:新增 app 时在此登记一处,构建侧按 appName 查表,
* 应用的 *.config.ts 不再各自硬编码端口。
*
* 新增应用由 `pnpm new:app` 在 PLOP_INJECT_PORT 锚点处注入端口条目。
*/
export const APP_PORTS: Record<string, number> = {
'example-rsbuild': 5273,
'example-rsbuild-mpa': 5373,
'example-vite': 5173,
/* PLOP_INJECT_PORT */
};
export const DEFAULT_DEV_PORT = 5200;
export function resolveAppPort(appName: string): number {
return APP_PORTS[appName] ?? DEFAULT_DEV_PORT;
}
就这么几十行,但有几个设计值得停一下。
第一,端口只有这一份。
全仓库要回答"某个应用跑在哪个端口",只有一个地方可以查。新增应用登记一处,构建侧按 appName 查表。
之前那个"E2E 写死 5173"的问题,在这里的结构上就不存在:E2E 也从这张表取值,端口变了跟着变。
第二,未登记是可预测的。
resolveAppPort 查不到时回退 DEFAULT_DEV_PORT,而不是随机分配或直接崩掉。
新应用忘了登记,行为依然可预测------这和 env 缺键直接抛错是两种策略,端口冲突的代价低,回退可用;环境变量缺配的代价高,宁可启动失败。后面讲 env 时再展开。
第三,注意文件头注释里的边界声明。
只放与运行环境无关的静态数据(端口、默认 HTML 配置等),零业务、零 import; 动态值(接口域名等"按环境切换"的配置)不放这里,统一走
.env.*+@repo/shared/env。
这条边界很关键:静态事实和运行时事实,是两类变化原因完全不同的东西。
端口在开发期就确定了,不随部署环境变;接口域名每个环境都不同。把它们塞进同一个文件,这个文件就会同时因为两类原因变化,最终谁也不敢动。
第四,"登记"这个动作本身也被自动化了。
text
/* PLOP_INJECT_PORT */
这行锚点是给代码生成器看的。pnpm new:app 创建新应用时,会把端口条目自动注入到锚点处。
你不需要"记得去登记"------生成器替你登记。人只做创造性决策(用哪个端口),机械性的抄写交给工具。
单一真相源最怕的就是"定义处虽然唯一,但没人知道要去那里登记"。把新增动作收进生成器,这个口子就堵上了。
四、page.config.ts:入口是一张显式的登记表,不是一个被扫描的目录
第二个真相源,多页应用的页面入口。
LYStack 的 apps/example-rsbuild-mpa/page.config.ts 长这样:
ts
import type { PageEntry } from '@repo/build-config/rsbuild';
/**
* 多入口唯一真相源。
*
* 构建侧只读这份清单决定入口,不扫描目录。登记了的页面才会成为 entry,
* 每个 name 既是入口名也是产出的 html 文件名。
*/
export const pages: PageEntry[] = [
{
name: 'index',
entry: './src/pages/index/main.ts',
title: 'LYStack · 首页',
},
{
name: 'about',
entry: './src/pages/about/main.ts',
title: 'LYStack · 关于',
},
/* PLOP_INJECT_PAGE */
];
很多人第一反应是:这不如自动扫描优雅。
MPA 项目的常见做法,是用 glob 扫 src/pages/*/main.ts,扫到什么入口就是什么。零配置,加页面不用改任何文件,看起来很先进。
我在上一篇文章里聊过一个观点:判断标准不该是"它被用了几次",而是"它为什么变化"。这里也一样:
入口配置的价值,不在于少写几行,而在于变更可 review、错误可检测。
自动扫描这两条都不占。
1. 目录名变成了隐式契约
扫描方案里,src/pages/index/ 这个目录名就是入口名,也就是产物 index.html 的文件名,很可能是线上 URL 的一部分。
这意味着:重命名一个目录,等于修改线上路由。
而在 git diff 里,它只显示为一个 mv。Reviewer 很难在几百个文件的 PR 里意识到"这次重命名改了 URL"。
2. 排除规则越补越厚
src/pages/ 下面迟早会出现不该成为入口的目录:components/、tests/、demo/、某人临时建的草稿目录。
于是扫描规则开始加排除项,排除项又误伤下一个目录。半年后,这个 glob 表达式没人看得懂,也没人敢动。
3. 元数据没有地方放
页面除了 entry 路径,还有 title、meta、是否启用某些构建特性。
扫描方案里这些信息没地方放,最后必然出现 pages/index/page.meta.js 这类按命名约定的 sidecar 文件------绕了一圈,配置又散落回去了,还多了一层"扫描 + 约定"的双重隐式。
4. 静默失败
删掉一个页面的 main.ts,入口静默消失,没有谁会报错。
反过来,entry 文件名从 main.ts 改成 index.ts,扫描目标悄悄 miss,构建产物里少了一个页面------如果你不逐个点开 html,可能到上线后才发现。
显式清单的行为正好相反:entry 指向一个不存在的文件,构建直接失败。错误在构建期爆发,而不是上线后。
自动扫描不是不能用在别处
公平地说,Nuxt 的 pages/ 文件路由没有这些问题。
但那不是"自研隐式扫描",而是框架把约定做成了强契约:约定的语义、优先级、escape hatch 都由框架定义和兜底。
问题从来不在扫描这个动作,而在于约定有没有执行机制保护。
自研 glob 扫描没有保护,它只是把"登记"这个动作从显式文件转移到了隐式目录结构里,省了三行配置,把风险留给了未来的每一次变更。
登记的成本也被消掉了
回到 page.config.ts:/* PLOP_INJECT_PAGE */ 锚点和 pnpm new:page 脚手架配合,新增页面时条目自动注入。
你不需要记住登记格式,不需要知道 PageEntry 有哪些字段------生成器替你写好,你只需要 review 它。
显式登记的好处一点没少,手写的成本一点没有。
五、pnpm catalog:依赖版本只写一遍
第三个真相源,不在这个项目的自有代码里,而在 pnpm-workspace.yaml 里(节选):
yaml
catalog:
vue: ^3.5.13
vue-tsc: ^3.0.0
'@vue/tsconfig': ^0.7.0
typescript: ^6.0.3
vite: ^8.1.5
'@vitejs/plugin-vue': ^6.0.0
axios: ^1.8.0
turbo: ^2.5.0
而每个子包的 package.json 里,不再写具体版本:
json
{
"name": "@repo/ui",
"peerDependencies": {
"vue": "catalog:"
}
}
catalog: 是 pnpm 的原生协议,意思是"版本以 workspace 的 catalog 为准"。
它直接消灭了本文开头的第一类问题:
- Vue 的版本在整个仓库只有一份定义;
- 升级 Vue 改一行,二十个子包同时生效;
- 结构上不可能出现"两个子包挂着两个 Vue"。
上一篇文章里 @repo/services 的 "axios": "catalog:" 就是同一个机制。
这里有个值得注意的选型思路:
好的真相源,尽量用平台能力,而不是自研魔法。
版本统一这件事,可以自己写脚本扫描所有 package.json、统一改写、CI 校验------很多团队真这么干。但 pnpm 把它做成了包管理器的原生能力,一行协议解决,还顺带解决了安装去重。
自研方案要维护、会出 bug、要说服新人;平台协议只需要存在于依赖文件里。
能用平台机制收口的真相源,不要自己造。
另外注意 catalog 里不只放了 dependencies:ESLint、Prettier、husky、commitlint 这些工程化依赖也在里面。
版本一致性这件事,对 dev 依赖同样成立------两个子包的 ESLint 大版本不一致,lint 结果就会因包而异,这种"规则本身不一致"比业务依赖不一致更隐蔽。
六、.env 与 @repo/shared/env:运行时事实的收口
第四个真相源,处理的是变化原因与前三个都不同的一类配置:随部署环境变化的运行时值。
LYStack 的做法可以概括成四条规则。
规则一:全仓库只维护一套环境文件
text
lystack/
├── .env.development
├── .env.test
└── .env.production
环境文件在仓库根目录,只有一套。
不是"每个应用各自一套 .env"------那会回到本文第一节的场景:测试环境加一个开关,要同步改 N 个应用的环境文件,改漏一个,那个应用的行为就和其他应用悄然不同。
规则二:PUBLIC_ 前缀决定谁能进客户端
只有 PUBLIC_ 前缀的变量会进入客户端产物。
这条契约画出了一条安全边界:密钥类配置天然被挡在客户端之外;同时它也是一个显式的暴露面清单------想知道"客户端到底能看到哪些配置",看前缀就知道,不需要审计构建配置。
规则三:读取只有一个入口
业务代码不直接碰 import.meta.env 或 process.env:
ts
import { getEnv } from '@repo/shared/env';
const apiBase = getEnv('PUBLIC_API_BASE_URL');
Vite 的 import.meta.env、Rsbuild 的 process.env、构建期聚合快照 __PUBLIC_ENV__,三套机制的差异被 @repo/shared/env 全部吸收。
上一篇文章提过"shared 不等于只能放纯函数",env 收口就是那条例外的实例:它感知平台,但它守的是底层平台边界。
还有一个容易被忽略的事实统一:"当前是什么环境"这个词,Vite 叫 mode,Rsbuild 叫 envMode。
同一个事实,两个构建工具给了两个名字。LYStack 把它们统一映射成 APP_ENV,业务代码只认这一个词。
这是"同一事实被工具生态写了两遍"时,收口的典型做法。
规则四:缺配置就大声失败
getEnv 读不到声明的键,直接抛错,而不是 ?? '' 静默兜底。
第一篇文章里聊过这个坑:?? '' 不会让构建失败、不会让页面白屏,它会让接口地址悄悄变成空字符串,请求打到当前域名上,问题拖到上线后才暴露。
对比一下 app.config.ts 里端口的策略:未登记回退默认端口,不抛错。
两种策略并不矛盾,判断标准是错误的代价:
| 配置 | 缺失后果 | 策略 |
|---|---|---|
| 端口未登记 | 回退默认端口,大概率可用 | 静默回退 |
| 环境变量缺失 | 接口地址错误、密钥缺失,上线事故 | 立即抛错 |
fail loudly 不是教条,是按代价分级。
七、哪些配置应该统一,哪些不应该
看到这里,容易产生一个冲动:把所有配置都收进真相源。
先忍住。
统一的判断标准,和上一篇判断代码归属的标准是同一个:
看它为什么变化、被谁消费,而不是看它"是不是配置"。
变化原因一致、被多个包消费的事实,才值得统一;变化原因各异、只被一处消费的配置,统一它只是把散落换成拥堵。
| 配置项 | 变化原因 | 应该统一吗 | 放哪里 |
|---|---|---|---|
| 依赖版本 | 上游发版 | ✅ | pnpm catalog |
| 开发端口 | 新增应用 | ✅ | app.config.ts |
| 页面入口 | 新增页面 | ✅ | page.config.ts |
| 接口域名、环境开关 | 部署环境 | ✅ | 根 .env + shared/env |
| TS / ESLint 基线 | 团队规范演进 | ✅ | 仓库级 base 配置 |
| 应用的路由表 | 应用自身需求 | ❌ | 应用内部 |
| Pinia 业务 store | 业务需求 | ❌ | 应用或领域包 |
| 页面私有参数(轮播间隔、默认页码) | 页面需求 | ❌ | 就近定义 |
| 文案、主题色 | 产品与设计 | ❌ | 就近定义或设计令牌 |
最后一行值得多说一句:设计令牌是"统一"的,但统一的是跨应用一致的视觉基础;某个页面自己的活动文案,离页面越近越好。
新增一条配置时,可以按这个顺序判断:
最重要的问题永远是最后那个分支之外的第一问:
这个事实,除了这里,还有别的地方需要知道吗?
只有一个消费者,就地定义就是最好的真相源。
八、别把真相源做成谁都不敢改的配置中心
统一做过头,会得到另一种怪物。
它的形态通常是:仓库根目录一个 config/index.ts,几百上千行,端口、入口、版本映射、CDN 地址、白名单、功能开关、部分文案,全部在里面。
所有文件 import 它,没有任何人敢改它。
每次提交必冲突,每次改动全仓回归。大家一边骂,一边继续往里加------因为"反正配置都放那里"。
破解它的办法,还是回到那条定义:
单一真相源统一的是事实,不是文件。一个真相源,只回答一类问题。
LYStack 把这件事拆得很清楚:
text
app.config.ts 只回答"构建期静态事实"(端口、默认 HTML)
page.config.ts 只回答"这个应用有哪些页面入口"
pnpm-workspace.yaml 只回答"依赖用什么版本"
根 .env.* 只回答"运行时按环境变化的值"
tsconfig.base.json 只回答"编译契约"
五个真相源,五类问题,互相不掺和。
除此之外,还有几条实践中很有效的守则:
- 真相源保持纯数据。
app.config.ts零业务、零 import,一个文件装得进一屏;装不进,说明它回答的问题已经超过一类了。 - 派生靠引用,不靠抄写。
import、catalog:、getEnv(),都是引用;任何"把这个值复制到那边"的动作,都是在制造第二份事实。 - 依赖方向单向。 构建侧读
app.config.ts,应用不反向耦合构建细节;真相源处在依赖图底部,和上一篇的shared同一个道理。 - 类型即文档。
PageEntry[]、Record<string, number>,字段含义由类型和注释就地说明,新人不需要一份随时过时的 wiki。 - 新增动作自动化。
PLOP_INJECT_PORT、PLOP_INJECT_PAGE锚点配合pnpm new:app/new:page,登记不依赖人的记性。 - 缺配置按代价决定静默还是报错。 端口回退,env 抛错,见上一节的对照表。
检验一套真相源设计得好不好,有个简单标准:
一个新人不读文档,只靠类型提示和报错信息,能不能把一个新应用跑起来。
能达到,说明真相源自己是自解释的;达不到,说明还有一份隐藏的真相源活在老员工的脑子里。
最后提醒另一个极端:也不要为三个应用去搭配置平台、做可视化配置中心。
真相源的规模应该和仓库的规模匹配。一个 YAML、一个 ts 文件能解决的事,平台化只会把问题从"改两个文件"变成"等平台排期"。
九、一个自查清单
不一定要马上动手改。可以先拿这六个问题,对自己现在的项目做一次体检:
- 改一个应用的名字,要动几个文件?能列全吗?
- 两个子包的同名依赖,版本怎么保证一致?靠 CI 报警,还是结构上不可能不一致?
- 新增一个页面,除了建目录还要登记什么?登记在哪,写在哪个文档里?文档和代码谁更新得更勤?
.env有几套?加一个环境变量要同步几个文件?漏同步会发生什么------构建失败,还是线上静默异常?- "当前是什么环境"这个事实,业务代码里有几种写法?
- 本地开发和 CI 构建,用的是同一个配置来源,还是两套各自维护的抄写?
如果大部分答案让你犹豫,这个项目已经在依赖人肉同步了。
最后
上一篇文章的结论是:包的边界不由文件长什么样决定,而由它为什么变化、知道多少上下文、允许依赖谁决定。
这一篇其实是同一个结论在配置上的投影:
- 一个事实为什么变化,决定它该进哪个真相源;
- 谁消费它,决定它该被引用还是就近定义;
- 它允许被写成几遍------答案永远是一遍。
代码的腐化从复制粘贴开始,配置的腐化从第二遍抄写开始。
两者用的是同一副药:让每个事实只有一个定义点,让所有派生指向它。
LYStack 项目地址:
text
https://github.com/liangy0323/LYStack
源码对应位置:
text
app.config.ts
apps/example-rsbuild-mpa/page.config.ts
pnpm-workspace.yaml
packages/shared/src/env
下一篇
下一篇,还上欠了两次的债:
前端项目怎么才能换构建工具,不动业务代码?
很多项目以为切换构建工具只是重写一份配置文件。真正动手才发现,import.meta.env、import.meta.glob、插件语法和入口规则,早已散落在业务代码的各个角落------业务代码认识了 Vite 的脸,就再也换不了发型。
下一篇,我会结合 LYStack 的 AppBuildOptions 中立契约和 Vite、Rsbuild 两套 adapter,拆解:
- 业务代码是怎么在不知不觉中被构建工具绑死的;
defineViteConfig({ kind: 'spa', ... })这层中立契约长什么样;- adapter 应该隔离什么,不应该隔离什么;
- 双构建支持什么时候是能力,什么时候是过度设计。
这次不跳票。