半年前,我们组做 Code Review 的时候,最常听见的一句话是“这代码风格跟我的不太一样”。乍一听是审美问题,实际上背后是规范缺失、工具链断裂和责任边界模糊。后来我们三个人花了两个星期,把散落在各处的 lint 规则、格式化配置、提交检查和评审清单收拢到一起,做成了内部代号为t3code的一套前端工程化基线方案。这篇博文就把 t3code 从想法到落地的完整过程拆开讲一遍,包括为什么叫这个名字、每个配置背后的取舍、怎么从零搭起来,以及我们踩过的几个比较典型的坑。
先交代一下背景:t3code 不是某个开源框架,也不是什么“低代码平台”,它本质上是一套可复用、可版本化、可被存量项目渐进式接入的代码规范与工具链集合。适合正在为“代码风格不统一”“提交信息乱七八糟”“CI 里 lint 形同虚设”而头疼的团队,也适合一个人维护多个项目、希望把工程经验沉淀成统一配置的开发者。如果你正处于“知道自己该定规范,但不知道怎么落地”的阶段,这一篇可以直接抄作业。
1. 从代号到体系:t3code 的诞生背景与整体思路
1.1 为什么是“t3”这三个字符
t3code 这个名字源自我们内部的三个关键词:Team(团队)、Three(三阶段)、Trinity(三位一体)。三个人组了一个临时小组,约定用三个阶段把规范推下去,所以取首字母 T,加上 code,就成了项目代号。
第一阶段是“定标准”,解决“什么样的代码算好代码”;第二阶段是“上工具”,让机器替人检查;第三阶段是“养习惯”,通过提交信息规范、Code Review 模板和文档示例把标准固化成协作方式。三个阶段不是串行的,实际执行中会有反复,比如第三阶段暴露出的问题又会回流到第一阶段去改规则。
从团队协作角度看,把这三件事分开很重要。很多团队失败的原因是把“规范”和“工具”混在一起谈,最后变成安装了一个 ESLint 插件就以为规范落地了。其实规范是语义层的约定,工具只是把它变成机器可执行的形式。t3code 的第一版文档里就明确写了:规则不解释清楚动机,就一定会有人绕过它。
1.2 整体拆解:规范、工具、流程三个维度
t3code 的体系结构可以拆成三个维度,每个维度都有自己的产物和责任人。
第一层是规范文档,资产包括命名约定、目录结构约定、组件设计约定、CSS 类名约定,它以 Markdown 形式存到仓库的 docs 目录里,作为评审时的“法律依据”。
第二层是工具配置,资产包括 ESLint 规则集、Prettier 配置、Stylelint 配置、Commitlint 配置、EditorConfig 配置,这些被打包成 npm 包,通过版本号管理。选 npm 包而不是直接复制 .eslintrc 文件到各个项目,是因为后续改规则只需要发一个新版本,存量项目执行一条升级命令就能同步,不用挨个仓库去改文件。
第三层是流程模板,包括 Code Review 指引、PR 模板、issue 标签约定、提交信息格式示例,这些以文件形式放在项目根目录的 .github 或 docs 目录中。流程模板的作用是减少“讨论成本”,比如提交信息格式定了之后,就不要再为“feat 后面要不要加冒号”争论了。
这三个维度对外表现为 t3code 的一个仓库,对内则对应三条明确的分工:一个人维护规则包、一个人维护文档和示例、一个人负责在存量项目中做接入试点。分工明确之后,推进速度比之前开三次全员会快得多。
1.3 技术选型:为什么是 ESLint + Prettier + Stylelint + Commitlint 这条组合
t3code 的工具链选了目前社区最成熟的一条组合:ESLint 管 JS/TS 的代码质量、Prettier 管格式化、Stylelint 管样式、Commitlint 管提交信息、Husky 负责在 Git 钩子阶段触发检查。这套组合的优点是生态稳定、文档齐全、团队成员的既有认知成本低。
选型的时候也考虑过更轻量的方案,比如只用 Prettier + 简单的脚本检查,但后来放弃了。原因是 Prettier 只管格式,不管“禁止使用 console.log 提交到主干”这种逻辑层面的问题,而这些恰恰是评审中最容易反复出现的问题。ESLint 通过自定义 rule 或引入 eslint-plugin-regexp 这类插件,能把团队约定转换成可自动校验的规则,这是轻量方案做不到的。
还有一点比较关键:Husky 的版本选择。我们用的是 Husky v9,它要求 Git 版本不低于 2.13,且初始化方式与 v4 完全不同。如果你网上搜到的是 v4 的配置方式,照着做大概率会失效。这个细节会在后面“常见问题”部分重点展开。
2. 核心规范解析:把“代码标准”落到纸面上
2.1 命名与目录结构约定
t3code 的第一份规范文档里,争议最大的不是 ESLint 规则,而是目录结构和命名。每个团队都有自己的历史包袱,约定不可能一蹴而就,但至少可以约定“新增代码”的规则。
我们为 React + TypeScript 项目定下的目录结构是这样的:src 下分 components、hooks、pages、services、styles、utils、types 七个顶级目录。components 目录内按业务域分子目录,每个组件目录包含 index.tsx、style.ts、types.ts、index.test.tsx 四个文件。文件名使用 PascalCase,常量文件使用 camelCase,工具函数使用 camelCase。
命名规范方面,我们吸取了一个比较痛的教训:以前项目里大量出现 btn、list、data 这类含糊变量名。t3code 规定:组件目录名和组件函数名必须一致;状态变量使用名词加状态的组合,比如 isLoading、hasError;回调函数统一用 handle 开头,比如 handleClick、handleSubmit,避免出现五花八门的 onClickXXX。事件处理函数里动宾结构优先,比如 submitForm 而不是 onForm。
这些规范最有效的地方在于写进了 ESLint 的自定义规则或通过 eslint-plugin-react 的 prop-types 相关规则间接校验了一部分。比如组件函数名和目录名的一致性,依靠规范文档人工检查,但变量命名规范里“禁止出现单个字母变量名(除循环变量)”是可以通过 no-restricted-syntax 规则做的。
2.2 代码质量规则的分级策略
t3code 把 ESLint 规则分成两类:error(必须改)和warn(建议改)。error 级别包括未使用变量、隐式 any、浮点数精度比较、重复导入等会直接导致 bug 或维护困难的问题;warn 级别包括 console.log、todo 注释、嵌套过深等可以阶段性处理的问题。
这个分级很重要,因为如果一开始就把所有规则都设成 error,存量项目接入时会产生海量报错,团队会直接放弃。t3code 的策略是:新项目直接使用最严配置,存量项目先用 warn 跑一周,沉淀出真实问题样本后,再把高频问题升级为 error。规则分级不是技术问题,而是推进节奏问题。
举一个具体例子:我们默认开启了@typescript-eslint/no-explicit-any为 warn,因为很多老代码里的 any 其实有历史包袱,直接改成 unknown 可能引发类型错误。但新增代码出现 any 是绝对不允许的,所以我们加了一条自定义规则:在 jslint 配置中通过 overrides 对 src 下的新增文件(即 git diff 新增的行)单独启用 error 级别。这个能力通过 lint-staged 配合实现,后面会详细说。
2.3 提交信息与 Git 工作流约定
提交信息规范是 t3code 里成本最低但回报最明显的一块。我们接入了 Commitlint 和 Husky 的 commit-msg 钩子,格式统一为 type(scope): subject,其中 type 限定为 feat、fix、refactor、docs、chore、test 六个,scope 限定为组件名或模块名,subject 用动词开头、不超过 50 个字符。
有人会觉得提交信息规范是形式主义,但我们统计过:在接入 commitlint 之前,PR 描述的质量参差不齐,因为提交信息太混乱,很多 reviewer 根本看不出这个 PR 的意图。有了统一格式之后,至少能快速从 Git 历史里筛选出“feat”和“fix”来确认改动范围,这在该回滚 release 版本时会非常救命。
Git 分支模型方面,t3code 没有引入复杂的 Git Flow,而是用主干开发 + 特性分支 + 短生命周期 PR 的方式。每个功能分支命名格式为 feat/description 或 fix/description,方便 CI 自动关联到对应 issue。规则简单到连实习生都不会弄混,执行率反而比过去用 Git Flow 时高了很多。
3. 实操落地:从零搭建 t3code 工具链
3.1 初始化项目与基础配置
下面进入实操环节,假设我们要在一个新的 npm 仓库里搭建 t3code 的核心包。先创建项目并初始化:
mkdir t3code cd t3code npm init -y git init然后安装基础依赖。这里建议把包的 type 设为 module,并配置 exports 字段,方便在 ESM 和 CJS 之间做兼容。基础依赖分为三类:核心检查工具、ESLint 相关插件、辅助工具。
npm install --save-dev eslint prettier typescript @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-config-prettier eslint-plugin-react eslint-plugin-react-hooks stylelint stylelint-config-standard stylelint-config-prettier commitlint @commitlint/cli @commitlint/config-conventional husky lint-staged这里有个细节:eslint-config-prettier 必须排在 extends 数组的最后,否则 Prettier 和 ESLint 在格式规则上冲突时不会正确报错。它做的事情是关闭 ESLint 中所有可能与 Prettier 冲突的格式化类规则,而不是让 Prettier 去覆盖 ESLint 的判断。
接着创建 .editorconfig 文件,统一缩进和换行符:
root = true [*] charset = utf-8 indent_style = space indent_size = 2 end_of_line = lf insert_final_newline = true trim_trailing_whitespace = trueEditorConfig 的优先级高于编辑器的默认配置,但低于 ESLint 的 prettier/prettier 规则。所以它更像是一层“编辑器级兜底”,真正在 CI 层把关的还是 ESLint 和 Prettier。
3.2 集成 ESLint、Prettier 与自动修复
ESLint 的配置我们写成 eslint.config.js(ESLint 9 的 flat config 格式)。解释一下为什么要用 flat config:ESLint 9 之后,旧的 .eslintrc 格式已经进入维护模式,新项目直接用 flat config 可以避免以后迁移成本。flat config 和 eslintrc 的差异主要是“不再有 extends 的隐式继承”,所有配置都必须显式写在数组里。
一段可以用的 ESLint 配置示例:
import js from "@eslint/js"; import tseslint from "typescript-eslint"; import reactHooks from "eslint-plugin-react-hooks"; import prettier from "eslint-config-prettier"; export default [ js.configs.recommended, ...tseslint.configs.recommended, { files: ["**/*.{ts,tsx}"], plugins: { "react-hooks": reactHooks, }, rules: { ...reactHooks.configs.recommended.rules, "@typescript-eslint/no-unused-vars": "warn", "@typescript-eslint/no-explicit-any": "warn", }, }, prettier, { ignores: ["dist", "node_modules", "coverage"], }, ];注意这里我们没有加 react 相关的推荐规则集,因为 t3code 的第一版只针对 TS/React 场景做示例,实际使用时你可以把 react 插件的 recommended 一起扩展进去。typescript-eslint这个包是官方推荐的统一入口,同时提供了 parser 和 plugin,代码上比分别安装 @typescript-eslint/parser 和 @typescript-eslint/eslint-plugin 更简洁。
Prettier 的配置故意保持最小化:
{ "printWidth": 100, "singleQuote": true, "trailingComma": "es5", "semi": true }之所以尽量少配置,是因为 Prettier 的设计哲学是“可配置项越少越好,风格问题交给默认值”。团队里最容易起分歧的其实是 semicolon 和单双引号,把这两个定了,其他交给 Prettier 就行。有些团队喜欢把 printWidth 设成 120,我建议先用官方默认 80,之后如果真的觉得换行太频繁,再改成 100,不要一开始就设 120,因为过长的行在 Review 工具里体验很差。
自动修复链路的最后一环是 lint-staged,它只对暂存区的文件运行检查,避免全量检查拖慢提交速度。在 package.json 里配置:
{ "lint-staged": { "*.{ts,tsx}": ["eslint --fix", "prettier --write"], "*.{css,scss}": ["stylelint --fix", "prettier --write"] } }lint-staged 的意义在于把检查范围缩小到 diff 涉及的文件,这样就算存量项目历史上有 1000 个 lint 错误,也不会阻塞你提交新代码,只是新增部分必须干净。
3.3 加入 Stylelint 与提交前检查
样式规范这一块,我们踩过一个坑:早期只配置了 ESLint 和 Prettier,CSS 里出现重复颜色值、未知属性这类问题完全靠肉眼。后来引入了 Stylelint,规则集直接用 stylelint-config-standard,再加了几条自定义规则,比如禁止 id 选择器、颜色值必须使用变量等。
Stylelint 配置示例:
export default { extends: ["stylelint-config-standard", "stylelint-config-prettier"], rules: { "color-no-hex": true, "selector-id-pattern": null, }, };把 color-no-hex 开成 true,意味着所有颜色值必须走变量或色板,这样主题换肤时只需要改变量文件。这个规则在接入了设计令牌(design token)体系的团队里非常实用。
接下来配置 Husky 和 Commitlint。Husky v9 的初始化命令是:
npx husky init执行后会在项目根目录生成 .husky/pre-commit 文件,默认内容是npm test,我们把它改成:
npx lint-staged然后创建 .husky/commit-msg 文件:
npx --no -- commitlint --edit "$1"这一行命令的作用是在每次提交时校验提交信息是否符合我们的格式约定。如果你团队用的是 pnpm,注意把 npx 换成 pnpm exec,避免因为包管理器差异导致钩子命令找不到可执行文件。
3.4 发布为团队私有包并接入存量项目
配置验证通过后,我们把 t3code 发布到公司内部的 npm registry,包名定为 @team/t3code。里面包含 .eslintrc 或 eslint.config.js 的导出、Prettier 配置、Stylelint 配置以及配套文档。这样存量项目只需要两条命令就能接入:
npm install --save-dev @team/t3code npx t3code initinit 命令做的事情是:读取项目现状、生成对应的配置文件入口、并打印一份接入报告,告诉你有多少存量 lint 错误、这些错误集中在哪些文件、建议的修复顺序。这个“接入报告”是我们觉得最有价值的功能,因为它把“抽象地讲规范”变成了“具体地列任务”。
接入一个新项目时,我建议按照“格式化修复 → 低风险错误修复 → 中高风险错误修复 → 规则升级”四步走。格式化修复最简单,直接跑 prettier --write 全量处理;低风险错误包括未使用变量、隐式导入类型错误等;中高风险则涉及 any 类型、数组索引访问、React hooks 依赖缺失等,需要人工确认。
4. 常见问题与排查技巧实录
4.1 Husky 钩子不生效的排查
这是整个落地过程中被问得最多的问题。常见现象是:git commit 时 pre-commit 完全没有触发,或者提示command not found: lint-staged。
第一步排查 Git 版本,Husky v9 要求 Git >= 2.13,老版本会直接失败。第二步确认 .husky 目录是否正确生成,并且该目录下的文件有可执行权限。第三点最容易忽略:Husky 钩子在 clone 后默认不生效,必须执行npx husky install或者首次运行npm install时自动触发 prepare 脚本。所以我们把 husky 的安装命令写成了npx husky init && npm run prepare,并在 package.json 里配置:
{ "scripts": { "prepare": "husky" } }prepare 脚本会在 npm install 自动执行,这样团队成员克隆仓库后装上依赖就能直接用钩子,不需要额外命令。很多教程没有提这一点,导致其他人拉代码后钩子失效,其实根因就是 prepare 脚本缺失。
4.2 Monorepo 与 workspace 场景的坑
如果用 pnpm workspace 或 npm workspaces,husky 和 lint-staged 的配置位置有讲究。最简单的做法是在 monorepo 根目录统一安装 husky 和 lint-staged,钩子文件也放在根目录。因为 Git 默认的 hooks 路径是仓库根目录,子包内安装的 husky 不会生效。
但 lint-staged 对 monorepo 的支持稍微复杂一点:如果每个子包都有独立 ESLint 配置,你可能需要按 package.json 里的 lint-staged 配置分区执行。我们的做法是在根目录配置统一的 lint-staged,并通过 commands 参数指定每个子包用自己目录下的配置:
{ "lint-staged": { "packages/**/src/**/*.{ts,tsx}": [ "eslint --fix --config packages/eslint.config.js" ] } }这样能保证每个包的规则边界是清晰的,不会被根目录的配置误伤。不过 monorepo 中 ESLint 的解析会因为 tsconfig 的 project 引用而变得复杂,建议在初步接入时先用parserOptions.project: false关闭类型感知规则,等能跑通基础规则后再逐步开启。
4.3 规则冲突与覆盖顺序
ESLint 在 flat config 中,数组后面的配置会覆盖前面的配置,这就是为什么 prettier 必须放在最后。但实际中我们遇到过@typescript-eslint/no-unused-vars和 Prettier 同时报错的情况,原因是某些参数被标记为未使用,而 Prettier 的格式化又把参数换行了,导致看起来两个工具莫名“互掐”。
解决办法是在 ESLint 的 no-unused-vars 规则里配置argsIgnorePattern: "^_",约定用下划线开头的参数表示“有意忽略”。这样既保留了未使用变量的报错能力,又不会因为函数签名里有一个不用的参数就全局报错。这个约定同时写进了规范文档,让 review 的人知道下划线参数是显式声明,不是遗留垃圾。
还有一个经常被忽视的覆盖问题:ESLint 的 overrides 是按文件模式匹配的,但 flat config 里没有 overrides,而是每个配置对象自己的 files 字段。迁移到 flat config 时,如果照搬 .eslintrc 的 overrides,很容易出现规则完全不生效的情况。建议对照官方迁移文档逐个检查 files 模式的正则写法。
4.4 问题排查速查表
| 现象 | 可能原因 | 排查步骤 | 解决方案 |
|---|---|---|---|
| commit 不触发 lint | husky prepare 未配置 | 执行ls -la .husky/查看钩子文件 | 配置 prepare 脚本后重新npm install |
| lint-staged 不格式化新增文件 | 配置文件 glob 未匹配新增文件名 | npx lint-staged --debug查看匹配列表 | 调整 glob 表达式,如**/*.{ts,tsx} |
| ESLint 和 Prettier 冲突报错 | 缺失 eslint-config-prettier | 查看报错规则是否与格式有关 | 在 extends/数组末尾加入 prettier 配置 |
| commitlint 不能识别中文提交信息 | subject 长度校验超限 | 看报错是长度还是格式 | 长度规则可自定义为 100,但建议保持短提交 |
| monorepo 下钩子失效 | husky 安装在子包 | 检查根目录 .git/hooks | 在 workspace 根目录集中安装 husky |
| Flow 类型文件被 lint 检查 | ESLint files 缺少 ignore 表达式 | 查看 lint 报告里的文件路径 | 在 ignores 数组中排除.js.flow或 build 目录 |
| 纯 JS 项目非要跑 TS 解析 | parser 配置错误 | 检查 parserOptions 中 ecmaVersion 与 sourceType | 移除 @typescript-eslint/parser 或仅对 ts/tsx 使用 |
表格之外再分享一个实用配置:在 CI 里跑 lint 时,建议增加--max-warnings=0参数,让 warn 级别的警告也导致任务失败。这个听起来“过于严格”,但实际执行下来会发现它逼着团队在一周内把 warn 级问题清理干净,否则合并请求会被一直拦截。相比人盯人式的督促,机器拦截省心得多。
还有一个容易踩到的坑:Prettier 格式化会在某些情况下把 import 顺序打乱,而 eslint-plugin-import 有 order 规则,这两者同时开可能会导致无休止的“格式化后报错,修复后又不合一”。我们的处理方式是放弃在工具层控制 import 顺序,而是靠约定:第三方依赖在前,绝对路径导入次之,相对路径导入最后。人工评审时扫一眼就行,不值得为这个上工具复杂度。
5. 存量项目渐进式接入:t3code 的落地节奏
接入存量项目,最忌“一步到位”。我们的做法是先用一周时间做体检。体检脚本会统计项目内 lint 错误总数、按规则聚合 Top 20、按文件统计密度,然后输出一份 Markdown 报告。这份报告不是用来吓人的,而是用来排优先级。
假设统计结果显示“no-unused-vars”错误 300 个,“react-hooks/exhaustive-deps”错误 50 个。那么显然先从 300 个那个规则开始清理,因为它机械性强、不会引发行为变化,修完能立刻把错误总数降下来,提升团队信心。而 react-hooks/exhaustive-deps 这种规则,误报比例可能不低,修的时候需要看上下文,更适合第二阶段再集中处理。
接入期间建议在 CI 上开“warn 模式”两周,让错误只出现在日志里,不阻塞合并。两周之后做第二次统计,看错误数量是否自然下降,再决定是否切换为 error 模式。我们实际执行下来,这个过渡期大概比预期快一倍,因为 warn 模式虽然不阻塞,但大家看到自己的 PR 日志里全是红色 warning,自尊心会驱使他们顺手修掉。
存量项目里还有一个比较特殊的情况:老代码里大量使用了相对路径 import,甚至路径深度到了 ../../../utils/xxx。t3code 不会强制你立刻改成 alias,因为这类重构风险高、收益慢。我们的约定只有一条:新增文件必须使用 alias 导入,老文件在修改时顺手改。这条约定执行半年后,新文件基本都不再出现深层次相对路径了。
6. 经验复盘与最后几个建议
t3code 落地到现在,最大的收获不是 lint 错误清零,而是团队对“代码评审”的讨论焦点变了。以前评审是在争论“这个变量名好不好”,现在是在讨论“这个设计能不能复用”“边界情况有没有考虑”。前者是审美分歧,后者是工程质量。工具把低层次的争议过滤掉,人的精力自然就上收到更高层次。
如果你也要做类似的事,我有几个比较具体的建议。第一,不要一开始就追求规则大而全,选 30 到 50 条核心规则就够用,规则越多,误报越多,信任消耗越快。第二,所有规则必须写成文档,并且备注“为什么”,比如禁止使用any的原因要写“因为 any 会关闭类型检查,导致重构时无法利用编辑器跳转”,而不是简单写“禁止 any”。第三,配置包一定要走 npm 版本管理,不然后期收敛不同项目的规则版本会变成一场灾难。
最后再分享一个小技巧:在 t3code 的配置包里,我们把版本号和变更记录做成了命令行工具,执行npx t3code changelog就能看到最近三个版本的规则差异。这个功能在团队升级依赖时特别好用,因为你不能再丢出一句“你升级一下 @team/t3code”就不管了,而是能明确告诉伙伴“这个版本加了哪些新规则、可能影响哪些代码”,让升级变成一次有预期的、低风险的操作。
t3code 本身不是一个有“技术含量”的项目,它没有新颖的算法,也没有复杂的架构。但从协作效率和代码可维护性的角度看,它是我们这半年投入产出比最高的一笔投资。如果你的团队还在为代码风格和评审效率头疼,不妨照这个思路,从最小闭环开始搭建属于你们自己的“t3code”。