
如果你也和我一样每次开新项目都要先花一个下午在 ESLint、Prettier、husky、tsconfig 这些配置上打转那你应该会喜欢t3code这个我最近收拢起来的项目。它不是一个框架也不是什么新语言它就是把一套已经被验证过的工程化实践固定成代码模板让我从项目初始化到提交代码、部署上线的整个过程都能用同一套约定少做重复决策。简单说t3code解决的是“新项目起步慢、规范靠自觉、类型靠默契”这三个问题适合那些一个人做多个前端或全栈项目、又要长期维护的人也适合小团队用来统一开发基线。1. t3code 是什么以及我为什么要折腾它1.1 从一团乱麻到一套约定以前的项目大概是这样的每个仓库里的代码风格都不一样有的用单引号有的用双引号有的提交信息随便写fix bug有的写了详细上下文更别说 tsconfig 里的strict开关有的打开有的关闭。久而久之协作成本会高到离谱。t3code就是在这种背景下出现的它把“开始一个新项目时应该怎么做”这个问题提前用代码回答掉了。t3code不是一个宏大框架它更像一个可复制的工程基座。项目里预先放好了 TypeScript 的严格配置、ESLint 规则、Prettier 格式、Husky 钩子、Commitlint 信息校验以及一个最小可运行的全栈示例包含前端页面、后端接口和数据库模型。拿到它之后你不需要再从零问自己“这个项目要不要开严格模式”因为答案已经写进模板了你也不需要纠结“PR 的提交信息怎么写”因为本地提交的时候钩子就会拦住你。我最早想写这套东西是因为有一段时间同时在维护四五个项目每个项目的依赖版本、目录结构、代码风格都不一样。每当要在两个项目之间切来切去我都得先花十几分钟适应它特有的“脾气”。后来我下定决心把最顺手的配置抽出来统一成一个模板名字就叫t3code。从那时起新项目的起点不再是空白目录而是一套已经跑通的最小完整系统。1.2 T3 到底代表什么T3 这个命名在我这里对应三个词Type-safe、Testable、Tooling-first。Type-safe 代表端到端的类型安全前端调接口时能直接获得类型提示后端改字段时编译期就会报错Testable 代表设计上就预留测试入口业务逻辑和框架解耦单元测试可以快速跑起来Tooling-first 则是指一切能自动化的事情都提前做成工具链包括格式化、lint、提交检查、依赖升级检查而不是靠团队纪律。这三个词正好对应了我在重写工程模板时最想解决的三个痛点。Type-safe 解决“前后端接口联调靠手对”的问题Testable 解决“代码越写越难改”的问题Tooling-first 解决“规范定了但没人执行”的问题。所以t3code这个名字其实是一面镜子提醒我不论项目变得多复杂这三个原则不能丢。顺便说一句市面上有另一个叫 T3 Stack 的流行组合里面是 TypeScript、Tailwind CSS 和 tRPC。t3code的命名思路和它有一点相似但并不是同一个东西。我更在意的是把工程规范固化下来而不是绑定某一组特定技术栈。你完全可以沿用这套思路把 Tailwind 换成 CSS Modules把 tRPC 换成 GraphQL核心不变量仍然是类型安全、可测试、工具优先。1.3 谁来用最合适如果你是一个人的全栈项目t3code能帮你把可重复的配置成本压到最低如果你是三人左右的小团队它又可以作为统一的初始化模板减少 code review 里因为风格和结构产生的无效争论。但如果你用的是大型 monorepo 或已经有了深度定制的脚手架那么t3code更适合作为思路参考而不是直接替换。毕竟模板最怕的就是强行塞进已有体系里反而制造新的摩擦。我身边有两类朋友用这个模板用得最勤。一类是自由职业者经常接各种小项目需要快速交付还要保证后续能维护另一类是创业团队的技术负责人他们希望新成员入职第一天就能进入开发状态而不是先看一星期文档。这两类人都有一个共同点没有太多时间可以花在“环境配置”上但又不想因为省时间而牺牲质量。t3code存在的意义就是把质量基线用代码锁死让人不用靠意志力去遵守规范。2. 工程化骨架配置文件的每处细节都不是白写的2.1 项目结构与依赖清单t3code默认采用 pnpm workspace 组织代码第一次打开目录时你会看到这样的结构t3code/ apps/ web/ # Next.js 前端 server/ # Node API 服务 packages/ ui/ # 共享 UI 组件 config/ # 共享 tsconfig / eslint db/ # 数据库 schema 与 client package.json pnpm-workspace.yaml选择 pnpm workspace 是因为依赖安装快磁盘占用小monorepo 的依赖提升问题也相对可控。不过如果你不熟悉 pnpm也可以退回到 npm workspaces配置思路是一样的。我一开始用的是 npm后来发现多个项目频繁安装依赖时node_modules 占用实在太大切换到 pnpm 之后单是磁盘占用就少了三分之一安装速度也明显提升。当然pnpm 也有自己的学习曲线比如.npmrc里的shamefully-hoist有时候必须开否则某些老依赖会报找不到包。把这一步写进 README可以省掉不少后续排查时间。依赖清单方面前端我会用 React 加 Next.js后端用 Fastify 或 Express数据库访问用 Prisma接口层用 tRPC。这些选择不是因为它最流行而是它们之间能自然共享 TypeScript 类型。React 生态成熟Next.js 把 SSR、路由、优化都包圆了Fastify 性能好、结构清晰Prisma 的 schema 文件天然适合作为前后端类型的“事实来源”tRPC 能在不写 OpenAPI 的情况下实现端到端类型安全。如果你有更顺手的组合完全可以替换但建议保持“一个数据库模型文件 一个路由定义文件 一个前端调用文件”这样清晰的分层。2.2 代码规范ESLint 与 Prettier 的配合姿势ESLint 负责代码质量Prettier 负责代码格式这是很多人早就知道的分工。但真正执行起来经常出现两个工具“打架”的情况ESLint 说这里需要分号Prettier 说不需要。解决办法不是让它们互相让步而是明确边界——ESLint 只关心no-unused-vars、no-explicit-any、react-hooks 规则这类“可能会出 bug”的东西格式问题全都交给 Prettier。在t3code里我用的是 ESLint 的 flat config根目录下的eslint.config.js大概长这样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 tseslint.config( { ignores: [dist, node_modules, .next] }, js.configs.recommended, ...tseslint.configs.recommendedTypeChecked, { plugins: { react-hooks: reactHooks }, rules: { ...reactHooks.configs.recommended.rules, typescript-eslint/no-explicit-any: error, typescript-eslint/no-floating-promises: error } }, prettier )这里最容易被忽略的是recommendedTypeChecked它会开启基于类型检查的规则比如自动检测Promise有没有被await。第一次把这些规则接到老项目上时报错数量可能会吓到人但坚持改完一轮后代码质量是真的会上一个台阶。no-floating-promises尤其有用因为它能拦下那些写着写着就忘记await的异步逻辑避免线上出现“偶尔不生效”的诡异问题。最后一行prettier是一个关闭格式类规则的配置包它不是让你不格式化而是避免 ESLint 重复管格式。Prettier 这边我保持了比较中庸的配置{ semi: false, singleQuote: true, printWidth: 100, trailingComma: es5, arrowParens: always }不写分号是我个人偏好但单引号和 100 字符宽度基本是社区里的常见选择。你不需要完全照搬关键是团队一旦定了就必须让 Prettier 在保存时自动执行不要让“格式化”变成一个手工操作。t3code里我会在.vscode/settings.json里配上editor.formatOnSave: true和editor.defaultFormatter: esbenp.prettier-vscode这样每个新成员打开项目不需要任何口头交代格式就自动统一了。2.3 Git 提交规范Husky 与 Commitlint代码格式只是第一步提交信息如果混乱后面回溯问题会非常痛苦。t3code里用 Husky 管理 Git 钩子在package.json里写入{ husky: { hooks: { pre-commit: lint-staged, commit-msg: commitlint --config commitlint.config.cjs } } }再配合lint-staged只检查本次改动的文件这样跑 lint 的速度很快不会因为项目变大而卡到每提交一次都要等半分钟。Commitlint 则要求提交信息必须符合 Conventional Commits 格式比如feat: add user profile page、fix: correct timer bug、chore: bump dependencies。这样生成的 changelog 和 git blame 都清晰得多。这里有个很容易踩的坑Husky 的钩子目录在安装依赖时才会被创建如果团队成员拿到了模板却没有重新安装依赖钩子可能不会生效。所以在t3code的 README 里我特意加了一句“如果提交时没有触发检查先执行pnpm prepare或重新pnpm install”。另外有些同学会把.husky目录写进.gitignore这是不对的——钩子脚本需要进仓库否则每个人都要手动配一遍那就失去模板的意义了。3. 端到端类型安全t3code 的杀手锏3.1 前端到后端类型不再靠默契传统的前后端联调流程是这样的后端写一个接口返回 JSON 结构前端再根据文档手写一个 TypeScript interface。接口少的时候没什么接口一多就经常出现“文档说的是userId代码里写的却是uid”这种问题。就算你定义了 interface也没有任何机制保证它和后端真实返回的数据一致。t3code的做法是利用 tRPC把 API 调用变成一次“类型安全的函数调用”。前端不用再为每个接口手写类型定义因为后端路由的输入输出类型会直接在前端代码中出现。后端改了返回字段前端 build 时立刻报错前端传错了参数类型编辑框里直接标红。这种感觉很像把过去靠默契维持的 interface 变成了编译器替你检查的 contract。有人会问为什么不用 OpenAPI 加代码生成当然可以也是成熟的方案。但你前后端都要部署一套代码生成流程还要维护一份独立的 spec 文件。tRPC 的思路是“不做协议描述直接共享类型实例”省去了中间那层翻译。当然如果你的前端需要开放给第三方应用调用或者后端要服务很多非 TypeScript 客户端那还是 OpenAPI 更合适。t3code默认选择 tRPC只是在“内部全栈项目”这个前提下做出的取舍。3.2 tRPC 路由的最小实践实际写起来tRPC 的路由并不复杂。我在packages/server里定义了一个基础 router然后在apps/web里直接引用// apps/server/src/router.ts import { initTRPC } from trpc/server import { z } from zod import { prisma } from ./prisma const t initTRPC.create() export const appRouter t.router({ userById: t.procedure .input(z.object({ id: z.string().uuid() })) .query(async ({ input }) { return prisma.user.findUnique({ where: { id: input.id }, select: { id: true, name: true, email: true } }) }) }) export type AppRouter typeof appRouter前端调用时不再需要拼 URL、处理各种 HTTP 状态码而是像调用本地函数一样import { trpc } from ../lib/trpc const { data, isLoading } trpc.userById.useQuery({ id: xxx })如果id不是合法的 UUID代码在编译期和运行时都会被拦下来因为z.string().uuid()的校验规则已经在类型里体现出来了。这比“传一个 string 进去后端返回 400”要友好得多。更重要的是data的类型就是后端select出来的字段不会出现前端取data.password这种错误因为类型系统根本不允许。不过 tRPC 也有一个需要接受的代价它不是 RESTURL 路由的语义被隐藏了。对于需要长期对外提供的 API可能还是得在 tRPC 路由外面再包一层 REST 适配器。t3code里我留了这个口子把 tRPC 的createContext和middleware写好后面要扩展鉴权、日志、限流都很方便。3.3 数据库层的类型约束与注意事项类型安全的链条最终要落到数据库。t3code使用 Prisma 管理数据库模型schema.prisma文件就是类型的源头model User { id String id default(uuid()) email String unique name String? createdAt DateTime default(now()) updatedAt DateTime updatedAt }运行pnpm prisma generate之后Prisma 会自动生成完整的 TypeScript 类型。无论是 tRPC 路由的返回值还是前端展示的字段类型都从这一份 schema 推导过来。这样整条链路里没有“手写类型”的动作也就没有“拷贝类型”带来的偏差。这个环节最需要注意的是不要在业务代码里用Prisma.raw或任何方式绕过 Prisma 的查询构建器。一旦写了原生 SQL类型安全就断了t3code的价值也就少了一半。如果某个查询用 Prisma 写不出来先用$queryRaw实验再在返回结果外面手动打一个as类型断言并且加注释说明为什么需要绕过。这个过程会逼着你确认“这里的边界确实需要手动处理”而不是随手为之。4. 实操过程从零把 t3code 跑起来4.1 初始化与目录生成如果你想把t3code用到自己的项目里最简单的路径是把它当成模板复制一份。因为是我个人维护的模板我用 GitHub 模板仓库来管理它使用方法就一步git clone 你的-t3code-模板地址 my-app cd my-app pnpm install如果没有现成模板地址你也可以按t3code的结构手动搭关键目录就是上一节里那五个。搭好之后先跑pnpm dev确认前端页面、后端接口、数据库三端能同时启动。我记得第一次手工搭这个结构时遇到最多的问题不是框架本身而是不同包之间的 Node 版本要求不一致。所以t3code的根目录里一定有个.nvmrc内容可能是20并且在 README 里写清楚“请用 Node 20 及以上版本”。初始化之后的第一个小实验我建议你试着在后端 router 里新增一个hello查询然后在前端页面上调用它这个过程能验证整条链路是否通畅# 在后端 router 里加 greeting: t.procedure.query(() hello from t3code) # 在前端任意组件里 const hello trpc.greeting.useQuery()看到页面上渲染出hello from t3code基本就说明类型安全和热更新都工作了。4.2 关键配置的落地顺序配置类的东西不能乱放我按依赖方向做了个顺序照着执行会少踩很多坑先配tsconfig.json。全局开启strict: true并设置baseUrl和paths比如server/*指向apps/server/src/*。再配 ESLint。这一步要结合 TypeScript 的project服务确保类型感知规则可以工作。ESLint 跑通后再加 Prettier否则两个工具的报错混在一起你分不清到底是哪边的问题。配置 Husky 和 lint-staged。建议先写一个简单的 pre-commit 钩子只跑 Prettier验证能在 commit 时自动格式化。加入 Prisma 和 tRPC。先建schema.prismagenerate之后立即写一个最小路由验证前端能拿到类型。最后补测试框架。t3code用的是 Vitest配置起来很轻前后端共享一套测试工具链。这个顺序背后的逻辑是先把类型基础打牢再上代码质量工具接着做流程钩子最后接框架和测试。如果反过来先把页面写得漂漂亮亮再补类型你会发现改类型时页面代码也要跟着大改工作量成倍增加。4.3 我踩过的三个坑第一个坑是依赖版本不一致。t3code里同时有 Next.js、tRPC、Prisma它们的版本更新都很频繁。一开始我把三个包都用latest结果某次升级后 tRPC 的 API 变了很多代码突然编译不过。现在我采取的策略是根目录package.json里锁定大版本比如 Next.js 固定在 14tRPC 固定在 10只有确认兼容才会整体升级。升级之前必须先跑一遍pnpm test和pnpm typecheck这两条命令在模板里就是为这种场景准备的。第二个坑是路径别名在构建时失效。开发模式下 Vite 或 Next.js 能识别server/*但一次部署时我发现构建产物里没有解析路径别名后端模块找不到。排查了半天原因是tsconfig.json里的paths只被 TypeScript 识别打包器需要单独的resolve配置。解决方法是统一使用tsconfig-paths或直接在构建命令里加node --loader ts-node/esm。这个经验我写进了 README不然每隔一段时间就会再踩一遍。第三个坑是环境变量的类型安全。以前我会在后端代码里直接process.env.DATABASE_URL前端也用同名变量结果有一次把后端地址写到了前端的变量里部署后才暴露。后面我在t3code里新增了一个env.ts集中读取并校验环境变量字段缺失时启动直接报错而不是运行时才报undefined。这个改动很小但真的能救命。5. 常见问题与排查技巧实录5.1 TypeScript 严格模式下的日子打开strict之后最常见的报错就是“argis used before being assigned”和“Object is possibly null”。一开始觉得严格模式烦人后来发现它逼着我把初始化逻辑写清楚。比如 React 组件里的useRef以前我喜欢写成useRef(null)开了严格模式后就要带上类型参数useRefHTMLDivElement(null)。这不会影响运行但会让代码意图更明确。还有一类报错来自第三方库类型不完整。如果某个包本身没有类型声明我的处理顺序是先找types/xxx找不到就用declare module在项目里定义最小类型最后一个办法才是as any。前两种占 90% 的情况剩下那 10% 我会在t3code的types目录里单独维护一份vendor.d.ts把临时类型收拢起来避免散落在各处。5.2 Husky 钩子不执行的排查如果你提交时发现 lint 没有跑先别急着怀疑配置。按照我的经验80% 的原因是.husky目录不在 Git 里或者新 clone 的项目没有执行过pnpm install。Husky 的钩子是在 install 时通过postinstall脚本写入.git/hooks的所以可以手动执行一次npx husky add .husky/pre-commit pnpm lint-staged如果这样还没生效再检查有没有拼写错误.husky/pre-commit文件名里不能有空格文件第一行需要是#!/usr/bin/env sh。最后一个常见原因是 Windows 下的换行符问题可以用git config core.autocrlf input改一下然后重新 checkout。5.3 开发体验优化热更新与依赖预构建t3code的前端用的是 Next.js平时热更新已经很及时。真正影响体验的是后端或共享包修改后前端能否快速感知。我的做法是把共享包packages/ui和packages/db都用tsup构建成dist同时在前端 dev 脚本里加上--watch模式。这样改完packages/db的代码前端能在一两秒内看到变化。如果你用的是 Vite还可以调整optimizeDeps.exclude避免某些依赖在预构建时被缓存导致类型更新不及时。这个操作在t3code里我会写成注释因为每个人的依赖都不一样没法开箱即用。一个大原则是如果改了 TS 类型但热更新没生效优先重启 dev server这是成本最低的排查手段别浪费时间调配置文件。6. 后续扩展与经验收尾6.1 从单体模板到 monorepo 的演进t3code一开始其实是最简单的单体仓库前后端代码放一个目录下方便是方便但项目一大就会出现依赖混杂的问题。后来我切成了 pnpm workspace 的 monorepo 结构把共享的量抽到了packages目录。这个过程值得做一个开放问题来讲不是所有项目都适合 monorepo如果你只有一个独立应用保持单仓反而省心。t3code之所以能撑住 monorepo是因为pnpm-workspace.yaml从第一天就定义了清晰边界没有让包与包之间产生乱七八糟的引用。如果你也想演化成这个结构我建议按顺序做先把apps/web和apps/server拆开再抽packages/db最后再碰packages/ui。拆分依据只有一个——是否在两个或以上应用间被共享。不满足这个条件就不要动否则仅是维护package.json的 exports 就够你折腾半天。6.2 我实际使用中的体会t3code在我自己手头的几个项目里已经跑了差不多半年。最直观的变化是新项目从 clone 到跑起来通常不会超过十分钟代码 review 时不再有关于“把这个 const 改成 let”这类风格争论因为类型安全前后端联调阶段暴露的问题少了一大半。这些收益不是因为它用了什么惊天动地的技术而是因为把这些最琐碎的基建决策用一种固定的方式固化了下来省下来的注意力全都在写业务上。如果你最近也被各种配置和规范问题折腾得够呛我的建议是从最小的一步开始先把 tsconfig 的strict打开再把 Prettier 和 ESLint 接上然后加一个 pre-commit 钩子。这三步做完你的项目至少已经接近t3code五成的水准了。剩下的类型安全和更复杂的工程结构完全可以在跑过一段时间的实际项目后再逐步演进。工具是死的思路是活的t3code只是把我认为好用的实践装进了同一个盒子真正让这套方案生效的还是你每天写代码时愿意遵循的那套规则。