前端项目为什么需要一个配置单一真相源?

前端项目为什么需要一个配置单一真相源?

上一篇,我们把 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 业务需求 ❌ 应用或领域包
页面私有参数(轮播间隔、默认页码) 页面需求 ❌ 就近定义
文案、主题色 产品与设计 ❌ 就近定义或设计令牌

最后一行值得多说一句:设计令牌是"统一"的,但统一的是跨应用一致的视觉基础;某个页面自己的活动文案,离页面越近越好。

新增一条配置时,可以按这个顺序判断:

flowchart TD A[新增一条配置] --> B{它是什么层级的事实} B -->|仓库级:版本、编译基线、代码规范| C[catalog / tsconfig.base / eslint] B -->|应用级:端口、入口、应用名| D[app.config.ts / page.config.ts] B -->|运行时:域名、开关、密钥引用| E[根 .env + shared/env] B -->|只属于一个页面或组件| F[留在使用现场,就近定义]

最重要的问题永远是最后那个分支之外的第一问:

这个事实,除了这里,还有别的地方需要知道吗?

只有一个消费者,就地定义就是最好的真相源。


八、别把真相源做成谁都不敢改的配置中心

统一做过头,会得到另一种怪物。

它的形态通常是:仓库根目录一个 config/index.ts,几百上千行,端口、入口、版本映射、CDN 地址、白名单、功能开关、部分文案,全部在里面。

所有文件 import 它,没有任何人敢改它。

每次提交必冲突,每次改动全仓回归。大家一边骂,一边继续往里加------因为"反正配置都放那里"。

破解它的办法,还是回到那条定义:

单一真相源统一的是事实,不是文件。一个真相源,只回答一类问题。

LYStack 把这件事拆得很清楚:

text 复制代码
app.config.ts          只回答"构建期静态事实"(端口、默认 HTML)
page.config.ts         只回答"这个应用有哪些页面入口"
pnpm-workspace.yaml    只回答"依赖用什么版本"
根 .env.*              只回答"运行时按环境变化的值"
tsconfig.base.json     只回答"编译契约"

五个真相源,五类问题,互相不掺和。

除此之外,还有几条实践中很有效的守则:

  1. 真相源保持纯数据。 app.config.ts 零业务、零 import,一个文件装得进一屏;装不进,说明它回答的问题已经超过一类了。
  2. 派生靠引用,不靠抄写。 import、catalog:、getEnv(),都是引用;任何"把这个值复制到那边"的动作,都是在制造第二份事实。
  3. 依赖方向单向。 构建侧读 app.config.ts,应用不反向耦合构建细节;真相源处在依赖图底部,和上一篇的 shared 同一个道理。
  4. 类型即文档。 PageEntry[]、Record<string, number>,字段含义由类型和注释就地说明,新人不需要一份随时过时的 wiki。
  5. 新增动作自动化。 PLOP_INJECT_PORT、PLOP_INJECT_PAGE 锚点配合 pnpm new:app / new:page,登记不依赖人的记性。
  6. 缺配置按代价决定静默还是报错。 端口回退,env 抛错,见上一节的对照表。

检验一套真相源设计得好不好,有个简单标准:

一个新人不读文档,只靠类型提示和报错信息,能不能把一个新应用跑起来。

能达到,说明真相源自己是自解释的;达不到,说明还有一份隐藏的真相源活在老员工的脑子里。

最后提醒另一个极端:也不要为三个应用去搭配置平台、做可视化配置中心。

真相源的规模应该和仓库的规模匹配。一个 YAML、一个 ts 文件能解决的事,平台化只会把问题从"改两个文件"变成"等平台排期"。


九、一个自查清单

不一定要马上动手改。可以先拿这六个问题,对自己现在的项目做一次体检:

  1. 改一个应用的名字,要动几个文件?能列全吗?
  2. 两个子包的同名依赖,版本怎么保证一致?靠 CI 报警,还是结构上不可能不一致?
  3. 新增一个页面,除了建目录还要登记什么?登记在哪,写在哪个文档里?文档和代码谁更新得更勤?
  4. .env 有几套?加一个环境变量要同步几个文件?漏同步会发生什么------构建失败,还是线上静默异常?
  5. "当前是什么环境"这个事实,业务代码里有几种写法?
  6. 本地开发和 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 应该隔离什么,不应该隔离什么;
  • 双构建支持什么时候是能力,什么时候是过度设计。

这次不跳票。

相关推荐
进击的明明几秒前
TypeScript速通笔记(下)
前端·面试·typescript
lzhdim4 分钟前
C#不为人知的10个魔法特性:资深开发者也会震惊的底层奥秘
java·前端·javascript·算法·c#
zach1 小时前
前端落地实战:Next.js 项目 Nginx + PM2 生产部署全流程(附踩坑总结)
前端·pm2·next.js
overmind1 小时前
oeasy教h5前端001 修改的乐趣_直接修改在线网页
前端
只睡四小时1 小时前
JS 手写 D-pad 空间导航:电视端焦点引擎实战
android·开发语言·前端·javascript·ecmascript·hls·空间导航
恋猫de小郭1 小时前
Android 原生的 Compose A2UI 也来了,你还抱着 XML 养老吗?
android·前端·flutter
庄园特聘拆椅狂魔1 小时前
从连连看到粒子消散——前端小游戏与视觉特效的技术选型
前端
JudithHuang2 小时前
React 常用 Hooks
前端·react.js·前端框架
sycmancia3 小时前
Qml——Window元素使用
前端
每天都好困3 小时前
03 AR 投影与误差修正:把地图上的点贴到视频画面上
前端