凌晨一点四十七分,手机在床头柜上连续震了三下。我眯着眼看了一眼群消息,一位同事发来一串代码截图和一句话:“谁能帮我看下这个 bug,测试环境复现不了,线上必现。”
那个晚上,某次发布把一个看似很安全的小改动带上了线,结果因为本地格式、依赖版本、提交内容全都“看着没问题”,实际到了一个模块里就翻车。最后定位到的问题很小:一处变量命名被 lint 规则拦过但被同事手动跳过,类型声明在本地是好的,换到 CI 环境后却因为依赖树不一致编译出了一套错误代码。看着群里十几条消息来回刷,我脑子里只有一个念头:不能再靠人肉小心来保证质量了。
后来我把一套内部工具链起名叫Impeccable,意思很简单——让代码在合入主干之前,先过够关卡,好到挑不出毛病。它不是让代码“没有 bug”,而是把一群靠自觉才能守住的质量底线,变成一套机器自动执行的规定动作。这篇文章就聊聊 Impeccable 这个项目本身:它是怎么设计的,每道防线到底在防什么,从零搭一套需要改哪些文件,以及我在落地和推行过程中踩过的那些坑。
1. 为什么叫 Impeccable:一个加班到凌晨换来的工程目标
1.1 一次差点让团队通宵的线上事故复盘
先说回开头那次事故。当时团队规模不大,前后端加起来不到十个人,没有专职测试,质量基本靠“写的时候小心点”和上线前人工点一遍。那次改动本身很小:某个列表页需要把排序方式从按时间改成按优先级,涉及一个公共函数的分支条件改动。
问题出在三个环节的接力失误。第一,代码在本机跑是正常的,因为本机的 Node 版本和依赖缓存都处在一种“恰好能跑”的状态,换到 CI 的干净环境后,某间接依赖被解析成了另一个版本,行为就偏了;第二,改动涉及的类型声明没有跑完整检查,只把编辑器里看到的红波浪线修掉了;第三,提交信息里只写了“fix: sort”,没写清影响范围,事后回溯时几乎找不到真正变更了哪个函数。
这个事给我的教训是:单独看每个环节,开发者都在做“正确”的事,但缺少一条把它们串起来的链路,任何一个环节失守,最终都靠线上事故来兜底。Impeccable 就是为了把这条链路补上,它不是某单一工具,而是一套按顺序执行的自动化防线。
1.2 从“做对”到“不让错发生”:三层目标拆解
如果你去问一个团队“代码质量怎么保证”,十有八九得到的答案是“大家注意点”。但“注意”是最不可靠的机制,因为人的注意力会疲劳、会受截止日期影响、会在复杂任务里被挤占。Impeccable 的设计目标是把质量从“人治”变成“法制”,而且法制必须覆盖三个层面:
| 层面 | 要解决的问题 | 对应防线 |
|---|---|---|
| 可读性 | 代码风格混乱,review 成本高 | ESLint + Prettier 静态规则 |
| 可追踪 | 提交信息不规范,回溯困难 | commitlint + 提交模板 |
| 可运行 | 类型、测试、依赖在环境间不一致 | 类型检查 + 单测 + CI 复现 |
这三个层面不是并列关系,而是漏斗关系:先让代码风格统一,review 才可能集中在逻辑本身;提交信息规范后,git log 才能当变更日志用;最后,类型检查与测试保证“看着对的代码”确实能在另一个环境里跑起来。
做这套东西有一个容易踩的认知误区:以为“无可挑剔”等于“零缺陷”。如果从一开始就盯着 100% 覆盖率、零警告、全绿流水线,这个项目大概率撑不过两周就会因为维护成本太高而被放弃。Impeccable 真正追求的是质量下限——确保低级错误、格式争端、环境差异这类“不值得花人工判断”的问题被机器自动拦住,把人的注意力留给真正的复杂逻辑。
2. 四道防线:Impeccable 的核心链路与工具选型
2.1 第一道防线:代码风格检定,把口角变成规则
代码风格问题最典型的表现是:一段代码三个人能写出三种格式,review 时一半评论是在说“这里换行不对”“那里引号应该统一”。这些讨论不是没有价值,但它们的价值密度太低。
Impeccable 的第一道防线就是 ESLint + Prettier 的组合。这两者分工很关键:Prettier 管格式,ESLint 管质量。Prettier 负责换行、缩进、引号、分号这类纯格式问题,它像一个“格式印刷机”,代码进去,统一格式的代码出来,不需要人做判断;ESLint 则负责更偏向逻辑层面的规则,比如不允许使用any、不允许在条件里写赋值表达式、不允许引入未使用的变量。
如果只上 Prettier 不上 ESLint,代码会很整齐,但依然可能带着隐藏的坏味道;如果只上 ESLint 不上 Prettier,就会陷入无休止的格式规则配置中,而且你很快会发现有两类规则是重复的——ESLint 有indent、quotes这类格式规则,Prettier 也有同样的能力,配置里两边一起开就会打架。
我的处理方案是:Prettier 做好自己的工作,同时把 ESLint 里的格式类规则全部关掉,让两者各管一段。ESLint 的扩展里带上prettier,它会把 ESLint 中与格式相关的规则批量关闭,这就是常见的eslint-config-prettier干的事。ESLint 正则规则补弱的逻辑交给它,格式统一交给另一个工具,特别建议直接在配置层就隔离这种冲突。
注意:ESLint 9 以后官方推荐的配置方式是扁平化配置,如果你是新项目,直接走
eslint.config.js的写法;如果是老项目还在用.eslintrc,也暂时不必急着迁移,把规则跑起来比迁移配置更重要。
结合实际落地,我的 ESLint 配置大致长这样:
// eslint.config.js import js from '@eslint/js' import ts from 'typescript-eslint' import prettier from 'eslint-config-prettier' import vue from 'eslint-plugin-vue' export default [ js.configs.recommended, ...ts.configs.recommended, ...vue.configs['flat/recommended'], prettier, { ignores: ['dist/**', 'node_modules/**', 'coverage/**'] }, { rules: { '@typescript-eslint/no-explicit-any': 'off', 'no-console': ['warn', { allow: ['warn', 'error'] }] } } ]这里有一个经验:我选择关闭no-explicit-any,因为真实业务里有一些地方确实需要any,比如对接一些外部数据格式不稳定的接口。如果规则一开就满屏报错,团队第一反应不是去改代码,而是把规则整个关掉。与其定一个没人遵守的严格规则,不如定一个能长期执行的适中规则。
Prettier 的配置保持极简,能用默认就用默认,因为 Prettier 存在的意义就是消灭争论,而不是制造新的争论点:
{ "semi": false, "singleQuote": true, "printWidth": 100, "trailingComma": "none" }2.2 第二道防线:提交前拦截,让坏代码进不了门
Prettier 和 ESLint 能发现问题,但如果每次都要开发者手动跑一遍命令,很快就会被遗忘。我见过很多团队配置写得很好,但成员习惯了“先提交再说”,质量防线形同虚设。
Impeccable 的第二道防线是把检查动作绑定到 Git 提交前的钩子上,让“不规范的代码提交不进去”。这里用到的工具是 Husky + lint-staged。
Husky 负责管理 Git 钩子,在git commit前触发脚本;lint-staged 负责只对本次暂存区里变动的文件执行检查。为什么要用 lint-staged?因为如果每次提交都对整个项目跑一遍 ESLint,项目稍微大一点,每次提交都要等十几秒,团队很快就会对这种卡顿产生怨言。lint-staged 的聪明之处在于:只检查你即将提交的这一批文件,速度足够快,而且不会在意旧代码里那些历史欠账。
安装配置的主流程大致是:
npm install -D husky lint-staged npx husky inithusky init之后会在.husky/目录生成一个pre-commit钩子文件,在里面写入要执行的命令:
npx lint-staged然后在你自己的包管理工具里注册prepare脚本,这样其他人拉完项目执行npm install时会自动激活 Git 钩子:
{ "scripts": { "prepare": "husky" } }lint-staged 配置写在package.json或独立的配置文件中:
{ "lint-staged": { "*.{js,ts,vue,jsx,tsx}": ["eslint --fix", "prettier --write"], "*.{json,md,yml,yaml}": ["prettier --write"] } }这里很多第一次配置的朋友容易忽略一个点:lint-staged 数组里的命令是按顺序执行的,所以要先eslint --fix再prettier --write,避免 Prettier 改完格式后 ESLint 又报错,导致某次提交反复触发 hook。
2.3 第三道防线:类型检查与测试覆盖,让隐患暴露在本地
代码风格问题解决了,下一个问题是“代码逻辑是否正确”。这道防线是两层:TypeScript 类型检查和单元测试。
TypeScript 的类型检查意义不必多谈,但有一个实践细节值得单独说:编辑器的类型检查,和命令行里的类型检查,是两回事。很多开发者在 VS Code 里看到没有红色波浪线就以为类型安全了,但编辑器往往只检查当前打开文件的即时诊断,并不保证全项目的类型一致性。一个组件改了 props 接口,另一个使用它的文件是否报错,这个要跑全量类型检查才知道。
所以我在package.json里一定会配一个typecheck脚本:
{ "scripts": { "typecheck": "vue-tsc --noEmit" } }Vue 项目用vue-tsc,React 或纯 TS 项目用tsc --noEmit。这个脚本在本地要跑,在提交钩子前也要跑(即便只对声明文件做检查),在 CI 上同样要跑。
单元测试的覆盖率怎么做,是另一个容易纠结的点。我见过一上来就把覆盖率门槛设到 90% 的团队,结果核心业务逻辑没人敢动,倒是各种工具函数被反复测试,覆盖率数字很好看,实际问题一个没拦住。Impeccable 的思路是分阶段设门槛:第一阶段全局覆盖率要求 60%,但核心目录单独要求 80% 以上;第二阶段再根据实际情况把全局门槛提到 80%。测试覆盖率是用来保护核心逻辑的,不是用来制造团队焦虑的计量表。
2.4 第四道防线:CI 管道终检,守住合并前最后一道关卡
本地钩子是“第一道闸门”,但光有本地钩子还不够,原因很朴素:本地钩子可能被跳过,也可能因为配置不同而结果不一。有人在git commit时用--no-verify绕过检查,有人本机 MySQL 没启动导致测试全红但没注意到,这些情况在团队协作中确实会发生。
CI 管道要做的是“最终裁决”:所有检查都在一个干净环境里重新跑一遍,任何人无法跳过。我在 CI 里安排了三项任务,按顺序执行:
stages: - lint - typecheck - test lint: script: - pnpm install --frozen-lockfile - pnpm lint typecheck: script: - pnpm install --frozen-lockfile - pnpm typecheck test: script: - pnpm install --frozen-lockfile - pnpm test -- --run这里三个 job 各自做一次依赖安装,比较费时间。如果你的 CI 支持缓存机制,务必把依赖缓存打开——把node_modules或包管理器的缓存目录缓存住,每次安装速度能提升一个量级,否则团队会吐槽“等 CI 的时间比写代码还长”。团队规范上,我加了一条硬性要求:CI 是合并代码的前置条件,失败即不能合入。这个要求一开始会让人觉得麻烦,但用过一个月后,整体效率反而更高了,因为代码合并之后几乎不会再为了“这个问题为什么线上才有”而在群里反复确认。
3. 从零搭建的完整实操:Impeccable 落地记录
3.1 依赖清单与环境准备
前面讲了设计思路,这块我按实际执行顺序记录一遍搭建过程,方便你照着操作。
前置条件:一个已经初始化好的前端项目,Node 版本建议 18 以上。我这里以 pnpm 作为包管理器举例子,用 npm 也是同理,只是命令略有差异。
一次性安装的依赖清单如下:
pnpm add -D eslint prettier typescript vue-tsc husky lint-staged @commitlint/cli @commitlint/config-conventional eslint-config-prettier typescript-eslint不同项目还要根据框架补充相应的 ESLint 插件,比如 Vue 项目增加eslint-plugin-vue,React 项目增加eslint-plugin-react-hooks。
安装完成后,先做第一步验证:在package.json里加两个脚本。
{ "scripts": { "lint": "eslint . --max-warnings=0", "format": "prettier --write ." } }--max-warnings=0是一个很容易被忽略但很重要的参数。如果不加,ESLint 遇到 warning 级别的问题时退出码仍然是 0,CI 会误判为“检查通过”,那些被当作 warning 的no-console、no-unused-vars就永远会混在代码里。加上它之后,warning 也会导致检查失败,规则才真正有了牙齿。
3.2 逐个配置文件:ESLint、Prettier、Husky、commitlint、lint-staged
首先是.prettierrc.json,这块前面已经贴过,保持极简:
{ "semi": false, "singleQuote": true, "printWidth": 100, "trailingComma": "none" }然后是 ESLint 配置,上文贴的是新版扁平化配置。如果你的项目还没迁到 ESLint 9,还在使用传统的.eslintrc.cjs,一个基础版本长这样:
module.exports = { root: true, env: { browser: true, es2022: true }, extends: [ 'eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier' ], parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, rules: { '@typescript-eslint/no-explicit-any': 'off', 'no-console': ['warn', { allow: ['warn', 'error'] }] } }接着是 Husky。执行npx husky init后,.husky/pre-commit的内容改成:
npx lint-staged再用一条命令添加 commit-msg 钩子,用于校验提交信息格式:
npx husky add .husky/commit-msg 'npx --no -- commitlint --edit "$1"'接着创建commitlint.config.cjs:
module.exports = { extends: ['@commitlint/config-conventional'] }config-conventional是一套适用范围很广的约定式提交规范,要求提交信息格式为:
type(scope): subject其中type可选feat、fix、docs、style、refactor、test、chore等。这套格式最大的价值是让git log变成一份可以自动生成 changelog 的日志,也方便在回溯问题时快速区分“这次改动属于什么类型”。
lint-staged 的配置我放在package.json里,前面已经贴过,这里不再重复。但有一点要提醒:Husky 钩子文件的路径在 Windows 环境下有时会因为换行符问题失效。如果你在 Windows 上开发,钩子报错却看不到具体原因,检查一下.husky/pre-commit文件是不是被自动转成了 CRLF 换行,如果是,改成 LF 再试一次。
3.3 本地验证一条违规提交:看拦截是怎么发生的
配置都完成后,建议先故意制造一次“违规提交”,验证整条链路是否真的生效。
我在搭建 Impeccable 时就是这么做的。第一步,改一个 TS 文件,在里面声明一个未使用的变量,再写一行明显的分号风格问题:
const unusedVar = 'hello';第二步执行git add然后git commit,此时 pre-commit 钩子会被触发,lint-staged 定位到这个文件,ESLint 会先报'unusedVar' is assigned a value but never used,Prettier 会尝试把分号去掉。由于我配置了eslint --fix,ESLint 会先尝试自动修复,但因为未使用变量属于无法自动修复的规则,所以提交进程会被打断,终端里出现红色报错信息。
第三步是把未使用变量删掉,再重新提交,此时提交信息如果不符合规范,commit-msg 钩子会被触发,commitlint会报一个格式错误。第一次跑通这套流程时,我特意看了下那两条钩子的执行时间,整个过程只有几百毫秒,对开发节奏的影响可以忽略。
这步验证最好在项目第一天就做一遍,确认钩子、配置、工具链都正常,而不是等到第一个同事被卡住时才去排查。
3.4 老项目渐进式接入:不被存量代码拖垮
如果你接手的是一个已经跑了一两年的老项目,代码里有大量历史问题,直接全量开 ESLint strict 模式,结果一定是“两万多个 error”,团队直接麻了。
Impeccable 的渐进式接入原则:存量问题不要求一次性清零,但新增代码必须守规矩。
具体做法是三步。第一步,在 ESLint 配置里把存量问题较多的规则降级,比如no-explicit-any设为off,no-console设为warn,用--max-warnings=0这类参数暂时不要加,让流水线先“绿”起来。第二步,在 lint-staged 里配置只检查暂存区文件,这样新增代码会被新规则检查,存量代码不会被反复揪出来。第三步,从下一个迭代开始,利用重构机会逐步修掉历史问题,每修一批就把对应规则的级别提上来一档。
渐进式接入还有一个配套措施:给团队一个“新代码守规矩、老代码逐步还”的缓冲期。直接全面开闸的结果一定是被集体抗议,因为开发者的自然反应是“这不是我造成的错误,为什么要我负责”。先说清楚规则只对增量生效,再去推动存量清理,配合度会高很多。
4. 实战踩坑与排查速查表
4.1 Git Hook 不触发的几种原因
Husky 最经典的问题就是“我明明配了 pre-commit 钩子,为什么 commit 时毫无反应”。
第一个原因是钩子文件本身没有执行权限。在你从其他人那里克隆了一个项目,或者在某些 Windows 文件系统上,.husky/pre-commit文件可能只有读权限,Git 不会执行没有执行权限的钩子脚本。排查方式是看ls -l .husky/pre-commit的输出,权限位里没有x就手动chmod +x。
第二个原因是prepare脚本没有执行。Husky 的完整激活流程是:执行npm install时,prepare脚本会调用 Husky 的命令,把钩子注册到.git/hooks/目录。如果新人在安装依赖时使用了--ignore-scripts参数,或者package.json里的prepare脚本丢失,钩子就不会被注册。
第三个原因比较隐蔽:项目里已有旧的 Git hooks 覆盖了新配置。有些团队早期是用手工方式往.git/hooks/里放脚本的,当你引入 Husky 后,它的注册机制和手工脚本产生冲突,结果钩子没有按预期执行。排查时可以直接看.git/hooks/目录下是否有 Husky 生成的符号链接,没有的话就重新执行npx husky init来重置。
排查速查:
| 现象 | 可能的根因 | 处理方式 |
|---|---|---|
| 提交时完全无拦截 | prepare 脚本未执行 | 检查 package.json,重装依赖 |
| 钩子报错但内容为空 | 换行符为 CRLF | 将 .husky 文件转为 LF |
| 新同事拉代码后钩子失效 | 安装时跳过了 scripts | 取消 --ignore-scripts 重新安装 |
4.2 Prettier 与 ESLint 抢地盘:规则冲突的根治方式
还有一个在刚搭好时会频繁遇到的怪问题:ESLint 说这段代码应该用双引号,Prettier 说应该用单引号,两边同时报错,钩子永远不过。
这个问题的根因是格式化规则重复配置。ESLint 内置了一批“格式类”规则(比如quotes、indent、semi),如果这些规则和 Prettier 的默认规则不一致,两边就会打架。而且因为你只配置了eslint --fix,没有把eslint-config-prettier加进 extends 里,ESLint 不会自动关闭这些重叠规则。
根治方式就是引入eslint-config-prettier,把它放在 ESLint extends 数组的最后一位。它的作用就是关掉所有与 Prettier 重叠的 ESLint 格式规则。这样 ESLint 只管逻辑类规则,Prettier 只管格式,各管一段,不再打架。
这条经验的核心是:不要在 ESLint 里手动开启格式规则;格式就交给 Prettier,因为 Prettier 对格式的处理是强制的、无争议的,而 ESLint 格式规则往往需要你手动维护大量细节,维护成本高却收益极低。
4.3 CI 结果与本地不一致:从环境差异入手
经常发生的情况是:本地钩子过了,CI 却红了。排查这类问题,关键要看环境和依赖是否完全一致。
最常见的根因是依赖锁文件没有提交或者不是最新版本。比如用 pnpm 的项目,pnpm-lock.yaml没有提交到仓库,CI 每次安装时都重新解析依赖版本,间接依赖一升级,行为就可能不一样。解决办法是:确保锁文件提交到仓库,并在 CI 安装依赖时使用--frozen-lockfile,这个参数的含义是完全按照锁文件的版本安装,不解析不升级。
第二个常见根因是 Node 版本不一致。本地是 Node 20,CI 上是 Node 18,某些语法特性和 API 行为会不同。解决办法是在项目里加.nvmrc或engines字段,把 Node 版本固定下来,CI 上也用同一版本运行时。
第三个根因比较隐蔽:本地 lint-staged 只检查了暂存区文件,但 CI 全量跑 ESLint,发现了存量代码里的 warning 导致失败。这种问题也是配置不统一的体现——本地检查的是 diff,CI 检查的是全量,两者结果天然可能不一致。要么 CI 也只检查 diff 文件,要么本地也允许全量跑,建议核心规则用全量,格式检查用 diff,规则分类要清晰。
4.4 团队推行的抵触心理与应对方法
工具链本身解决的是“机器检查”的问题,但真正推行时,阻碍往往不在工具,而在人。
最常见的三种抵触我都遇到过。第一种是“这套东西拖慢了我”:新增了提交时自动检查,每次 commit 可能要等一两秒,有人就会故意用--no-verify跳过。应对方法是把 lint-staged 的范围控制在暂存区,把哪些检查放在本地、哪些放在 CI 重新划分:本地不要跑全量测试,只跑格式和类型检查,让本地速度快到几乎没有感知;全量测试交给 CI。
第二种是“规则太严了,这不让我们用 any 吗”:这类抱怨通常来自规则配置没有和业务节奏匹配。应对方法是保留一个可灰度降级通道,比如any先设为warn,等过一个月业务稳定后再收紧,而不是一上来就绝杀。
第三种是“你们定的规则我不认同”:这一块没有工具层面的解法,只能靠评审机制。规则不能是一个人写了就全员执行的,至少要过一轮团队 review,把“为什么这样定”讲清楚。我在 Impeccable 里加了一份CONTRIBUTING.md,把每条柔性规则的背景说明写在文档里,新人来了先读文档再写代码,争议率明显下降。
最后再说一点关于这套东西的体会
搭 Impeccable 之前,我以为“工程化质量保障”是工具问题,把 ESLint、Prettier、Husky、CI 接好就完事了。真正跑了一段时间后发现,最难的部分是取舍:在“规则能拦住问题”和“规则不会烦到人”之间找平衡。
根据我个人的实操经验,有一个建议值得你优先考虑:不要一开始就把所有规则开到最满,先让整套链路跑通,再逐步加码。哪怕最开始只有 ESLint 推荐规则和提交信息校验,也比你一个人靠自觉写高质量代码要强得多,因为这已经把质量从“个人习惯”变成了“团队默认动作”。
另外一个让我意外的小收获是:给工具链起了 Impeccable 这个名字之后,团队成员在 commit 时看到钩子里弹出“Impeccable check failed”这句话,反而会笑一下然后认真地改掉问题,而不是像以前那样觉得“被针对了”。一个好记的、略带俏皮的名字,有时比十页规范文档更能建立认同感。
这套东西后续还可以往下扩展的方向不少,比如把依赖安全扫描(pnpm audit)接进 CI、给常规提交自动生成 changelog、甚至把 code review 的检查清单固化成机器人自动评论。但前提一定是先稳住当前这套链路:提交时把格式管住,合并前把类型和测试管住,线上事故的概率就已经会肉眼可见地降下来。