在软件工程团队里,真正决定项目走向的往往不是某一次代码提交,而是一类被称为“工程政策决策”的选择。它们决定了代码应该怎么写、依赖应该怎么引、提交应该怎么过、失败应该怎么查。代码提交是对当前需求的响应,工程政策则是对后续所有开发的约束。如果把这些长期生效的规则写进配置文件、脚本、CI 流程和文档,它们会像政策一样被反复执行,影响范围远超单个功能模块。因此,把它看成“项目生命周期中最重要的政策决定之一”并不夸张。这篇文章会用一次 TypeScript 工程规范统一作为例子,展示如何把一个重要决策从想法变成可执行、可验证、可回滚的工程政策。
1. 为什么“工程政策决策”比一次代码提交更影响项目
1.1 工程决策是“政策”,不是“代码”
代码提交可以被 review、可以被 revert,单个功能的改动影响范围通常可控。工程政策一旦通过 npm 脚本、CI 检查、代码规范、分支模型固定下来,它会持续作用在每一次提交上。如果政策本身有问题,团队要付出的不只是改代码的成本,还有习惯迁移、历史代码兼容和流程调整的成本。
一个具体的例子:如果团队决定把所有 TypeScript 文件的未使用变量一律视为错误,这个决定在当天只是改一条 lint 规则,但在接下来几个月里,每位开发者的每次提交都可能被它影响。它可以避免大量潜在 bug,也可能在某个特殊场景下成为阻力。因此,制定工程政策前必须像写代码一样严谨,不能只凭“我觉得这样更好”就全量推送。
工程政策决策也不同于日常功能迭代。功能迭代可以快速上线、快速回滚,工程政策则会影响一批人的工作方式。它需要更完整的生命周期管理:先定义问题,再调研方案,然后试点验证,最后推广和复盘。
1.2 一次重要决策的典型生命周期
工程政策决策不是“开一次会,定一个规则”这么简单。尤其是在多人协作的项目中,跳过其中任何一环,都可能让政策在落地时走样。
推荐的生命周期如下:
- 问题定义:收集团队痛点和可量化证据,明确这个决策要解决什么问题。
- 方案调研:准备至少三个候选方案,比较各自成本、收益和风险。
- 试点验证:在某个小范围模块或项目中实施,观察执行效果和团队反馈。
- 评审推广:根据试点结果决定全量推广、调整方案或放弃。
- 落地执行:输出配置、脚本、文档、培训和自动化检查,让规则自动生效。
- 复盘回滚:设置回滚条件,定期检查政策是否仍然适合当前项目状态。
很多团队把工程政策当成一次性任务,配置一加、文档一写就算完成。实际项目中,最值钱的部分反而在试点验证和定期复盘。因为规则是静态的,团队、依赖、业务需求却在持续变化。
1.3 哪些场景属于工程政策决策
适合走完整决策流程的,通常不是单点功能,而是影响所有开发者日常工作的规则。常见场景如下:
| 场景 | 影响范围 | 典型示例 |
|---|---|---|
| 代码规范统一 | 所有开发者的代码风格 | ESLint、Prettier、TypeScript 严格模式 |
| 依赖治理 | 依赖引入、升级和安全审核 | 锁文件、依赖审查、版本升级策略 |
| 分支策略 | 合并流程和发布节奏 | trunk-based、Git Flow、PR 模板 |
| 配置管理 | 环境配置的组织和变更 | 12-factor 配置、环境变量校验 |
| 安全基线 | 密钥、鉴权、敏感数据处理 | 禁止硬编码密钥、强制加密存储 |
| CI 流程 | 每次提交和合并的通过条件 | 类型检查、单测、构建门禁 |
这些决策的共同特点是:一旦执行,就不会只影响某一个功能,而是影响所有后续功能。所以在制定它们时,需要像对待“政策”一样,考虑执行成本、例外情况和回滚方式。
2. 动手前先完成问题定义和方案调研
2.1 问题定义:写出决策需要消除的具体痛点
没有清晰的问题定义,方案评估就会变成主观争论。建议在项目文档或协作卡片里写清楚四件事:
- 现状:目前代码风格和类型检查靠个人习惯,没有统一执行,PR 里经常出现格式差异。
- 期望:所有 TypeScript 代码在提交前通过类型检查、Lint、格式化检查。
- 差距:缺少统一配置和校验入口,CI 也没有相关步骤,导致问题只能在 review 时人肉发现。
- 影响范围:团队全部前端和 Node.js 项目,涉及约 20 人规模的交付流程。
问题定义越具体,后面的方案评估越容易。不要写“提高代码质量”这种无法验证的描述,而应该写“类型错误数量降到 0”“Lint 告警数量为 0”“格式化差异文件数为 0”这类可测量结果。
2.2 方案收集:至少准备三个候选方案
只准备一个方案,很容易让评审变成“同意或不同意”的立场之争。至少准备三个方案,可以让讨论集中在“约束强度多少合适”,而不是“要不要做”。
以 TypeScript 规范统一为例:
- 方案 A:仅添加统一的 ESLint 和 Prettier 配置,由开发者本地手动执行。
- 方案 B:在方案 A 基础上加入 pre-commit 钩子,提交前自动执行检查和格式化。
- 方案 C:在方案 B 基础上再加入 CI 检查,任意分支的提交都必须通过后才能合并。
三个方案的自动化程度、改造成本、团队阻力是依次递增的。方案 A 最轻,但依赖个人自觉;方案 B 能拦截本地提交,但存在被绕过的可能;方案 C 最严格,但需要先处理好存量代码的兼容问题。
2.3 候选方案对比表
| 维度 | 方案 A:本地执行 | 方案 B:pre-commit 钩子 | 方案 C:CI 门禁 |
|---|---|---|---|
| 自动化程度 | 低 | 中 | 高 |
| 改动范围 | 配置文件 | 配置 + 脚本 | 配置 + 脚本 + CI 流程 |
| 执行成本 | 最低 | 中 | 较高 |
| 被绕过的可能性 | 高 | 中 | 低 |
| 对存量代码的要求 | 低 | 中 | 高 |
| 适合阶段 | 刚开始推动规范 | 团队接受度较高 | 已经有统一规范且需要强制 |
选择方案时,要结合团队规模和历史代码质量。如果团队只有 3 人且都是长期维护者,方案 A 可能已经够用。如果团队超过 10 人且需要处理大量外部贡献,方案 C 更合适。不要一开始就选最严格的方案,更不要一直停留在最宽松的方案。
3. 把决策落成“策略即代码”:一个 TypeScript 规范实例
3.1 选择最小可试点模块
不要一开始全量改造所有目录。选择一个相对独立、代码量适中、团队成员熟悉的模块作为试点。比如src/components下的一个基础组件目录。这样可以快速验证规则是否合理,避免一次引入大量冲突。
试点模块需要明确的验收边界:
- 试点范围内代码必须通过 TypeScript 类型检查。
- Lint 告警数量必须为 0。
- 格式化差异文件数必须为 0。
- 试点模块不允许使用
// eslint-disable-next-line绕过规则,除非在文档中说明原因。
如果试点模块做完后发现某个规则频繁误报,这就是调整规则的信号,而不是让团队强行适应的信号。
3.2 统一依赖与配置文件
工程政策要稳定,离不开可重复的依赖环境。如果团队本地是 ESLint 8,CI 是 ESLint 9,很多规则行为会不一致。先统一工具链版本,再谈配置。
在package.json中固定依赖版本,并让本地和 CI 使用相同的安装方式:
{ "name": "example-frontend", "private": true, "engines": { "node": ">=20.0.0", "npm": ">=10.0.0" }, "scripts": { "lint": "eslint . --max-warnings=0", "typecheck": "tsc --noEmit", "format:check": "prettier --check .", "prepare": "husky" }, "devDependencies": { "eslint": "^9.0.0", "prettier": "^3.0.0", "typescript": "^5.0.0", "typescript-eslint": "^8.0.0", "husky": "^9.0.0", "lint-staged": "^15.0.0" } }engines字段的作用是声明项目需要的 Node.js 和 npm 版本。CI 和本地安装依赖时,建议使用npm ci,它会严格按照锁文件安装,避免因版本漂移导致规则不一致。
3.3 用脚本保证本地和 CI 行为一致
本地手动执行的命令,必须和 CI 执行的命令完全一致,否则会出现“本地能过、CI 报错”的典型问题。一个常见做法是把所有检查都收敛到package.json的 scripts 中:
npm run lint npm run typecheck npm run format:checkCI 中直接复用这些 script,而不是重新写一条等价但可能不同的命令。lint脚本中的--max-warnings=0很关键,它要求 ESLint 不允许任何 warning,否则失败。这样可以避免“告警只是提醒”的心态,让规则以更稳定的方式长期生效。
3.4 用 flat config 配置 ESLint
ESLint 9 默认使用 flat config,配置文件通常是eslint.config.mjs。以 TypeScript 项目的常见配置为例:
import js from "@eslint/js"; import tseslint from "typescript-eslint"; export default tseslint.config( js.configs.recommended, ...tseslint.configs.recommended, { ignores: ["node_modules/", "dist/", "coverage/"], }, { files: ["src/**/*.ts"], languageOptions: { parserOptions: { project: "./tsconfig.json", }, }, rules: { "@typescript-eslint/no-floating-promises": "error", "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }] }, }, );这个配置做了几件事:
- 先继承 ESLint 官方推荐规则和 TypeScript ESLint 推荐规则。
- 忽略
node_modules、dist、coverage目录。 - 对
src下的 TypeScript 文件开启类型感知。 - 将
no-floating-promises设为错误,防止异步操作被遗漏。 - 将
no-unused-vars设为错误,但允许以下划线开头的参数。
parserOptions.project需要指向tsconfig.json。如果项目较大,可以为 lint 单独准备tsconfig.eslint.json,只包含需要检查的文件,避免拉入过多编译目标。
3.5 配置 TypeScript 严格模式
工程政策的力度很大程度上取决于编译选项。下面的tsconfig.json示例开启了几项对长期维护非常有利的选项:
{ "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "noImplicitOverride": true }, "include": ["src"] }strict: true会同时开启一系列严格检查,包括没有隐式 any、空值检查等。noUncheckedIndexedAccess: true会让数组索引访问返回T | undefined,强制处理越界情况。exactOptionalPropertyTypes: true会区分“属性不存在”和“属性值为 undefined”,更容易暴露接口设计的边界问题。noImplicitOverride: true要求子类覆盖父类方法时显式写override。
这些选项在试点阶段可能让存量代码出现大量报错。这正是试点的作用:在可控范围内评估修正成本。
3.6 用 pre-commit 钩子拦截不合格提交
工具链配置好了之后,用husky和lint-staged让检查在提交时自动执行:
npx husky init然后生成.husky/pre-commit:
npx lint-staged在package.json中配置 lint-staged:
{ "lint-staged": { "*.ts": ["eslint --fix", "prettier --write"] } }这样每次提交时,只有暂存区里的.ts文件会被自动修复和检查。它不会一次检查整个仓库,所以运行速度更快,也更容易被团队接受。
注意:pre-commit 钩子只约束本地提交,并不能保证所有分支都经过完整检查。因此,严格模式下必须把 CI 门禁作为兜底。
4. 用试点结果验证决策,再决定是否全量推广
4.1 设定可量化的成功指标
试点阶段不能只看“能不能跑通”,还要看几个可量化指标:
| 指标 | 计算方式 | 目标值 | 验收点 |
|---|---|---|---|
| 类型错误数量 | tsc --noEmit输出错误条数 | 0 | 命令退出码为 0 |
| Lint 告警数量 | eslint . --max-warnings=0输出条数 | 0 | 命令退出码为 0 |
| 格式化差异文件数 | prettier --check .输出差异文件数 | 0 | 命令退出码为 0 |
| 试点代码修复耗时 | 从接入配置到全部检查通过的时间 | 不超过 1 个工作日 | 记录在试点文档中 |
| 团队阻塞时间 | pre-commit 每次额外耗时 | 单次不超过 30 秒 | 通过 git hook 日志或计时观察 |
不要只看“代码是否好看”,要看流程是否稳定。如果一个规则导致团队每天需要花费大量时间处理误报,那这个规则本身就需要调整。
4.2 小范围试点过程
试点过程可以按下面的顺序执行:
- 在试点模块应用新配置。
- 修复所有类型和 Lint 问题。
- 运行
npm run typecheck、npm run lint、npm run format:check,收集结果。 - 记录修复耗时和常见错误列表。
- 让试点模块的负责人验证功能行为没有变化。
修复过程中,重点记录哪些错误是真实问题,哪些是工具误报。比如noUncheckedIndexedAccess确实会发现很多潜在越界风险,但也会在确实保证非空的情况下增加额外判断。如果这类错误在试点模块中出现频率过高,可以考虑分目录逐步开启,而不是全局强制。
4.3 结果评审和推广 gate
试点完成后,需要明确下面几种情况的处理方式:
- 如果试点模块所有检查通过,且修复工作量小于预期,则进入全量推广。
- 如果出现大量误报或配置冲突,则回到方案调研阶段,调整规则后再试。
- 如果修复耗时过高,考虑分阶段放宽规则,或者先只启用一部分子集。
- 如果团队反馈某个规则无法理解,补充文档或示例,而不是强行解释。
推广 gate 不一定是全有或全无。可以按模块分批推进,先把最稳定的目录切到新规范,再逐步扩展到其他目录。
4.4 验证命令和预期输出
在试点模块和本地环境,执行以下命令验证:
npm run typecheck npm run lint npm run format:check正常结果:三个命令均没有错误输出,退出码为 0。可以在 bash 中追加验证:
echo $?如果看到类似下面的输出,说明类型检查发现问题:
src/example.ts:10:3 - error TS7005: Variable 'count' implicitly has an 'any' type. Found 1 error.这类错误的修复方式通常是给变量补上显式类型,或者根据上下文推导出更精确的类型,而不是在变量上写any。工程政策的目的是消除隐性风险,因此不建议通过关闭规则来绕过。
注意:本地验证通过不代表所有场景通过,还需要在 CI 中执行同一组命令,并检查 Node.js 版本、依赖安装方式和锁文件是否一致。
5. 落地后的宣传、培训和回滚预案
5.1 发布变更说明和升级指南
工程政策落地本质上是团队协作流程变化。如果只改配置不发说明,团队会在踩坑后自行绕过规则。建议在仓库docs/目录下新增一份简短文档,例如docs/engineering-policy-typescript.md,内容包含:
- 这次决策要解决什么问题。
- 新规则适用的范围和生效时机。
- 推荐的执行命令和修复示例。
- 升级路径和常见 FAQ。
- 例外申请流程和回滚方式。
在创建合并请求时,把文档链接放到 PR 描述中,并让技术负责人 review。这样其他成员知道规则不是个人喜好,而是经过评审的工程决策。
5.2 制定回滚条件
工程政策也需要“回滚预案”。提前定义以下回滚条件:
- 新配置导致构建无法通过,且修复时间超过预期。
- 工具新版本出现兼容性问题,导致本地和 CI 行为不一致。
- 团队反馈规则严重阻塞日常工作,且可以通过数据证明。
- 试点指标结果显示规则带来的修复成本远大于收益。
回滚方式不一定是删除配置。在 CI 中临时允许失败也是一种降级方式,但要设置有效期,比如“一周内必须修复问题并恢复强制检查”,避免悄悄关闭检查。
如果政策已经写了文档并且个人习惯已经开始改变,那么“回滚”不只是 git revert,还需要同步更新文档和团队通知。否则会出现“配置已经回滚,但文档还写着必须检查”的混乱状态。
5.3 常见阻力和处理方式
| 阻力 | 常见原因 | 处理建议 |
|---|---|---|
| 认为规则太严格 | 和长期写代码习惯冲突 | 用误报率和实际修复日志说明,保留例外申请流程 |
| 老项目大量报错 | 存量代码长期没有接受严格检查 | 分目录推进,先用 ignore 排除非核心目录,设定最终截止时间 |
| 工具版本升级导致规则变化 | 升级成本被低估 | 锁版本,不要跟着 latest 走;升级前单独走一次试点 |
| 本地和 CI 行为不一致 | 版本不一致或安装方式不同 | 使用npm ci,相信我:确认engines和锁文件 |
| 规则被绕过 | 缺少强制兜底 | 以 CI 合并检查为准,pre-commit 只作为效率工具 |
强调一点:工程政策的目标是降低长期维护成本,不是制造新的阻碍。如果某项规则在多数场景下都在增加成本,那就应该调整规则本身。
6. 常见失败模式与排查路径
6.1 现象一:配置改了没生效
现象:本地明明改了eslint.config.mjs,但运行 lint 后仍使用旧规则。
可能原因:
- 当前位置存在多个配置文件,例如旧的
.eslintrc.js和新的eslint.config.mjs同时存在。 - 运行的是全局 ESLint,而不是项目本地依赖。
node_modules/.cache中存在旧缓存。
排查方式:
npx eslint --print-config src/example.ts这条命令会输出实际生效的配置。如果输出和预期不一致,说明配置文件优先级有问题。也可以直接查看项目根目录的配置文件列表:
ls -la | grep eslint解决方案:
- 删除旧
.eslintrc*文件。 - 统一使用
npx运行项目本地依赖。 - 清理 ESLint 缓存后重新运行。
6.2 现象二:本地通过 CI 失败
现象:本地/run npm run lint和npm run typecheck都通过,但 CI 上出现错误。
可能原因:
- 本地 Node.js 版本和 CI 不一致。
package-lock.json未提交,导致 CI 安装了不同版本依赖。- CI 执行了和本地不同的命令。
排查方式:
node -v npm -v git diff package-lock.json解决方案:
- 使用
package-lock.json和npm ci安装依赖。 - 在
engines中固定 Node.js 主版本。 - 确保 CI 只调用 package.json scripts 中的命令。
6.3 现象三:团队不愿意执行
现象:规则文档写好了,pre-commit 和 CI 都配好了,但团队成员仍然在合并请求中跳过检查,或者在 review 时忽略失败。
可能原因:
- 规则没有和合并请求门禁绑定,所以失败不影响合并。
- 团队认为规则不是共识,而是某个人单方面加的。
- 规则本身误报太多,导致信任度下降。
排查方式:
- 查看 CI 流水线里是否把检查设为 required。
- 回顾决策过程是否有多人参与和评审记录。
- 统计误报日志,看是否有高频出现的无效报错。
解决方案:
- 在代码托管平台开启“分支保护”,要求必须通过 CI 才能合并。
- 把决策文档和 ADR 放在仓库中,让后续加入成员也能看到背景。
- 定期回顾规则,删除无效规则,保留真正能避免 bug 的规则。
6.4 工程政策排查清单
| 问题 | 检查点 | 推荐命令或操作 |
|---|---|---|
| 本地命令没有生效 | 工作目录、配置文件优先级、缓存 | npx eslint --print-config src/example.ts |
| 规则被忽略 | 配置文件是否在正确位置 | ls -la,查看是否存在多个 eslint 配置 |
| CI 与本地不一致 | 依赖版本、Node 版本、脚本名称 | node -v、npm -v、npm ci |
| 某些目录未检查 | ignore 配置是否正确 | 检查eslint.config.mjs和.prettierignore |
| 老代码大量报错 | 存量代码未迁移 | 使用渐进式 ignore 并设定截止日期 |
| pre-commit 不执行 | husky 是否安装、钩子是否生效 | 检查.husky/pre-commit和git config core.hooksPath |
| 团队绕过门禁 | CI 是否设为 required | 在代码托管平台开启分支保护 |
7. 工程决策的全生命周期最佳实践
7.1 用 ADR 记录决策过程
ADR(Architecture Decision Record,架构决策记录)是记录工程政策决策的常用方式。它能让后来的维护者知道“为什么这样定”,而不是只看到结果。
推荐模板:
# ADR 001:使用 TypeScript 严格模式和 ESLint 作为默认质量门禁 ## 背景 团队在代码评审中频繁发现未处理 null、隐式 any、未捕获 Promise 等问题。现有流程缺少自动检查,问题只能依赖人肉发现。 ## 决策 从试点模块开始,统一使用 TypeScript strict 模式,并在 pre-commit 和 CI 中加入 ESLint、Prettier 检查。Lint 和类型检查失败时禁止合并。 ## 后果 短期需要修复存量代码,长期可以降低 review 中的琐碎问题,减少运行时异常。 ## 备选方案 - 方案 A:仅提供配置文件,不设强制检查。 - 方案 B:只加 pre-commit,不加 CI 门禁。 - 方案 C:全量改造,不做试点。 ## 相关链接 - 文档链接 - 试点合并请求链接 - 修复耗时统计ADR 不需要很长,但必须包含背景、决策、后果和备选方案。有了它,后续讨论就有据可查。
7.2 一份可复用的工程决策检查清单
每次准备制定新的工程规范时,建议对照下面的清单逐项检查:
- [ ] 是否用可量化的方式描述了当前问题?
- [ ] 是否至少调研并对比了两个候选方案?
- [ ] 是否确认了试点范围和成功指标?
- [ ] 是否通过统一的本地脚本和 CI 命令验证结果?
- [ ] 是否为存量代码准备了渐进式迁移路径?
- [ ] 是否准备了回滚条件和降级方案?
- [ ] 是否编写了变更说明和文档?
- [ ] 是否指定了负责人和评审人?
- [ ] 是否设置了定期复审日期?
- [ ] 是否记录了决策过程和备选方案?
这个清单也可以用于代码审查。当一个新的“工程政策”被提出时,先对照清单讨论,而不是直接开始写配置。
7.3 决策之后:定期复审与持续演进
工程政策不是永久不变的。依赖会有新版本,项目会有新模块,团队成员也会变化。即使当初选择了一条完全合理的规则,也可能在一年后变得不合时宜。
建议在每次迭代结束后检查几个问题:
- 当前的门禁指标是否仍然达成?
- 是否有团队必须高频使用的豁免规则?
- 工具链是否有安全升级,是否值得再走一次试点?
- 文档和 ADR 是否和现状一致?
与宏观“政策决定”类似,工程政策的价值不在于一次性制定得多完美,而在于它能被自动执行、能验证效果、能定期复审。把最重要的决策交给配置文件和 CI 门禁,而不是交给个人自觉,这是工程团队长期稳定运转的关键。
下一次当团队里有人说“我们要不要统一一下这里的规范”时,先不要急着改配置。把这次讨论当成一次工程政策决策,走完问题定义、方案对比、试点验证、文档落地和回滚预案,再让规则生效。这样做出来的决定,才会真正成为项目生命周期里那些值得被记住的重要政策。