前端工程化实践:从代码规范到自动化校验的t3code落地
我在团队里推了近一年的前端工程化改造最后沉淀下来的核心产出就是 t3code。这个项目最初定位非常朴素解决我们团队“项目越来越多、代码风格越来越乱、新人上手成本越来越高”的问题。它不是一个框架也不是一个具体业务库而是一整套“代码规范 项目模板 自动化校验”的基础能力集合。简单说用 t3code 拉起一个新项目三分钟就能跑通提交代码之前一条命令就能完成 lint、格式化、类型检查遇到规范问题绝大多数场景可以自动修复修不了的会直接阻止你提交。这套东西做出来之后团队里从“看别人代码得猜意图”变成了“看代码第一反应是先跑工具检查”效果非常明显。如果你也带着几个前端同学或者你正在为多项目代码风格不统一发愁这篇文章里的思路、目录设计、规则取舍和踩坑记录应该能帮你少走不少弯路。1. 项目定位与核心思路好规范不该停留在文档里1.1 背景十几个项目十几种代码风格最开始我们团队只有两三个人、一个项目代码风格靠自觉。后来业务扩张项目从 1 个涨到十几个人员也从两三个人涨到了七八个。问题开始集中爆发有人用单引号有人用双引号有人喜欢 React 函数组件带React.FC有人直接裸写function状态管理的写法更是五花八门有的用 Redux Toolkit有的直接 useState Context 一把梭。更麻烦的是配置层面的差异。每个项目的eslint.config.js几乎都是复制前一个项目改两行就完事有的项目还在用旧版.eslintrc有的已经切到 ESLint 9 的 flat config。tsconfig.json里的strict有人开着有人没开。这些差异在单个项目里看问题都不大但跨项目协作时每个人都在被迫适应不同的“规则方言”代码评审的大部分时间不是用在业务逻辑上而是用在“这里为什么不用分号”这种毫无意义的事情上。我们试过写《前端代码规范文档》写了几十页结果没人看。文档最大的问题是“静态的”它不会在你拼错一个配置的时候拦住你也不会在 CI 里告诉你怎么改。后来我意识到一件事规范只有变成工具才真正具备执行力。1.2 T3 的三个含义Template、Toolchain、Testingt3code 这个名字不是拍脑袋取的。当时想了好几个方案最后敲定了 t3code因为 t3 恰好代表我们体系的三个核心组成部分。Template 对应“项目模板层”。我们不再允许每个人从头搭脚手架而是通过命令行直接生成结构统一的项目包括目录结构、基础配置文件、CI 流程、环境变量样例。这一层解决的是“初始态一致”。Toolchain 对应“工具链层”。ESLint、Prettier、Stylelint、TypeScript 检查、Commitlint、Husky这些工具被统一封装成可复用的配置包和 CLI 命令而不是散落在各个项目里的复制粘贴。这一层解决的是“过程态一致”。Testing 对应“校验兜底层”。commit 之前跑 lint-stagedpush 之前跑完整的 type-check 和关键单测MR 合并之后由 CI 再复检一遍。这一层解决的是“收口态一致”。这三层合在一起理解下来就是一句话从项目出生的第一天到最后一次提交每一步都有工具兜底而不是靠人脑记忆和自觉。1.3 自研还是用开源脚手架我的取舍逻辑这里一定会有人问直接用 create-vue、create-react-app 或者现成的 monorepo 脚手架不好吗为什么非要自己做一套我的答案是现成脚手架解决的是“生成一个能跑的项目”但解决不了“多个项目长成一家人”。create-vue 生成的项目后续配置升级、依赖版本管理、目录约定它都不管。而 t3code 的核心目标不是“生成”而是“持续对齐”。模板仓库单独管理每当你更新了某条规范所有已经生成的项目都可以通过一条命令同步升级这是普通脚手架做不到的。另外我们团队有 React、Vue、Node 服务、纯 TS 库四种项目类型。用现成脚手架等于要维护四套不同的生成逻辑而 t3code 可以做到“同一套 CLI、同一种交互逻辑、同一份底层规则只是模板内容不同”维护成本反而更低。2. 脚手架的落地实践新项目三分钟跑通2.1 初始化流程设计交互式问答 命名约束t3code 的入口命令非常简单npx t3code create执行命令后CLI 会先问几个问题不是为问而问每个问题都会直接影响生成结果问题可选值影响范围项目类型vue / react / node-lib / ts-lib选哪套模板包管理器npm / pnpm / yarn生成 lockfile 策略和安装命令是否启用组件库element-plus / antd / 无是否引入对应依赖和按需加载配置是否使用 monorepo 子包是 / 否是否生成 workspace 配置Git 仓库地址字符串自动初始化远程仓库和提交信息模板这里有一个我踩过坑后坚持加的设计项目名不能随便填。CLI 会对项目名做校验只允许 kebab-case 小写字母、数字、中划线并且禁止出现“test”“demo”“tmp”这类无意义后缀。以前出现过太多vue2-final-final或者demo-app-v3这种提交记录时间一长根本分不清哪个是正式项目。这个限制看着有点“不讲人情”但对后期维护帮助巨大。生成逻辑的核心代码如下简化过// packages/cli/src/commands/create.ts import { prompt } from prompts; import { renderTemplate } from ../core/render; export async function create() { const options await prompt([ { type: select, name: type, message: 选择项目类型, choices: [ { title: Vue 3 项目, value: vue }, { title: React 项目, value: react }, { title: Node 服务, value: node-lib }, { title: 纯 TS 库, value: ts-lib }, ], }, // ... badge、packageManager、monorepo 等选项 ]); const projectPath resolveProjectName(options.name); const exclude parseGitignore(**/node_modules/**); renderTemplate(options.type, { projectPath, variables: options, exclude, }); installDependencies(projectPath, options.packageManager); initGitRepository(projectPath); }renderTemplate 背后用的并不是简单的文件拷贝而是一个支持 Mustache 语法的小型渲染引擎。每个模板文件里形如{{ projectName }}、{{ packageManager }}的占位符会根据用户输入自动替换。模板里还有一个__optional__目录比如只有在选择启用 element-plus 时__optional__/src/plugins/element.ts才会被真正渲染到项目里。这个设计避免了一个模板要复制出好几份的维护噩梦。2.2 目录结构和关键文件解读默认生成的目录结构大概是这样my-app/ ├── src/ │ ├── api/ # 接口请求层 │ ├── assets/ # 静态资源 │ ├── components/ # 公共组件 │ ├── composables/ # 组合式函数 / hooks │ ├── layouts/ # 布局组件 │ ├── router/ # 路由配置 │ ├── stores/ # 状态管理 │ ├── styles/ # 全局样式 │ ├── utils/ # 工具函数 │ ├── App.vue │ └── main.ts ├── tests/ │ ├── unit/ # 单测 │ └── e2e/ # 端到端测试 ├── .eslintrc-typed/ # 实际是 eslint.config.mjs 的软链 ├── tsconfig.base.json # 统一 TS 基础配置 ├── commitlint.config.mjs ├── .editorconfig ├── .gitignore └── t3code.meta.json # 项目元信息升级模板时使用这个结构里最值得说的两个文件是tsconfig.base.json和t3code.meta.json。tsconfig.base.json不是单独一个项目用的它会让所有子项目和兄弟项目共享同一套编译选项核心是强制开启strict、noUncheckedIndexedAccess、exactOptionalPropertyTypes。这些选项单独看会增加开发时的报错但长期来看能拦下一大批运行时类型错误。以前我们关掉strict的项目后来在迁移期付出了数倍的返工成本这个坑我建议后来者直接规避。t3code.meta.json记录的是当前项目由哪个版本的模板生成{ template: vue, templateVersion: 2.4.0, createdAt: 2024-06-30T10:30:00.000Z, updates: [] }这个文件是后面做模板升级的关键没有它你就无法判断旧项目和当前模板之间到底差了多少个版本。2.3 模板升级如何拯救已经存在的旧项目有了t3code.meta.json我就能实现一个比较实用的功能增量升级。npx t3code update命令执行后t3code 会读取当前项目的模板版本号拉取模板仓库对应版本的差异然后用一个类似 git merge 的流程推进升级。涉及冲突的地方不会自动覆盖而是把每个冲突文件列出来让开发者自己选择“保留本地版本”还是“使用模板版本”。一开始我们想直接整个目录覆盖后来发现这个方案太暴力——很多项目的目录结构虽然都来自模板但里面已经改得面目全非了直接覆盖会丢业务代码。增量升级的语义要安全得多。这个功能的实现思路并不复杂核心就是对比新旧模板的文件哈希输出一份补丁列表再对补丁逐个处理。关键是要给每个模板文件生成一份“指纹”记录在元信息里升级时只需要比对指纹就能快速定位变化。3. 核心能力拆解lint 规则、代码生成器、提交校验3.1 ESLint 规则集从继承到自研的思考过程团队没做 t3code 之前我也天真地以为“直接用 eslint 官方 recommend 配置就够了”。实际情况是官方推荐规则比较保守很多团队内部的历史习惯和管理层要求根本没有覆盖到。比如说我们要求所有组件文件名必须是 PascalCase官方规则不管这个我们又要求 hook 文件名必须以use开头官方也不管。这些需求要落到团队级规范里必须自己写规则。t3code 的规则包组织方式是这样的t3code/eslint-config-base和框架无关的基础规则适用于所有项目核心是 js/ts 通用规则、import 排序、命名规范。t3code/eslint-config-vue依赖 base加入 Vue 3 的规则包括 template 顺序、属性命名、defineProps使用强制。t3code/eslint-config-react依赖 base加入 React hooks 规则、组件定义方式、JSX 属性排序。t3code/prettier-config统一的 prettier 配置配合 lint 使用。规则包本身是发布到私有 npm 仓库的。项目侧只需要这样引入// eslint.config.mjs import base from t3code/eslint-config-vue; export default base({ projectType: vue, enableTypeChecking: true, });函数形式的好处是允许项目按需覆盖。比如有的项目因为历史原因有两只外部接口字段会自动生成下划线字段名规则里默认强制 camelCase此时就可以在具体项目里局部关闭这一条而全局核心规则依然生效。这里我要特意提醒一个坑ESLint 9 之后的 flat config 和旧版的.eslintrc差异很大如果你要自研规则包建议直接从 flat config 起步别再走旧格式的路了。我们早期为了兼容旧项目做过一段双格式适配那个维护成本极高最后还是在项目统一推进迁移时才真正解决了问题。3.2 代码片段生成器让“规范组件”变得高效lint 规则只能检查“写出来的代码符不符合规范”但它管不了“该写的代码有没有写”。比如一个组件内部必须提供displayName必须定义 props 的类型导出必须写基本的注释和默认导出这些规范性内容如果靠手写总会漏。所以我做了 t3code 的第二个核心工具代码片段生成器。最常用的是组件生成npx t3code generate component Button指定生成一个叫 Button 的组件后生成器会做这些事检查目标目录里是否已经有同名组件有则拒绝执行。根据项目类型自动生成.vue文件或.tsx文件。自动从模板里填充name、props类型、事件定义、样式文件引用。自动把新的组件路径追加到 components 索引文件里。生成的 Vue 组件大概是这样的script setup langts export interface ButtonProps { type?: default | primary | danger; size?: small | medium | large; disabled?: boolean; } const props withDefaults(definePropsButtonProps(), { type: default, size: medium, disabled: false, }); const emit defineEmits{ (e: click, event: MouseEvent): void; }(); /script template button classt3-button :disabledprops.disabled click(event) emit(click, event) slot / /button /template style scoped src./button.css/style这个模板看似简单但它把团队约定写进了代码props 必须定义接口并导出、事件必须通过 defineEmits 声明、样式文件与组件同名全部是约定。比起把规范写成文档让人照着敲直接生成规范代码显然高效得多。生成的代码还会自动过一遍 ESLint 和 Prettier。这意味着生成结果一定是“零告警”的开发者在起始点就站在一个干净的基础上后续随便改也很难污染结构。代码生成器的实现不复杂核心是模板路径映射const generatorMap: RecordProjectType, GeneratorContext { vue: { component: (name) path.resolve(templateDir, vue/component.vue.mustache), store: (name) path.resolve(templateDir, vue/store.ts.mustache), // ... }, react: { component: (name) path.resolve(templateDir, react/component.tsx.mustache), hook: (name) path.resolve(templateDir, react/hook.ts.mustache), // ... }, };如果你也想做类似功能我建议尽早建立“原子化模板目录”的概念。每个模板文件保持小而独立组合关系通过 CLI 层控制这样比维护一整个巨型模板文件要好用得多。3.3 提交前校验一条命令管住所有质量红线lint 生成器做得再好如果开发者提交前不跑一切都等于零。t3code 用两套钩子收口质量pre-commit 和 pre-push。pre-commit 阶段通过 husky 触发 lint-staged只对暂存区里的文件做快速检查// package.json { lint-staged: { *.{js,ts,mjs,cjs,tsx,jsx}: [eslint --fix, prettier --write], *.vue: [eslint --fix, prettier --write], *.{css,scss,less}: [stylelint --fix, prettier --write] } }这个阶段的设计原则是“快”绝大多数问题通过--fix自动解决不需要人来参与。只有自动修复不了的问题eslint 才会报错退出。pre-push 阶段则是重头戏执行的是一个自定义 node 脚本npx t3code check --all它会依次执行tsc --noEmit全量 TypeScript 类型检查。eslint .全量 lint和 pre-commit 只看暂存区不同这里看的是整个工作区。vitest run跑所有单元测试。有人会问pre-commit 已经查了 lint为什么 push 还要再全量查一遍这是因为 pre-commit 只查暂存区存在一个经典漏洞如果一个人 commit 之前修改了文件 A然后又改了同模块的文件 B 却没有 stageB 的问题不会被 pre-commit 拦住。全量 check 能最大程度兜住这种漏网之鱼代价是多花十几秒但换来的安心感很值得。如果你用的是 pnpm记得在 install 时加上--frozen-lockfile不然很容易出现本地 lockfile 漂移导致 CI 和本地依赖不一致的问题。这一条是后来被 CI 反复打脸几次之后补上的铁律。4. 常见问题与排查实录这些坑我替你踩过了4.1 ESLint 插件相互打架规则到底听谁的做规则包最痛苦的事情是不同插件对同一类问题给出了方向相反的建议。举两个真实案例。第一个案例import 排序。eslint-plugin-import的order规则要求第三方包排在相对路径前面但项目里如果用了eslint-plugin-simple-import-sort它的排序原则是按字母序而不是按包类型。两个插件同时开着代码永远处于一边修复一边报错的状态。我们的解法是只保留simple-import-sort去掉import/order并且让 prettier 的排序结果作为最终展示层避免二次格式化产生差异。第二个案例typescript-eslint/no-unused-vars和 Vue 的setup语法冲突。在script setup里定义的变量如果只用于模板TS 规则默认认为没有使用。后来的解决方案是在规则包里对.vue文件单独配置{ files: [**/*.vue], rules: { typescript-eslint/no-unused-vars: [error, { argsIgnorePattern: ^_, varsIgnorePattern: ^_, caughtErrorsIgnorePattern: ^_, }], // vue 编译宏变量和模板变量不触发 typescript-eslint/no-unused-expressions: off, }, }处理规则冲突的通用原则我总结成三条同一类问题只交给一个插件管不搞“多重否决”。配置冲突时以“先格式化、再静态检查”的顺序消除重叠地带。大部分冲突都能通过关闭基础规则里的某一项解决不建议通过写 disable 注释绕开那等于把问题直接漏到了下一步。4.2 模板升级导致的“本地魔改文件”丢失增量升级功能上线以后有一次一个老项目跑t3code update升级列表里出现了一个tsconfig.base.json的冲突。开发者着急上线直接点了“使用模板版本”结果他项目本里为特殊构建场景写的几个自定义paths配置全被覆盖构建当场挂掉。这个事故给了我一个教训在交互式升级策略里默认选项绝对不能是“使用模板版本”。后来的处理是把“保留本地版本”作为默认选项并且对于核心配置文件跳出一个二次确认弹窗明确提示“该文件包含本地修改建议先做差异对比再决定”。另外在模板设计阶段就刻意把“公共配置”和“项目私有配置”拆开比如tsconfig.base.json里不写任何paths或include把这些全部放到业务侧tsconfig.json覆盖掉这样模板升级时几乎不会动到业务配置的部分。这个经验后来也移植到了 git 钩子之外所有配置文件模板关闭“自动覆盖”默认只输出 diff 提示永远让开发者的项目掌握最终决定权。4.3 团队新人上手时的三个高频问题t3code 上线之后新人入职第一周最常见的问题都集中在工具使用上这里列出来给同样做内部工具的同学参考。第一个问题是“我直接用npm run dev能跑起来为什么还要走 t3code 的初始化”这个背后是理解偏差。t3code 不是开发服务器它管的是项目生成和“过程红线”。我们的处理方式是让 CLI 在首次执行时输出一段简短的引导文案说明自己的定位并且把常用命令集中到一个README里放在项目根目录新人看一眼就能理解工具边界。第二个问题是“为什么 CI 里能过的规则在我本地报错”绝大多数情况是依赖版本不一致或者某个插件缓存了旧配置。我们给 t3code 加了一条t3code doctor命令专门检测本地环境、依赖版本、配置格式是否符合预期。这个命令后来成为所有新人报到的第一关。第三个问题是“工具会不会限制我写代码”会但限制本身是种保护。一个好的内部工程化项目应该做到“平时无感、违规有感”。日常开发时你不怎么感知到它的存在可一旦要提交不合规的代码它会毫不客气地把门关上。我的经验是一个新工具在团队里推广是否顺畅很大程度取决于这个“进门拦住你”的动作是不是足够早、足够清晰。4.4 Windows 下的脚本兼容问题这个坑很多重 Windows 开发的团队都遇到过。t3code 早期的 commit 钩子用了一段 bash 脚本放在husky那边跑 git 命令Linux 和 macOS 下表现正常到了 Windows 的 Git Bash 环境下脚本里的某些路径解析方式会出问题导致钩子根本不执行。最终解法很直接所有钩子逻辑一律不写在 shell 脚本里husky 只负责触发一条node命令真正逻辑全部用跨平台的 Node 脚本实现#!/usr/bin/env node // scripts/checkStaged.js import { execSync } from node:child_process; import { fileURLToPath } from node:url; const stagedFiles execSync(git diff --cached --name-only --diff-filterACM) .toString() .split(\n) .filter(Boolean); if (stagedFiles.length 0) process.exit(0); execSync(npx lint-staged, { stdio: inherit });这样不管在 Windows 还是 Linux行为都完全一致。如果你做类似工具我建议从一开始就避免写任何平台相关的 shell 脚本直接全部交给 Node 统一处理能省掉一整个季度的“为什么有人机器上跑不起来”类问题。5. 一点补充规则包发布和版本管理经验5.1 规则包的语义化版本策略t3code 的规则包是发布到私有源上的版本管理用的是一套相对严格但实用的策略patch只修复某个规则误报或误自动修复不改变用户侧行为。minor新增一条规则或新的模板能力但已有项目不会因升级而爆红。major删除或提升了某条规则的级别比如从 warning 改成 error或者在升级过程中要求项目做结构性调整。这个分级对下游项目非常重要。因为很多项目会把规则包固定在某一个大版本上如果你把一条破坏性变更发成了 minor所有下游项目会在悄悄升级后爆出一大片错误。为了保险起见我们还加了规则更新说明文档每次发 major 前必须列出“升级后你可能会看到哪些新报错以及对应原因”。5.2 规则包和模板的联动升级模板和规则包是两个独立仓库但它们之间有依赖关系。比如模板里生成了eslint.config.mjs指向t3code/eslint-config-vue^2.0.0模板版本升级到 2.1.0 时可能要求规则包也升到 2.1.0。为了不破坏依赖我们建立了统一的版本面对齐机制模板的package.json脚本里会检查规则包版本是否符合模板的“最低要求”不符合时报错并引导升级。这种做法等于在模板和工具链之间加了一层“兼容性扶手”不再依赖开发者在多个 npm 包之间手工匹配版本。刚开始做的时候麻烦一点但稳定运行后团队就很少因为配置包版本不匹配而出现诡异问题了。个人使用体验和一点建议做 t3code 这一年我最大的体会就是内部工程化项目要想活下来不能靠“强制”要靠“降低摩擦”。如果你设计的规范工具让团队每次提交都要跟编辑器、git 钩子、命令行搏斗很快就会有人想尽办法绕过你。t3code 后来能顺利推下去靠的就是三件事生成项目时把所有标准都埋好真正需要人动手处理的冲突极少提交时绝大多数问题自动修复很少打断心智流出了问题时命令行的报错信息足够清晰能直接告诉开发者下一步该怎么做。如果你也想在团队里做类似的事我的建议是从小的点切入先把 lint 规则包统一起来再慢慢做模板和生成器。不要一上来就铺一个巨大的 monorepo 工具链那样大概率会在第二个月就失去迭代动力。另外一定要给工具留一个“逃生舱”比如t3code update的交互式选择本质上就是在矫正过度自动化的偏航。能在关键路径上把选择权还给开发者工具反而会得到更多信任。