本节目标:把「团队约定」翻译成机器能执行的检查。读完后你会有一条三层防线——编辑器即时格式化、提交前钩子拦截、CI 全量兜底——并且清楚 ESLint 与 Biome 各自适合什么团队、类型感知检查值不值得它的成本、提交信息规范为什么值得坚持。
1.3 代码规范与提交门禁(ESLint / Biome / husky)
前两节我们解决了「能跑」和「类型正确」。但一个真实项目还有第三类问题:代码风格不统一、提交信息无法追溯、本地能跑 CI 却挂。这类问题不会让程序崩溃,却会让每一次 code review 都浪费在缩进和命名上。
规范类问题的特点是:靠自觉一定失败。不是团队不认真,而是人在赶进度时必然会跳过检查。所以唯一有效的办法是把检查自动化,并且放在「不做就不能继续」的位置上。这就是本节要搭的三层防线:
| 层次 | 触发时机 | 拦截能力 | 反馈速度 |
|---|---|---|---|
| 编辑器 | 保存文件 | 只提示,不阻断 | 毫秒 |
| 提交钩子 | git commit | 阻断本次提交 | 秒级 |
| CI | push / PR | 阻断合并 | 分钟级 |
三层缺一不可:只有编辑器等于没有约束;只有 CI 则反馈太慢、来回修很痛苦;只有钩子则可以被 --no-verify 绕过。
1.3.1 格式化:先解决「不该讨论的事」
规范里最容易吵架、也最没价值的是格式:缩进几格、要不要分号、单引号还是双引号。这类问题应当交给工具一次性定死,之后任何人不再讨论。
当前有两条技术路线:
| 方案 | 组成 | 优点 | 缺点 |
|---|---|---|---|
| 经典组合 | ESLint + Prettier | 生态最成熟,规则最全,插件最多 | 两套配置、两套忽略文件,偶尔互相打架 |
| 一体化 | Biome | 单一二进制、极快、配置一份 | 规则数量与插件生态仍在追赶 |
选型建议:如果你的项目需要 React、Vue、Jest 等大量框架特定规则,选 ESLint + Prettier;如果是纯 Node 服务端或想减少配置维护成本,Biome 已经够用且速度优势明显。
先给 Biome 的最小配置:
// biome.json
{
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noExplicitAny": "warn"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"semicolons": "always"
}
}
}
配好后的两条命令:
npx biome format --write .
npx biome check --write .
biome check 是「lint + 格式化」的合体,一次跑完。它不需要 node_modules 之外的任何东西,启动开销接近于零。
1.3.2 ESLint flat config 与 typescript-eslint
如果选经典组合,先装依赖:
pnpm add -D eslint @eslint/js typescript-eslint eslint-config-prettier prettier
注意这里装的是 typescript-eslint 这个聚合包,而不是老的 @typescript-eslint/parser + @typescript-eslint/eslint-plugin 两个包——前者是后者的官方整合,配置更简洁。
ESLint 9 起默认使用 flat config(eslint.config.js),不再支持 .eslintrc。这是近年最大的一次配置范式变更,写法如下:
// eslint.config.js
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import prettier from "eslint-config-prettier";
export default tseslint.config(
{
ignores: ["dist/**", "node_modules/**", "coverage/**"],
},
js.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
{
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_" },
],
},
},
prettier,
);
几个关键点:
recommendedTypeChecked是类型感知的规则集,它需要parserOptions.projectService指向你的tsconfig.json。import.meta.dirname是 Node 20.11+ 提供的,等价于老写法里的一串fileURLToPath组合。- 最后一项
prettier是eslint-config-prettier,作用是关闭所有与 Prettier 冲突的格式规则。它必须放在最后,否则会被后面的规则覆盖。
consistent-type-imports 这条规则值得单独说:它会自动把只导入类型的语句改写成 import type,这与上一节的 verbatimModuleSyntax 是同一套约束的两个入口。前者在 lint 层修,后者在编译层拦。
1.3.3 类型感知规则:值得,但要知道代价
recommendedTypeChecked 里最有价值的两条,恰好都是纯语法规则做不到的:
// no-floating-promises:忘记 await 的 Promise
async function save(): Promise<void> {
// 这行会报错:Promise 被创建但未处理
db.write(data);
// 正确
await db.write(data);
}
// no-misused-promises:把 async 函数当同步回调传
// 这行会报错:Promise-returning function provided to a void-returning parameter
items.forEach(async (item) => {
await process(item);
});
第一条能抓住「忘了 await 导致错误被吞掉」这个极难排查的 bug;第二条能抓住「forEach 里用 async 导致并发不受控」这个经典陷阱。这两个问题光看代码几乎看不出来,必须依赖类型信息。
代价也要说清楚:类型感知检查会显著变慢。因为它需要构建完整的类型信息,规则运行时开销比纯语法规则高一个数量级。在大型项目里,eslint . 从两秒变成三十秒是常态。
所以工程上的做法是分工:
| 场景 | 配置 | 目的 |
|---|---|---|
| 编辑器 | 全量类型感知规则 | 即时反馈,慢一点无妨 |
| pre-commit(lint-staged) | 只对暂存文件跑,可关类型感知 | 控制在 3 秒内 |
| CI | 全量类型感知 + 全量文件 | 慢也没关系,但要全 |
lint-staged 的作用正在于此——它只把本次提交涉及的文件喂给检查工具,而不是全仓库。
1.3.4 husky 与 lint-staged
先装依赖并初始化:
pnpm add -D husky lint-staged
pnpm exec husky init
husky init 会创建 .husky/ 目录并写一个示例 pre-commit 脚本。把内容改成:
# .husky/pre-commit
pnpm exec lint-staged
然后在 package.json 里配置 lint-staged:
{
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix --max-warnings=0",
"prettier --write"
],
"*.{json,md,yml,yaml}": [
"prettier --write"
]
}
}
执行顺序是从右到左、逐条串行,且每条命令只接收本次暂存的文件列表。三个细节值得注意:
--max-warnings=0让警告也视为失败。不写这一条,no-explicit-any这类设为warn的规则就形同虚设。--fix会自动修复能修的问题,并把修复后的结果重新加入暂存区。这意味你提交的代码已经被格式化过。- 如果项目用 Biome,把
lint-staged换成biome check --write --no-errors-on-unmatched即可,配置更短。
还要加一条「提交信息」的钩子:
# .husky/commit-msg
pnpm exec commitlint --edit "$1"
$1 是 Git 传给钩子的参数,指向本次提交信息的临时文件路径。
1.3.5 commitlint:让提交信息可被程序读取
提交信息看起来是小事,但它是自动化发版与变更日志的唯一数据源。如果格式随意,feat: 与 feature: 混用,语义化版本工具就无法判断该升 minor 还是 patch。
pnpm add -D @commitlint/cli @commitlint/config-conventional
// commitlint.config.js
export default {
extends: ["@commitlint/config-conventional"],
rules: {
"type-enum": [
2,
"always",
["feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert"],
],
"subject-case": [2, "never", ["upper-case"]],
"header-max-length": [2, "always", 72],
},
};
格式约定是 <type>(<scope>): <subject>,例如:
feat(auth): 支持通过 OAuth 2.1 登录
fix(queue): 修正重试次数计算导致的死循环
chore(deps): 升级 typescript 到 5.6
格式不对时提交会被直接拒绝:
⧗ input: update code
✖ subject may not be empty [subject-empty]
✖ type may not be empty [type-empty]
✖ found 2 problems, 0 warnings
这套约定的长期回报很高:变更日志可以自动生成,git log --oneline 一眼能看出改动性质。想了解提交规范在团队协作流程中的位置,可延伸阅读 Git 工作流
与 DevOps 文化与 CI/CD
。
1.3.6 CI 兜底:钩子会被绕过
本地钩子有一个根本弱点:git commit --no-verify 一行就能跳过。所以必须有一道在服务端执行的检查。
# .github/workflows/ci.yml
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9.12.0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
- run: pnpm lint
- run: pnpm build
四个要点:
--frozen-lockfile保证 CI 用的依赖版本与pnpm-lock.yaml完全一致,不会因为解析到新版本而出现「本地过 CI 挂」。cache: pnpm复用依赖缓存,能把安装时间从一分钟降到几秒。- 顺序有讲究:
typecheck最快且最可能失败,放前面能尽早止损。 - 这里不跑
lint-staged,而是跑全量的pnpm lint,因为钩子只查了暂存文件,历史文件可能从未被检查过。
CI 的完整写法(矩阵、缓存策略、并发控制)可延伸阅读 GitHub Actions Node.js CI 。第 18 章会把它扩展成完整的交付流水线。
1.3.7 常见坑与错误信息
坑一:Parsing error: ... was not found by the project service
现象:ESLint 报某个文件不属于任何 tsconfig。
原因:类型感知检查要求每个被 lint 的文件都在某个 tsconfig.json 的 include 范围内。上一节的 tsconfig.json 只 include 了 src、scripts 和 tsup.config.ts,如果新增了 test/ 目录,必须同步加进 include,或在 ESLint 里给该目录单独配 projectService。
坑二:Prettier 与 ESLint 规则互相覆盖
现象:保存时格式化一次,lint –fix 又改回去,来回抖动。
原因:没加 eslint-config-prettier,或加的位置不对。它必须在配置数组的最后一项。
坑三:husky 钩子不生效
现象:改了 .husky/pre-commit 但提交时毫无反应。
原因有三种:core.hooksPath 被其他工具改过(git config core.hooksPath 查看);文件没有可执行权限(chmod +x .husky/pre-commit);项目不在 Git 仓库根目录。逐个排查即可。
坑四:lint-staged 只跑暂存文件,导致漏查
现象:CI 上报一堆本地从未见过的 lint 错误。
原因:某些文件从未被修改过,因此从未进入 lint-staged 的检查范围。解决方式是在 CI 里跑全量检查(1.3.6 节已经这么做),并定期在本地跑一次 pnpm lint。
坑五:--no-verify 成为习惯
现象:某次紧急提交跳过了钩子,之后每次都跳。
原因:钩子太慢。这是唯一需要认真对待的原因——把 lint-staged 的执行时间压到 3 秒以内,跳过它的动机就消失了。速度是门禁能否被遵守的决定性因素。
1.3.8 门禁自检清单
| 检查项 | 命令 | 通过标准 |
|---|---|---|
| 格式化可用 | pnpm format 或 npx biome format . | 无 diff |
| Lint 通过 | pnpm lint | 0 error 0 warning |
| 类型检查通过 | pnpm typecheck | 无输出 |
| pre-commit 生效 | 故意提交一个格式错误的文件 | 被自动修复或拒绝 |
| commit-msg 生效 | git commit -m "update" | 被 commitlint 拒绝 |
| CI 已配置 | 打开 PR 页面 | verify 任务运行并变绿 |
六项全过,规范链路就闭环了。
小结
本节搭起了三层防线:编辑器负责即时反馈,husky 加 lint-staged 在提交前只查暂存文件以保证速度,CI 用 --frozen-lockfile 加全量检查做最终兜底。工具选型上,ESLint 加 Prettier 生态更全,Biome 更轻更快,按项目复杂度取舍;类型感知规则值得开,但要清楚它慢一个数量级,因此必须在 pre-commit 与 CI 之间做差异化配置;commitlint 则是让提交历史可被程序读取的前提。
至此第 1 章结束:脚手架能跑、类型规则够严、规范有门禁。接下来要面对的是「项目变大之后」的问题——多个包如何共享代码、路径别名怎么让导入不再是一串 ../../../、环境变量怎样获得类型。下一章从 2.1 路径别名与 monorepo 结构
开始。如果你想先把测试这道门禁补上,可以延伸阅读 TypeScript 类型安全测试
与 Node.js 进阶测试
,本书第 4 章会系统展开。
阅读导航:上一节:1.2 严格模式与 tsconfig 分层 · 下一节:2.1 路径别名与 monorepo 结构 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。