《TypeScript编程实战》1.3 代码规范与提交门禁(ESLint / Biome / husky)

本节把代码规范从口头约定变成不可绕过的自动化门禁:先对比 ESLint 加 Prettier 与 Biome 两套方案的取舍,再用 flat config 写出带类型感知的 typescript-eslint 配置,然后用 husky 与 lint-staged 把检查挂到 pre-commit,用 commitlint 约束提交信息,最后在 CI 里跑全量校验兜底。

本节目标:把「团队约定」翻译成机器能执行的检查。读完后你会有一条三层防线——编辑器即时格式化、提交前钩子拦截、CI 全量兜底——并且清楚 ESLint 与 Biome 各自适合什么团队、类型感知检查值不值得它的成本、提交信息规范为什么值得坚持。

1.3 代码规范与提交门禁(ESLint / Biome / husky)

前两节我们解决了「能跑」和「类型正确」。但一个真实项目还有第三类问题:代码风格不统一、提交信息无法追溯、本地能跑 CI 却挂。这类问题不会让程序崩溃,却会让每一次 code review 都浪费在缩进和命名上。

规范类问题的特点是:靠自觉一定失败。不是团队不认真,而是人在赶进度时必然会跳过检查。所以唯一有效的办法是把检查自动化,并且放在「不做就不能继续」的位置上。这就是本节要搭的三层防线:

层次触发时机拦截能力反馈速度
编辑器保存文件只提示,不阻断毫秒
提交钩子git commit阻断本次提交秒级
CIpush / 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 lint0 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 结构 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes