事情是这样的。上个月朋友拉我去看他们组的一个老项目,业务逻辑挺完整,功能也都在跑,可代码一打开就是一股“能跑就行”的味道:有人用单引号有人用双引号,ESLint 的 warn 堆了两千多条没人清,TypeScript 里到处是 any,git 提交信息清一色“fix”和“update”。谁也不敢动那些文件,因为一动就可能踩到别人埋的雷。
这不是个例。几乎所有团队在从“小作坊”往“正规军”过渡的时候都会撞上这堵墙:功能没问题,质量没保障。我给自己的一套工程质量基线方案起了一个代号,叫 impeccable。它不依赖具体业务框架,核心是把 ESLint、Prettier、TypeScript 严格模式、Husky、lint-staged、commitlint 和 CI 流水线整合成一套自动化的质量守门机制,目标是让“无可挑剔”从愿望变成可执行的工程约束。这篇文章就是我从零搭建 impeccable 的完整记录,里面有方案选型、具体配置、排坑经验,也有一段关于“质量检查如何改变团队习惯”的思考。适合正在建设工程规范的前端工程师,也适合被存量代码债折磨得想重构又不敢动手的团队参考。
1. 为什么做 impeccable:一次对“差不多就行”的反击
1.1 这个项目到底想解决什么问题
代码规范这件事,听起来是“审美问题”,做起来全是“成本问题”。没有统一规范时,每个人打开别人的代码都要先经历一次心智转换:这个人喜欢把类型定义放哪?这个文件为什么不带分号?这里的 any 是故意还是偷懒?别小看这些琐碎,它们会让 code review 变成无休止的风格争论,让重构变成一场赌博,让新人入职的第一周全耗在“猜规矩”上。
impeccable 想解决三个具体问题:第一,提交前就把低级问题挡住,比如未使用变量、隐式 any、明显格式错误;第二,让提交信息有迹可循,以后翻 git log 能一眼看出每个提交的意图;第三,让 CI 成为第二道质量守门员,就算有人本地跳过了检查,合并之前也会被拦下来。核心思路是分层设防:本地快速反馈层负责日常开发,CI 全量检查层负责兜底,两边跑同一套规则,不给“漏网之鱼”留空间。
这里要强调一个观点:自动化工具不是要替代 code review,而是要替代 code review 里最无聊的那部分。人眼应该去看“这个接口设计是否合理”“这段逻辑有没有边界漏洞”,而不是盯着“你这里多了个空格”。机器能做的检查全部交给机器,review 才有时间做真正有价值的事。
1.2 方案选型:为什么是这六个工具的组合
定方案时我对比过不少工具,最后敲定的组合是 ESLint + Prettier + TypeScript strict + Husky + lint-staged + commitlint。每个工具都有明确分工,也有明确的替代方案,但组合起来最顺。
ESLint 是 JS/TS 生态里事实标准的静态检查工具,TSLint 早已停止维护,没有悬念。Prettier 负责代码格式化,它跟 ESLint 的分工是:风格问题交给 Prettier,逻辑质量问题交给 ESLint。TypeScript 的 strict 模式不是额外插件,而是编译器自带的一组严格开关,它能把整个类型系统的安全系数拉上来。Husky 用来挂载 git hooks,比手动配置.git/hooks更优雅,也方便团队共享。lint-staged 只对暂存区的文件做检查,避免每次提交全量扫描拖慢速度。commitlint 配合 conventional commits 规范,让提交信息格式统一。
这几个工具有一个共同特点:它们都是“声明式配置”驱动,规则写进配置文件后,团队所有人共享同一套标准。相比口头约定,配置文件是防呆的——新人不需要问“我们项目用什么风格”,跑一遍命令就知道哪里不合格。
1.3 为什么叫“impeccable”
这个词在英文里的意思是“无可挑剔的、无瑕疵的”。给工具链起个名字,听上去有点中二,但实际好处是团队有了一个共同语言。你说“走一遍 impeccable”,大家知道是跑 lint、格式化和类型检查;你说“PR 没过 impeccable”,大家知道是质量门槛没过。命名让“质量”从一个抽象概念变成了一个具体的、可引用的东西。
更重要的是,“无可挑剔”这个目标本身就是一种工程态度。代码不是写给机器看的,机器只要能跑就行;代码是写给下一个维护者看的,那个维护者可能是三个月后的你自己。impeccable 这个名字每天都在提醒我:质量不是靠某一次大扫除,而是靠每一次提交都不过线。
2. 核心工具链拆解:每一条质量防线都在拦什么
2.1 ESLint 9:用扁平配置告别历史包袱
ESLint 9 之后默认走 flat config,也就是eslint.config.js这个配置文件。老版本的.eslintrc那套层级继承、env、overrides的写法还在过渡期,但新项目没必要再学旧东西。扁平配置最直观的变化是:一个数组搞定所有规则,从前到后依次生效,心智负担小很多。
接入 TypeScript 时我直接用了typescript-eslint这个官方推荐的工具链,它内部把 parser、plugin、配置项都封装好了,不需要自己手动拼接@typescript-eslint/parser和@typescript-eslint/eslint-plugin。tseslint.config()这个工厂函数还能合并多套配置,代码写起来很干净。
规则配置上,我默认只开recommended集合里最核心的几条,再补充几个团队痛点规则。比如@typescript-eslint/no-explicit-any我设成 error,就是为了逼团队处理那些any泛滥的问题。实际写代码时,any意味着告诉编译器“你别管我了”,可程序崩溃时编译器不会替你说“我提醒过你”。换成unknown加类型收窄虽然要多写几行,但至少你对自己的数据结构有交代。
// eslint.config.js import js from '@eslint/js'; import tseslint from 'typescript-eslint'; import prettier from 'eslint-config-prettier'; import globals from 'globals'; export default tseslint.config( { ignores: ['dist', 'node_modules', 'coverage'], }, js.configs.recommended, ...tseslint.configs.recommended, { files: ['**/*.{js,ts,tsx}'], languageOptions: { ecmaVersion: 2022, sourceType: 'module', globals: { ...globals.browser, ...globals.node }, }, rules: { '@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], eqeqeq: ['error', 'always'], }, }, prettier, );注意最后一行prettier,我引入的是eslint-config-prettier,它的作用是把 ESLint 里跟格式化相关的规则全部关掉。否则 ESLint 和 Prettier 会在缩进、换行、引号这些事上重复管理,冲突只会让你头疼。
2.2 Prettier:把格式争论从 code review 里彻底踢出去
你可以把 Prettier 理解成一个“不讲道理的排版员”:它不管你的代码逻辑对不对,只管把代码按统一风格重新排好。为什么需要它?因为人是不可靠的,手写的缩进在十个人的协作下一定会漂移,而机器格式化是确定性的,同一个配置全世界跑出来都一样。
配置 Prettier 时要注意一个心态:别花太多时间调参数。printWidth、tabWidth、singleQuote这些选一套顺手的就锁死,团队里任何人提出“我想用 120 列”,请他用数据证明这个改动带来的收益,而不是凭审美投票。
// prettier.config.js export default { semi: true, singleQuote: true, trailingComma: 'all', tabWidth: 2, printWidth: 100, endOfLine: 'lf', };我这里用了endOfLine: 'lf',主要是避免 Windows 和 macOS 之间因为 CRLF/LF 差异产生大量无意义的 diff。这个问题在多人协作时特别隐蔽,两个系统各自格式化后,git 会显示整个文件都被改了,实际上只是换行符不同。
Prettier 还有个大用途:历史存量代码可以先全体跑一遍prettier --write,瞬间统一风格。这一步看着声势浩大,但它是纯格式变更,不触碰业务逻辑,review 起来只需要确认没有夹带私货。
2.3 TypeScript strict:让类型系统替你兜底
TypeScript 的strict: true一次性打开了一大批严格检查开关,包括strictNullChecks、noImplicitAny、strictFunctionTypes、strictBindCallApply等。如果你接触过strict: false的项目再切到true,第一反应通常是“怎么到处报错”,这很正常,因为过去编译器对很多可疑操作都睁一只眼闭一只眼。
除了strict,我还会额外开几个个人非常推荐的开关:noUncheckedIndexedAccess是最容易被低估的一个。它会让数组和对象的索引访问结果带上undefined,逼你面对“取不到值”这种现实问题。举个例子:
// 非严格模式:编译器不吭声,运行时可能崩溃 const list: string[] = []; const first = list[0].toUpperCase(); // 开启 noUncheckedIndexedAccess 后:编译器直接报错 // 'list[0]' is possibly 'undefined' const first = list[0]?.toUpperCase() ?? '';生活化一点,普通模式像是“成年人的自觉”,编译器默认你不会访问越界;严格模式则是把安全绳挂在栏杆上,就算你一时手滑,它也能拉住你。对生产代码来说,我愿意多写几个?.和??,也不想在凌晨两点接到“线上白屏”的电话。
2.4 Husky + lint-staged + commitlint:把检查绑死在提交前
本地检查要真正落地,必须把它和 git 操作绑定,而不是指望每个人自觉跑一遍脚本。Husky 干的就是这件事,它让你能优雅地配置 git hooks。安装并初始化后,你会在.husky/目录下看到可执行的 hook 脚本,这些脚本会跟随仓库分发给所有人。
lint-staged 的价值在于“只检查暂存区”。设想一个中型项目有几千个文件,每次提交都全量跑一遍 ESLint 可能要十几秒,开发者很快就烦了。lint-staged 会把git add过的文件筛出来,只对这些文件执行检查命令,提交动作能控制在两三秒内完成,反馈足够快,人才愿意守规矩。
commitlint 则负责规范提交信息。它要求提交信息符合 conventional commits 格式,比如feat: 增加登录接口、fix: 修复空指针异常。这条规则看似繁文缛节,但当你需要回滚某个功能、生成 changelog、或者用git bisect定位问题时,规范的提交信息能省下大量时间。工具不贵,习惯才贵。
3. 实操:从零搭建 impeccable 质量基线
3.1 初始化工程与安装依赖
先准备一个空目录,用 npm 初始化项目。如果你已经有工程,直接跳到依赖安装步骤就行。Node 版本建议 20 以上,ESLint 9 要求 Node 至少 18.18,太老的版本会出现兼容问题。
mkdir impeccable-demo && cd impeccable-demo npm init -y然后安装核心依赖:
npm install --save-dev eslint @eslint/js typescript typescript-eslint globals npm install --save-dev prettier eslint-config-prettier npm install --save-dev husky lint-staged npm install --save-dev @commitlint/cli @commitlint/config-conventional这里我特意没有装eslint-plugin-prettier,因为现代化的推荐做法是:Prettier 单独做格式化,ESLint 只做代码质量检查,两者通过eslint-config-prettier交接。eslint-plugin-prettier是把 Prettier 当成 ESLint 规则跑,效率低而且容易出现“双重格式化”的闹剧。
安装完成后,在package.json里补几个脚本,方便后续统一使用:
{ "scripts": { "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write .", "format:check": "prettier --check .", "typecheck": "tsc --noEmit" } }把命令收敛到统一脚本里很重要。团队协作时,所有人跑的都是npm run lint,而不是各自记忆一串长长的 npx 命令。
3.2 配置 ESLint 与 Prettier
ESLint 9 的配置文件是eslint.config.js,我用的是 ESM 写法,所以记得在package.json里加上"type": "module",或者把配置文件改成eslint.config.mjs。之前给的配置可以直接抄,但有两个点要展开说。
第一,globals不能漏。浏览器环境有自己的全局变量,比如window、document,Node 环境有process、module。如果不声明,ESLint 会把它们当成未定义变量报错。用globals包一次性引入,比旧版的env直观。
第二,ignores里的dist、node_modules必须保留,否则 lint 自己生成的产品代码就是浪费性能。如果你还用了 Vite、Next.js 这类框架,记得把它们的构建产物目录也加进去。
Prettier 配置文件如果也用 ESM 格式,prettier.config.js里写export default即可。注意,Prettier 默认会读取package.json里的prettier字段,或者.prettierrc,多个配置源同时存在时会让人迷惑。我的习惯是只保留一个prettier.config.js,删掉其他可能的配置来源,保证单一事实来源。
3.3 接入 TypeScript 严格模式
如果你是从零开始,建议直接开满严格配置;如果是老项目,可以分阶段开启,这个后面会讲。先看推荐配置:
{ "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "noImplicitOverride": true, "noFallthroughCasesInSwitch": true, "noUnusedLocals": true, "noUnusedParameters": true, "verbatimModuleSyntax": true, "skipLibCheck": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true }, "include": ["src"] }重点解释几个容易被忽略的开关。exactOptionalPropertyTypes会严格区分“属性被设置为 undefined”和“属性未定义”这两种情况,它能揪出一批“看似可选实则偷偷传 undefined”的调用。verbatimModuleSyntax强制你使用import type导入纯类型,这能让构建工具更好地做 tree-shaking,同时减少运行时依赖。
noUnusedLocals和noUnusedParameters初看有点烦人,但它们在帮你保持代码整洁,大量死代码就是从这里开始藏污纳垢的。如果担心开发时调试不便,可以把它们先设成 warn,推进度时再升级为 error。
3.4 配置 Husky、lint-staged 和 commitlint
Husky v9 的初始化命令很简单:
npx husky init这条命令会在.husky/目录生成一个pre-commit脚本。我们把它改成:
# .husky/pre-commit npx lint-staged再创建一个commit-msg钩子,用来校验提交信息:
# .husky/commit-msg npx commitlint --edit "$1"lint-staged的配置我放在package.json里,集中管理:
{ "lint-staged": { "*.{js,ts,tsx}": [ "prettier --write", "eslint --fix" ], "*.{json,css,md}": [ "prettier --write" ] } }顺序上,我刻意让prettier先跑、eslint后跑。因为 eslint-config-prettier 已经把格式类规则关掉了,理论上两者不冲突,但先统一格式再检查逻辑,心智上更顺畅,ESLint 报的错误也更纯粹。
commitlint 的配置:
// commitlint.config.js export default { extends: ['@commitlint/config-conventional'], };这里直接继承约定式提交标准,不用自己造轮子。它允许的 type 包括feat、fix、docs、style、refactor、test、chore等,覆盖了几乎所有常规提交场景。
3.5 流程演示:一次完整的提交是一次质量关卡
配置完成后,整个提交流程是这样的。开发完一个功能,git add暂存文件,然后执行git commit。此时pre-commit钩子触发,lint-staged 只对暂存文件跑 prettier 和 eslint。如果有问题,提交会直接失败,终端会告诉你哪个文件哪一行违反了什么规则。修复后再 add、再 commit。
提交信息起手式如果写成update xxxxx,commit-msg 钩子会立刻拦下,提示你 type 必须符合规范。正确写法是:
git commit -m "feat: 增加用户注册接口"这套流程的精妙之处在于“阻塞式检查”。很多人担心这样会打断开发节奏,但实际体验是:早失败永远比晚失败便宜。提交前的检查失败,成本只有几秒;合并到主分支后才发现问题,成本是回滚、修复、重新走发布流程。把质量关卡前置到每一次提交,就是把成本控制在最低点。
4. 落地过程中的真实问题与排查手册
4.1 ESLint 和 Prettier 互相打架
这是接入时最常见的问题。症状是:格式化完代码后,ESLint 依然报错,报错内容全是缩进、换行、引号这类格式问题。原因也简单,ESLint 内置的indent、quotes、semi等规则跟 Prettier 的格式化结果不一致,两边都在管同一件事。
解决思路是明确分工:格式问题只归 Prettier,逻辑问题只归 ESLint。eslint-config-prettier的作用就是把 ESLint 里的格式规则全部关闭。如果你确实想保留一些风格约束,也请只选 Prettier 配置里能表达的维度,不要两边各设一套。
我踩过的坑是,早期为了“少装一个依赖”,试着只靠 ESLint 的indent和quotes维持格式。结果团队里有人用 eslint --fix 自动格式化,有人手写,最后仍然一团乱。后来老实接上 Prettier,世界安静了。
提示:如果 lint-staged 里同时跑 prettier --write 和 eslint --fix 后依然报错,检查一下 eslint.config.js 里
prettier是不是放在了配置数组的最后一层,顺序错误会导致规则覆盖失效。
4.2 Git hooks 不生效
Husky 初始化完成后,有时候会发现提交时钩子压根不执行。先查一个东西:git 的core.hooksPath。有些全局配置(比如某些 dotfiles 管理工具)会把 hooks 指向别的地方,导致.husky/下的脚本被忽略。
git config core.hooksPath如果输出不是.husky,就用下面的命令修正:
git config core.hooksPath .husky另一个容易踩的坑是 Windows 环境。Husky v9 在 Windows 上默认能工作,但如果你之前手动改过.git/hooks或者用了旧版 husky,可能残留一些冲突文件。最省事的办法是删掉.git/hooks下同名的 hook 文件,再跑一次npx husky init。
还有一类问题是团队协作里的:某个人换了新电脑,clone 仓库后node_modules还没装,或者忘了跑npm install,hooks 自然不存在。所以在项目 README 里写清楚“安装后必须执行npm install && npx husky init”这类引导很有必要。
4.3 提交信息不规范
commitlint 报错的文案一开始会让人摸不着头脑,最常见的是subject may not be empty,这通常是因为提交信息里没有写好 type 前缀。另一种常见报错是type must be one of [feat, fix, ...],说明你写了个规范之外的 type。
为了方便团队查阅,我把 commit 规范整理成一张速查表:
| type | 适用场景 |
|---|---|
| feat | 新功能 |
| fix | 修复 bug |
| docs | 文档变更 |
| style | 不影响逻辑的格式调整 |
| refactor | 重构,行为不变 |
| test | 测试相关 |
| chore | 构建、依赖、工具链变更 |
记住一个原则:提交信息不是写给 git 看的,是写给三个月后的自己看的。当你需要定位“登录模块的报错是哪个提交引入的”,规范的提交信息能让你用git log --grep快速锁定范围,而不用在“update”和“fix”之间大海捞针。
4.4 存量代码改造:如何把历史债务一次结清
老项目接入 impeccable 最怕的是“平地一声雷”:全量打开严格规则,结果几千条错误刷屏,团队直接放弃。我的做法是分三步走。
第一步,先全量跑一遍prettier --write,把格式统一了。这一步风险最小,纯格式化不改变行为。第二步,ESLint 先以 warn 级别接入,不阻断构建,让团队在开发时看到警告但可以暂时放行。第三步,设定一个债务清零期限,比如一个迭代内把 warn 降到 0,然后把规则从 warn 升级成 error。这个过程看着慢,实际上比一步到位快得多,因为团队没有“破罐破摔”的挫败感。
针对无法短期内清完的历史文件,可以在 eslint.config.js 里用files单独开一个老目录的降级配置,比如对src/legacy/**关闭@typescript-eslint/no-explicit-any。但这里有一个我强烈建议的约束:豁免必须设时限。你可以加一张“技术债清单”,每一条豁免都对应一个 issue,CI 里再做一道检查——如果 legacy 目录的体积在增长,就说明有人又把豁免范围扩大了,这时候应该亮红灯。豁免是权宜之计,不是长期方案。
5. 从工具到文化:impeccable 的团队落地心得
5.1 质量检查不是审批,而是安全网
很多人一听到“质量门槛”就觉得是给自己的活找麻烦,实际上自动化检查真正改变的是 code review 的气质。我的亲身体验是:接入 impeccable 之后,review 的评论从“你这里有个 any 忘改了”“为什么用双引号”变成了“这个接口在边界条件下会不会崩溃”“这段逻辑要不要补个单元测试”。
机械问题由机器守住之后,人脑才有余力关注真正重要的事。质量门槛不是审批流程,不是管理者拿来卡人的工具,它是一张安全网。它让团队里每个人都有底气说“我来改这个文件”,因为你知道规则是公开的、自动的、不会因为谁心情好就放水。
5.2 在 CI 里跑同样的检查
本地 hooks 再完善,也挡不住某些“聪明人”绕过。有人可能用了git commit --no-verify,有人可能改动了没有 hook 的机器上的配置。所以 CI 必须做第二道防线,而且 CI 里的检查一定要是全量的,不能像 lint-staged 那样只查暂存区。
用 GitHub Actions 举一个简单例子:
name: quality on: push: branches: [main] pull_request: jobs: quality: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - run: npm ci - run: npm run lint - run: npm run typecheck - run: npm run build这套流水线把 lint、类型检查、构建全部串起来,任何一步失败,PR 都不允许合并。CI 的价值在于“凡是在我机器上能过”的借口彻底失效,质量验证变成了仓库级的事实。
5.3 后续还能怎么扩展
impeccable 是基线,不是终点。等团队适应了这套流程后,还可以往三个方向扩展:测试覆盖率门禁,比如全项目覆盖率不低于 80%,新增代码覆盖率不低于 90%;复杂度检查,用 ESLint 的complexity规则限制单个函数的分支数量;依赖安全审计,npm 生态里就有现成工具可以扫描漏洞依赖。
我曾经试过再接入一个死代码检测工具,把那些“从来没被 import 过的导出”全部找出来。清理完之后,项目体积和心智负担一起降了下去。这些扩展都遵循同一个原则:自动化、可量化、不靠自觉。
5.4 我个人的一点体会
做 impeccable 这个过程,让我对“工程质量”有了新的理解。过去我以为质量好是靠几个高手把关,后来发现高手也会累,也会疏忽,也会在周五下午的紧急需求里妥协。真正可靠的质量不是靠英雄主义,而是靠让“正确的事情方便做,错误的事情不方便做”的制度设计。
团队里推行这套机制时,阻力最大的往往不是写代码的人,而是那些总说“这些规则太严格了,我们这是业务项目,不是开源库”的人。我的回答一直是:业务项目才最需要质量基线,因为业务代码会不断生长,没有约束的长线代码最终会变成所有人都不愿意碰的沼泽。给代码装上刹车,不是为了限制速度,是为了让你敢开快车。