
又到了周五晚上。本来计划用一个小时把新项目的架子搭起来结果光是在tsconfig.json、ESLint 规则、测试框架、CI 脚本之间来回折腾就耗掉了整个晚上。代码没写几行配置文件倒是删了又建、建了又删。我相信这不是我一个人的经历只要是正经写过 TypeScript 项目的人多少都经历过这种工程化配置比写业务代码还累的时刻。正是因为这个原因我断断续续整理了一套自己的工程化方案起名t3code。它不是某个大厂出的重量级框架就是一个私人的、面向 TypeScript 开发者的项目脚手架与规范集。T3 这个代号有三层意思Template模板、Type类型、Test测试——分别对应项目怎么搭数据怎么传质量怎么守。这篇文章我不打算写什么官方文档就把 t3code 到底是什么、里面怎么组织、实际跑起来长什么样、以及我在这个过程中踩过的坑一次性讲清楚。如果你是一个独立开发者、正在带一个小团队或者经常需要从零开新项目这篇文章应该能帮你省下不少时间。就算你最后不打算用 t3code光看里面关于 tsconfig、ESLint、测试框架、CI 的取舍思路也值得花几分钟。1. t3code 是什么三个字母背后的工程化执念先说结论t3code 是一个以约定为基础的 TypeScript 工程化工具核心是一条命令初始化项目一条命令校验质量。它把一套我认为合理的默认配置打包成 preset通过 CLI 暴露出去。你不需要理解每一个配置项为什么这么写但如果你想改也有足够的出口。1.1 拆开 T3 这个代号Template模板不是简单地把一个模板仓库拷贝到本地。t3code 的模板是带变量的、可组合的。比如你要起一个 Node API 服务它会自动选择node-api骨架你要是想做一个 React 前端它会选择react-web骨架。公共部分像.gitignore、package.json基础字段、tsconfig.base.json这些全部从templates/base里合并进来避免同一个文件在两个模板里维护两遍。Type类型这一层是我投入精力最多的地方。Typescript 的strict只是起点真正让人头疼的是跨端类型共享。同一份数据结构后端定义一遍前端再写一遍 interface两边经常对不上。所以 t3code 内置了一个基于 OpenAPI/Swagger 的客户端生成逻辑后端把路由描述成 schema前端用一条命令生成类型安全的 API 调用代码。类型不再是写着玩的而是真正成为前后端的契约。Test测试测试这件事最大的问题不是不会写而是忘了跑。所以 t3code 的默认约定是把 lint、类型检查、单元测试、构建全部串成一条t3code check命令。每次提交代码之前你必须跑一遍它。跑不过提交就进不了 pre-commit 钩子。这比我见过的很多项目测试是可选项的做法要残酷但确实有效。1.2 它和 create-react-app、Vite 模板这些工具有什么区别可能有人会说现在 create-vite、create-next-app 已经把体验做得很好了为什么还需要 t3code我用一个表格来说明它们的分工差异工具/方案解决的核心问题覆盖范围与 t3code 的关系create-vite / CRA快速起一个能跑的前端工程打包、开发服务器、基础模板可以作为 t3code 的底层渲染器但不是全部私有模板仓库把团队已有项目复制一份改改目录结构、基础配置一次性操作后期没有维护关联plop / hygen在已有项目里生成文件按需生成组件、页面等局部代码定位不同t3code 管整仓初始化plop 管单文件生成t3code覆盖初始化 日常校验 升级维护全链路目录结构、类型契约、测试门槛、CI 集成它自己想做的是一整条闭环最大的区别在于生成完项目之后t3code 还会继续参与你项目的生命周期。比如某天我更新了预设里的 lint 规则或者 tsconfig 配置你不需要手动去改项目文件只需要执行t3code update它会自动把增量改动合并进现有项目同时保留你对项目的定制内容。这一点是普通脚手架工具做不到的。1.3 哪些人最适合用 t3code我自己总结下来这三类人用它的收益最大独立开发者特别是那种一个月要开两三个小项目的人。省下的配置时间比学工具的时间多太多了。5 到 20 人的小团队。团队规模一大规范统一就是头等大事。与其在 code review 的时候一遍遍说这里类型不对那边单测没写不如直接在工具层面卡住。经常写演示项目、教学项目、开源示例的人。拿 t3code 初始化的项目可以直接开源出去因为它默认就带完整的规范文件别人 clone 下来不会一脸懵。反过来如果是几百人的大厂内部已经有一套成熟脚手架那就没必要用 t3code。它面向的是没有专职工程化团队也想拥有工程化体验的人。2. 从一团乱麻到一套规范t3code 诞生的真实背景每个工具背后都有一个烦人的故事。t3code 也一样它的诞生源于我多次在项目初始化阶段被配置问题折磨的经历。2.1 我见过的项目启动灾难先描述几个我实际见过的场景看看你有没有共鸣场景 A从老项目拷贝配置。有一天新项目开张同事把上上个项目的tsconfig.json复制了过来。结果项目里根本没有那个配置文件里引用的路径别名依赖代码里用了/开头的 import构建直接报找不到模块。接下来就是漫长的配置修正。场景 BESLint 规则集互相打架。项目里同时装了eslint-config-airbnb-风格的规则和 Prettier格式化之后代码在 ESLint 眼里仍然是一堆错误。原因是没有配eslint-config-prettier去关掉冲突的格式类规则。这种问题你说严重吧不严重但它会消耗掉每个人每天的零碎时间。场景 C测试框架选了又换。今天用 Jest明天发现需要 ESM 支持换 Vitest换完 Vitest 发现覆盖率插件又要重新配。项目刚开始一个礼拜测试文件一个没写测试框架已经折腾三轮了。这些问题单拎出来都不算难但它们是踩坑—搜索—修复—再踩坑的循环。最让人受不了的是这些问题和业务完全无关。我一次次意识到工程化本身应该是一件被解决过的事情不应该让每个开发者重复探索。2.2 为什么是CLI 模板 规范集这种形态市面上不是没有 generator 类的工具。yeoman 曾经很火plop 也很好用为什么 t3code 不干脆做成一个 yeoman generator原因在于生命周期。yeoman generator 是一次性的项目生成后生成工具和你的项目再也没有关系。后续你的团队规范要是升级了怎么办还是得靠人肉去改。t3code 的做法是把初始化和日常校验绑在一起通过同一条 CLI 暴露初始化阶段用的 template被编译进工具内部日常阶段用的 check/update 命令读的是同一套 preset团队规范升级时t3code update负责把差异部分合并进项目。这就相当于项目从一开始就和工具建立了一条可维护的连接而不是生成完就各走各的。这个设计决策是所有后续功能的基础也是 t3code 和普通脚手架最本质的区别。2.3 设计目标与取舍约定优于配置但保留出口我在一开始就定了几条设计原则之后所有功能都围绕它们展开命令越少越好。用户只需要记住init、dev、check、update这几条不应该需要读几百页文档才能上手。默认值必须够严格。strict模式、类型检查、测试覆盖率门槛、pre-commit 钩子这些默认都打开。严格是默认值想放松是你主动的行为而不是反过来。允许逃逸escape hatch。任何强制约定都不能锁死用户。t3code.config.ts可以关闭部分检查项、覆盖页面路径规则、调整覆盖率阈值。规则是默认的不是绝对的。聚焦 TypeScript不贪多。很多人劝我把 Python、Go 也加进来我都拒绝了。贪多嚼不烂把 TypeScript 生态做透比做一个什么都会一点的四不像有价值得多。这些原则说穿了也简单但真正执行起来需要不断抵抗增加功能的诱惑。到现在为止t3code 的核心功能列表依旧很短这也保证了它上手很快。3. 目录里到底藏了什么t3code 的项目骨架拆解工具本身也是一个正经的 TypeScript monorepo 项目。如果你 clone 下来会看到这样的结构t3code/ ├── packages/ │ ├── cli/ # 命令入口参数解析、交互提示 │ ├── core/ # 核心逻辑模板引擎、配置读取、任务编排 │ ├── presets/ # 内置规范集react、node、lib 等 preset │ └── shared/ # 共享的类型定义与工具函数 ├── templates/ │ ├── base/ # 所有项目共享的公共骨架 │ ├── react-web/ # React Vite 前端示例骨架 │ └── node-api/ # Node Fastify 后端示例骨架 ├── docs/ │ └── guides/ # 使用指南与决策记录 ├── pnpm-workspace.yaml └── package.json3.1 核心模块各自干什么packages/cli负责面向人的那一层。参数解析、交互式提问比如让你选择前端/后端/全栈、命令分发。这里的代码很薄真正的逻辑都在 core 里。packages/core核心逻辑所在地。读取t3code.config.ts、渲染模板、处理文件冲突、编排 lint/typecheck/test/build 等子任务。所有命令最终都是在这里执行的。packages/presets预设规范集。一个 preset 就是一个描述文件里面声明了该用哪个 tsconfig 基底、哪些 ESLint 插件、测试框架是什么、覆盖率阈值多少。templates/项目模板。base 是公共部分react-web 和 node-api 是具体的业务骨架。模板文件里面包含{{placeholder}}这样的占位符core 在复制时会替换成项目名、包名、作者等信息。3.2 preset 里面的规范细节值得抄如果你不想用 t3code但想借鉴它的配置思路我建议重点看这三个文件tsconfig.base.json把通用编译选项抽出来分为 base、node、web 三层。base 只放所有环境通用的选项node 层开启composite、declaration这类库项目需要的选项web 层开启jsx和dom相关的 lib。eslint.config.js用 ESLint 9 的 flat config 写法按语言基础规则 → TypeScript 规则 → React/Node 环境规则 → Prettier 兼容层的顺序合并。eslint-config-prettier永远放在最后这是避免规则冲突的关键。.lintstagedrc配合 husky 的 pre-commit 钩子只对暂存区里改过的文件做格式化与基础检查。这样大项目里 commit 速度不会因为全部检查而被拖垮。这些文件单个拎出来都是社区最佳实践的结晶。区别在于大多数人是靠搜索引擎把它们一个个找齐的而 t3code 把它们打包好了开箱即用。3.3 自定义扩展机制每个人都有自己偏好的技术栈。t3code 的 preset 设计允许你做两件事在t3code.config.ts里覆盖已有的规则字段比如修改覆盖率阈值、追加 ESLint 插件写一份自定义 preset通过extends字段继承内置 preset再增删规则。如果你以后想把某个自定义规范同步给团队所有项目只需把 preset 文件放到一个共享的 npm 包或者 git 仓库里然后在每个项目的配置中引用它。这和复制配置文件发给每个人是完全不同的体验因为t3code update会基于 preset 的源做 diff 合并。4. 跑通一个真实项目t3code CLI 的完整实操前面说了这么多理论不实际操作一下等于白说。这一节我带你完整跑一遍从安装、初始化到写一个最小 API 服务再到跑 check 和生成类型安全的客户端。4.1 安装与初始化t3code 不需要全局安装用 npm 的 create 命令或者 pnpm 的 dlx 都可以# 方式一npm npm create t3codelatest my-app # 方式二pnpm pnpm dlx t3code init my-app初始化过程中终端会出现几个交互问题大意是选择项目类型前端React Vite/ 后端Node Fastify/ 全栈前后端放在一个仓库里选择包管理器npm / pnpm / yarn / bun选择测试框架Vitest默认/ Node 内置 test runner是否开启覆盖率门槛默认开启行覆盖率需达到 80%选完之后t3code 会把模板文件复制到目标目录执行依赖安装然后自动运行一次t3code check验证项目可用。这一步会在确认环境无误之后才提示你 creation succeeded。4.2 初始化完成后的目录长什么样我们选一个后端Node Fastify项目作为例子生成的目录结构大致如下my-app/ ├── src/ │ ├── index.ts # 服务入口 │ ├── routes/ │ │ └── ping.ts # 一个健康检查路由 │ ├── schemas/ │ │ └── ping.schema.ts # OpenAPI schema 描述 │ └── test/ │ └── ping.test.ts # 对应测试 ├── openapi/ │ └── spec.yaml # OpenAPI 规范文件 ├── t3code.config.ts # 项目级配置 ├── tsconfig.json # 继承自 preset 的配置 ├── eslint.config.js ├── .prettierrc ├── .husky/ │ └── pre-commit ├── .github/ │ └── workflows/ │ └── ci.yml └── package.json每个文件都不是摆设。openapi/spec.yaml是整个类型系统的源头后面t3code gen会读它.github/workflows/ci.yml里已经默认配置好了 CI 流程你推上 GitHub 就会自动跑。4.3 日常命令怎么用t3code dev启动开发服务器。对于 node-api 项目就是跑 Fastify对于 react-web 项目就是跑 Vite。它内部会读取 preset 里声明的 dev 脚本不需要你自己去记命令。t3code check这条是最关键的。它依次执行以下任务ESLint 检查TypeScript 类型检查tsc --noEmit单元测试含覆盖率统计生产构建任何一步失败命令都会以非零状态码退出pre-commit 钩子会拦住提交。t3code gen根据 OpenAPI 规范生成 API 客户端代码。这是Type这一支柱的核心功能。比如你在后端定义了一个/ping的 schema前端项目里执行t3code gen它会在src/api目录下生成ping.ts里面包含了完整的请求函数和响应类型。这样前端调用接口时根本不可能把响应字段的名字写错因为错误在编译期就会暴露。t3code update把 preset 的最新改动合并到当前项目。我会在第五节详细讲它的坑。4.4 一个最小案例的完整演示我们在生成的src/schemas/ping.schema.ts里定义一个最简单的接口描述export const pingSchema { path: /ping, method: get, response: { 200: { type: object, properties: { pong: { type: boolean }, timestamp: { type: string, format: date-time }, }, }, }, } as const;然后在路由文件里实现它import { FastifyInstance } from fastify; import { pingSchema } from ../schemas/ping.schema.js; export async function pingRoutes(app: FastifyInstance) { app.get(pingSchema.path, async () { return { pong: true, timestamp: new Date().toISOString(), }; }); }接着写一个测试import { describe, expect, it } from vitest; import { buildApp } from ../app.js; describe(GET /ping, () { it(should return pong with timestamp, async () { const app buildApp(); const response await app.inject({ method: GET, url: /ping }); expect(response.statusCode).toBe(200); expect(response.json()).toMatchObject({ pong: true }); }); });最后在项目根目录执行t3code check你会看到终端依次输出 lint、typecheck、test、build 四段结果全部通过后以绿色提示结束。这就是 t3code 的正常工作流init建项目gen生成类型契约check守住质量线。日常开发的循环就是这三条命令加一个编辑器的组合拳。5. 那些文档里没写的事我在 t3code 上踩过的坑和取舍工具是自己写的坑也一个没少踩。这一节我重点讲几个最有代表性的问题算是给后来者的排雷手册。5.1 tsconfig 继承在 monorepo 里的坑tsconfig.json里的extends字段很多人会以为路径解析是相对于使用者的位置的其实不然。extends的路径是相对于被继承文件自身的位置解析的。这点在 monorepo 里特别容易翻车。举个例子packages/presets/tsconfig.base.json里如果写了{ compilerOptions: { paths: { /*: [./src/*] } } }那么这个./src/*是相对于tsconfig.base.json所在目录的而不是相对于你项目根目录的。结果就是每个继承它的项目的路径别名全部错乱找不到/开头的模块。我最终的解决方案是分层设计tsconfig.base.json里只放不包含路径语义的通用选项比如strict、target、moduleResolution路径别名这种跟具体项目相关的放到各项目自己的tsconfig.json里。这也是为什么 t3code 的 preset 要分 base/node/web 三层而不是一个文件走天下。TypeScript 5.0 之后引入了${configDir}模板变量可以在继承文件中正确引用使用者目录但考虑到老项目升级还需要时间我的 preset 仍然保留路径别名项目自理的约定。5.2 pnpm 的 hoisting 争议t3code 默认推荐 pnpm但 pnpm 有一个著名的特性默认依赖隔离node_modules 是不完全扁平化的。这个特性在大多数场景下是好事但对某些工具来说却是灾难。典型例子是 ESLint 的插件加载机制。很多 ESLint 插件需要通过require.resolve(eslint-plugin-xxx)去找插件的元信息在 pnpm 的隔离结构下项目顶层node_modules里并不会有这个插件于是 ESLint 直接报 Cannot find module。Vitest 的某些依赖也有类似问题。我当时在presets/react-web里调试了整整一个下午最后发现社区已经给了方案在.npmrc里加一行node-linkerhoisted让 pnpm 采用扁平化的 node_modules 布局。这样做会牺牲一部分依赖隔离能力但对大多数应用项目来说完全够用换来的是兼容性问题大幅减少。建议用 t3code 初始化的项目.npmrc文件默认写入node-linkerhoisted。如果你是一个对依赖隔离有强需求的库作者再考虑去掉这行配置。5.3 ESLint 9 flat config 迁移带来的连锁反应ESLint 到 9.x 之后配置文件从.eslintrc系列换成了eslint.config.jsflat config。这是一次破坏性变更影响比很多人想象的大旧的.eslintrc没人管了。网上大量教程、老项目的配置示例还是旧写法直接复制过来在新版 ESLint 下根本不生效。插件加载方式变了。flat config 里插件需要显式导入不能再靠字符串名称自动解析。这让很多维护不活跃的插件直接失效。部分优秀规则集还没跟上。到写这篇文章的时候部分以前常用的第三方规则集也才勉强完成了迁移。t3code 的 preset 从第一天起就全面切换到 flat config 写法因为我认为 ESLint 9 已经是事实上的主线没必要迁就旧世界。但这也意味着你在网上搜到的一些老规则包可能没法直接用。遇到这种情况我的建议是先查插件的 npm 页面有没有更新版本没有的话就换成维护活跃的替代品。这套迁移的教训是工程化配置要尽早跟着主线上走越晚迁移成本越高。5.4 测试框架Vitest 还是 Node 内置 test runnerNode.js 自带的node:test从 20 版本开始已经相当成熟不需要额外依赖就能跑测试非常轻量。但它有一些让人难受的短板断言库和 mock 需要自己引入或手写体验不如 Vitest 的一体化方案覆盖率统计需要配合c8或者v8工具配置又多了一层Node 内置 test runner 的并发模型在动态 import 场景下偶尔有兼容问题。Vitest 的优点恰恰是开箱即用它内置断言、mock、spy、覆盖率报告和 TypeScript 无缝集成对 ESM 支持极好。代价是引入了一个比较大的依赖链。我最终的取舍是默认用 Vitest但给用户一个切换选项。如果你是一个追求极简、不想在测试上投入太多依赖的纯 Node 库项目可以在初始化时选node:test其他项目一律默认 Vitest。这个选择放在初始化向导里由用户决定而不是替所有人做决定。5.5 CI 流程里的缓存策略t3code 生成的.github/workflows/ci.yml自带缓存配置但缓存没配好会导致 CI 跑得像蜗牛。核心经验是pnpm 的 store 缓存必须和 lockfile 的指纹绑定。- name: Install dependencies run: pnpm install --frozen-lockfile - name: Cache pnpm store uses: actions/cachev4 with: path: ~/.local/share/pnpm/store/v3 key: ${{ runner.os }}-pnpm-store-${{ hashFiles(**/pnpm-lock.yaml) }} restore-keys: | ${{ runner.os }}-pnpm-store-关键在于两点一是安装依赖时一定要加--frozen-lockfile保证 CI 和本地用的是同一份依赖版本否则三天两头出现本地能过 CI 挂掉的奇怪问题二是缓存的 key 要包含 lockfile 的 hashlockfile 一变化就自动生成新缓存不会复用到过期的依赖。还有一个我踩过的坑是CI 里的t3code check会跑完整构建这一步通常很耗时。如果构建产物只是为了验证能构建可以考虑对纯前端项目把build步骤拆成单独的 job主 job 只跑 lint/typecheck/test加快核心反馈速度。当然这个属于优化项项目简单的时候可以直接全跑。6. 给想上手的人几句实在话工具不在多在于合适。最后说几句我在实际使用中的体会和总结出来的建议。什么情况下我建议你别用 t3code如果你们公司已经有一套成熟的内部脚手架并且大家都用得很顺那就没必要引入新工具。同样如果你的项目是一个高度定制化的遗留系统里面有一堆历史包袱强行套用新脚手架只会增加迁移成本。还有如果你只是写一个单文件脚本那直接tsc编译就行完全不需要整套工程化体系。什么情况下建议你认真考虑一下如果你正在从零搭一个预计会持续迭代半年以上的项目或者你刚接手一个配置一团糟的新项目想彻底重来t3code 这类工具能帮你把工程化底子一次打对。特别是类型契约 质量卡口这套结构能让小团队在没有专职基建人员的情况下依然拥有接近正规军的工作流。我的三步上手建议是这样的第一步用默认配置跑通一个 demo别急着改任何规则第二步把你团队的特殊需求沉淀成自定义 preset而不是改生成之后的项目文件第三步坚持用t3code check作为合并请求的唯一质量门槛形成肌肉记忆。最后再分享一个小技巧如果你确实需要在t3code update时保留自己的自定义内容尽量把改动写在t3code.config.ts或项目自己的src目录里别去改 preset 生成的那些配置模板文件。因为t3code update会采用 diff 合并的策略你直接改过的配置文件在合并时可能会产生冲突而写在独立的配置文件和业务代码里的改动可以干干净净地保留下来。这个习惯我已经用了很久实测下来能节省大量和工具搏斗的时间。