聊到 t3code我得先交代一句这不是某个大厂开源的工具也不是什么官方框架。它是我给自己的一整套个人全栈项目工程化实践起的代号核心就一句话——用 TypeScript 把前后端的契约、校验、测试全部拧在一起让类型安全从上到下贯穿整个项目。我做了大半年反复试错最后发现这个思路对中小型项目、对个人开发者、对团队协作都有很强的参考价值。这半年里我用 t3code 这套实践重写了一个业务管理后台、一个开源工具站的 API 层还把两个老项目从 JavaScript 慢慢迁了过来。明显感受是依赖类型系统把很多本该运行时才能暴露的问题吞掉了联调时间大幅缩短测试补起来也轻松很多。如果你正在纠结全栈技术栈怎么选、REST API 和类型系统怎么配合、前后端怎么减少扯皮这篇文章值得你往下看。我不会只讲概念。接下来直接把思路拆开、把配置贴出来、把我踩过的坑和排查过程一并交代清楚。无论是刚学 TypeScript 的新手还是想优化现有项目的团队都能从中找到能直接抄作业的部分。1. t3code 的整体设计三个 T 如何分工1.1 为什么一定要 TypeScript 打底我自己从 JavaScript 项目写过来的最早也觉得 TypeScript 是多此一举。但当你维护一个超过三五个模块的项目最痛苦的事情不是功能写不出来而是改一个字段不知道哪里跟着崩。JavaScript 的灵活是双刃剑你可以在运行时给对象挂任意属性但也意味着业务一旦复杂所有约束都只能靠记忆和文档。TypeScript 的价值不是多了个类型标注而是把错误暴露的时机从运行期提前到编译期。拿装修打比方JS 是边刷墙边决定插座位置刷完发现插座藏在衣柜后面TS 是开工前先画水电图哪面墙开槽、哪条线走顶全都提前标清楚。虽然前期多花时间但施工途中几乎不会返工。在实际代码里这个优势会体现在非常具体的地方后端返回的user.name写错了IDE 立刻划红线而不是等线上日志打出来才发现。前端调用接口少传一个参数编译阶段就拦下来。数据库字段改了类型所有引用它的组件、函数、测试用例同步报错。这几个场景放在没有类型的项目里每一个都是夜间大新闻级别的故障来源。t3code 的第一步思路就是所有代码能写成 TS 的绝不写 JS。1.2 三个 T 的分工Type-Safety、Testing、Toolingt3code 里的 t3 我拆成三个维度Type-Safety类型安全解决数据长什么样。从数据库的表结构到 API 的入参出参再到组件 props我只定义一份真实来源其他位置通过类型推导自动获得。这个环节的工具就是 Prisma 加 tRPC后面细讲。Testing测试解决行为对不对。类型安全只能保证形状不错但不能保证逻辑正确。比如状态机的流转顺序、权限判断的分支、定时任务的边界条件这些必须靠自动化测试去锁住。t3code 在测试上的底线是核心业务函数至少要有单元测试关键技术路径要有端到端测试。Tooling工具链解决体验顺不顺。ESLint、Prettier、TypeScript 严格模式、路径别名、环境变量类型校验……这些工具看似琐碎实际上决定了团队开发时阻力大不大。很多项目写着写着就放弃 TypeScript不是因为类型系统不好而是因为工具链没配好打开项目的瞬间一堆红色波浪线谁看了都头大。三个 T 的顺序不是并列的而是层层递进类型安全先把数据的形状钉死测试再把行为的结果钉死工具链让前两者成为日常习惯而不是额外负担。1.3 技术选型为什么是 Next.js tRPC Prisma Tailwind选型这件事做过技术决策的人都懂没有最好只有和你的场景最匹配。t3code 这套选型并非我对其他框架有偏见而是它在类型贯穿性、开发效率、生态成熟度三者之间取得了不错的平衡。环节工具解决什么问题替代方案对比框架Next.js服务端渲染与 API 路由一体部署心智低Remix 也不错但 Next 的 App Router 生态更成熟全栈通信tRPC前后端共享类型接口代码量减半REST 需要手动维护 OpenAPIGraphQL 需要写大量 resolver 样板ORMPrismaSchema 即类型来源迁移流程完整TypeORM 迁移较重Drizzle 太年轻校验Zod运行时防御 静态类型推导双重保障Yup 类型推导弱一些superstruct 生态小样式Tailwind CSSutility-first 快速迭代样式不会裸奔CSS Modules 维护成本高styled-components 性能略输测试Vitest Playwright单元测试与端到端测试两种粒度全覆盖Jest 配置麻烦Cypress 更重其中最关键的组合拳是Prisma 生成类型 - tRPC 自动推导 - 前端组件获得完全类型提示。这个过程是从数据源头开始的类型闭环后面我会用完整示例演示。2. t3code 的工程化细节与实操要点2.1 项目目录结构怎么搭才清晰目录结构看着琐碎其实决定了后期维护的体验。t3code 采用按功能分层、按路由聚合的思路既不像传统 MVC 那样全部堆在 controllers 里也不完全按业务模块一刀切。一个典型结构长这样src/ app/ # Next.js App Router 页面 api/ # 极少直接写的 API 路由tRPC 接管 (dashboard)/ page.tsx components/ ui/ # 基础组件库 forms/ # 表单类组件 server/ db.ts # Prisma Client 单例 trpc.ts # tRPC 路由初始化、中间件 routers/ user.ts # 用户相关路由 article.ts _app.ts # 聚合根路由 schemas/ user.ts # Zod 输入校验的 schema article.ts lib/ env.ts # 环境变量解析 utils.ts tests/ unit/ # Vitest 单元测试 e2e/ # Playwright 端到端测试有几个细节我在实际项目里反复验证过第一server/routers和schemas必须分开。很多人习惯把 Zod schema 直接写在 router 文件里一开始很方便但当你需要在多个地方复用同一套校验比如前端表单预校验、后端接口校验、服务间调用校验直接从schemas导入是最省心的方式。第二lib/env.ts是一道防线。我见过太多项目用process.env.DATABASE_URL时类型是string | undefined要么到处写非空断言要么运行到一半才报错。t3code 里用 Zod 在入口处解析一次环境变量缺哪个立刻报错所有环境变量的类型从源头就明确了。第三组件目录不要按页面堆。把通用组件放components/ui业务组件放对应的路由目录下这样页面路由重命名时不用翻遍全项目找组件。2.2 从数据库到组件的类型闭环是怎么做到的类型闭环是 t3code 最核心的工程实践。理解它只需要盯住一条链路Prisma Schema 是第一真实来源定义数据库表结构。Prisma Client 根据 Schema 生成 TS 类型。tRPC Router 返回这些类型无需手写 interface。前端调用 tRPC 方法时返回值的类型自动被推导出来。这条链路的好处怎么强调都不为过你只维护一份数据定义其余所有类型都是机器生成的。再也没有后端改了字段、前端文档没同步的问题因为前端 IDE 里的类型提示是直接从数据库定义推导过来的改完 Schema 跑一次prisma generate所有引用处立刻感知。实操中要特别注意不要在中间环节手写类型。比如手写一个interface UserDTO表面上和 Prisma 生成的类型很像但一旦数据库加了字段你手写的 DTO 不会自动更新类型闭环就从这里断掉了。对外输出数据时不要直接暴露数据库模型。用 Zod schema 做过滤只挑需要的字段返回既保证安全也保证类型干净。前端不要展开 tRPC 的返回类型去重新定义。直接让组件 props 使用推断结果而不是自己再写一遍字段列表。否则又回到两个真实来源的坑。2.3 校验规则统一永远不要前后端各写一套前后端各自写一套校验是我见过最多也最容易引发线上事故的问题。前端做了表单校验后端接口没校验后端校验了前端预校验的规则已经旧了甚至两边规则不一致导致同样的合法输入在前端被拦截、在后端报错。t3code 的做法是从一开始就把校验规则定义为源码中的唯一事实// schemas/article.ts import { z } from zod; export const createArticleSchema z.object({ title: z.string().min(4, 标题至少 4 个字).max(80, 标题过长), content: z.string().min(10, 正文太短), tags: z.array(z.string().max(12)).max(5, 标签最多 5 个).optional(), }); export type CreateArticleInput z.infertypeof createArticleSchema;前端表单直接使用同一个 schema 做预校验后端 tRPC procedure 也用同一个 schema 做输入过滤。一套规则两边复用改一处即可生效这是减少前后端扯皮最快的方法。一个小的实践心得Zod 的错误信息我建议直接用中文写死在 schema 里而不是去做错误码映射。原因很简单——前端拿到错误信息可以直接显示省去一层翻译中小项目完全够用。2.4 组件层和样式方案的设计约束组件层的核心原则是use client 边界越少越好样式类名约定越简单越好。Next.js App Router 模式下Server Component 能直接读取数据库、调用 tRPC而且不会把数据请求逻辑暴露到客户端。我发现很多初学者把整棵页面组件树都标成 use client结果服务端渲染的优势全没了。正确做法是页面顶层用 Server Component 拉数据只有交互类叶子组件才标记 use client中间层保持无色。样式方案我用 Tailwind但没有直接堆工具类。在 t3code 的实践里我会用layer components把几个高频组合收敛成语义类layer components { .card-base { apply rounded-lg border border-gray-200 bg-white p-4 shadow-sm; } }这样既保留 Tailwind 的灵活性又不会让 JSX 里堆满十几个类名导致没法读。另外颜色和间距尽量统一走设计变量避免三个人写出三套灰色。实测下来这套约束让新功能页面的开发速度明显提升。过去写的 CSS 一到改版就是全局搜索类名现在只要改 Tailwind 配置里的 token全站配色随之更新。3. 从零搭建 t3code 的完整实操过程3.1 初始化项目建一个带类型闭环的最小骨架我用create-t3-app初始化项目它会把 Next.js、TypeScript、tRPC、Prisma、Tailwind 一次性配好省去大量手动配置时间。命令如下pnpm dlx create-t3-applatest my-t3-app它会有一系列交互选项我的选择是开启 NextAuth如果你做的是内部工具可以先不开权限后面自己接。开启 tRPC必须开这是类型闭环的关键。开启 Prisma必须开数据模型靠它定义。开启 TailwindCSS推荐开样式效率高。开启 Playwright推荐开端到端测试后面需要。开启 ESLint / Prettier全部开。初始化完成后的目录结构和我在前面第一节列的基本一致。到这里项目的骨架就是三 T 齐全的状态类型安全工具链就位测试工具就位开发体验工具就位。3.2 用 Prisma Schema 定义数据模型数据模型是整条类型链路的起点。我拿一个文章管理场景举例这是每个业务系统里都会出现的、又是最典型的 CRUD 加关联查询的模型// prisma/schema.prisma generator client { provider prisma-client-js } datasource db { provider sqlite // 本地开发先用 sqlite部署换 postgresql url env(DATABASE_URL) } model User { id String id default(cuid()) email String unique name String? role Role default(USER) articles Article[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Article { id String id default(cuid()) title String content String published Boolean default(false) author User relation(fields: [authorId], references: [id]) authorId String tags Tag[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Tag { id String id default(cuid()) name String unique articles Article[] } enum Role { USER ADMIN }写完后执行npx prisma migrate dev --name init npx prisma generate这两步的作用要说清楚migrate dev会在数据库里建表并生成迁移文件generate会生成类型安全的 Prisma Client。每次改完 Schema都要跑这两条命令这是类型闭环的刷新机制。这里有一个新手的常见认知误区Prisma 不只是对象关系映射工具它的核心价值是Schema 即类型来源。你在这个文件里定义的每个模型都会被编译成 TS 类型直接供 tRPC 和前端使用。所以 Schema 写得越精确后面所有代码享受的类型收益就越大。3.3 编写 tRPC Router把数据库能力安全地暴露给前端tRPC 的核心是procedure过程它把查询数据和修改数据抽象成了可类型推导的函数。看一个实际例子// src/server/routers/article.ts import { z } from zod; import { createTRPCRouter, protectedProcedure, publicProcedure } from ../trpc; import { createArticleSchema, updateArticleSchema } from ../../schemas/article; export const articleRouter createTRPCRouter({ list: publicProcedure .query(async ({ ctx }) { return ctx.db.article.findMany({ include: { author: { select: { id: true, name: true } }, tags: true }, orderBy: { createdAt: desc }, }); }), create: protectedProcedure .input(createArticleSchema) .mutation(async ({ ctx, input }) { const article await ctx.db.article.create({ data: { ...input, authorId: ctx.session.user.id, }, }); return article; }), byId: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) { return ctx.db.article.findUnique({ where: { id: input.id }, include: { author: true, tags: true }, }); }), });几个关键点protectedProcedure 和 publicProcedure 是 tRPC 的中间件机制。我在这里做了会话校验没有登录的用户只能调list和byId不能调create。这套权限规则写在类型系统里前端甚至连调用按钮都不会渲染出来。返回值不需要手写类型标注。list返回的类型是 Prisma 查询结果的精确推导前端拿到后调用.map、访问article.author.nameIDE 里全部有提示拼错一个字母立刻报错。input 校验由 Zod 承担。传给create的数据在进入函数体之前已经被解析过了非法输入直接抛错tRPC 会返回格式化的错误信息前端可以统一处理。3.4 前端调用Server Component 数据直取与客户端交互Next.js App Router 模式下调用 tRPC 有两种方式我分别说明。第一种是Server Component 直接调用。页面组件可以直接调用 tRPC 的方法返回的数据直接进入渲染流程不需要走 HTTP// src/app/(dashboard)/articles/page.tsx import { api } from ~/server/api/root; import { ArticleCard } from ./ArticleCard; export default async function ArticlesPage() { const articles await api.article.list(); return ( div classNamegrid gap-4 {articles.map((article) ( ArticleCard key{article.id} article{article} / ))} /div ); }这里的api.article.list()返回值类型是自动推导的articles.map的每一项结构在编译期就知道不需要额外定义 props。第二种是客户端交互调用。比如创建文章是个表单提交动作必须发生在浏览器里。我使用 tRPC React Query 集成// src/components/forms/CreateArticleForm.tsx use client; import { api } from ~/trpc/react; import { createArticleSchema } from ~/schemas/article; export function CreateArticleForm() { const utils api.useUtils(); const createArticle api.article.create.useMutation({ onSuccess: () { utils.article.list.invalidate(); // 刷新列表 }, }); return ( form onSubmit{(e) { e.preventDefault(); const formData new FormData(e.currentTarget); const parsed createArticleSchema.safeParse({ title: formData.get(title), content: formData.get(content), tags: formData.getAll(tags), }); if (parsed.success) { createArticle.mutate(parsed.data); } }} {/* 表单字段省略 */} /form ); }注意一点这里前端校验用的createArticleSchema和后端 router 里输入校验用的是同一个 schema 文件。改校验规则只动一处前端和后端同时生效。这就是我在前面强调的单一真实来源在日常开发中的具体体现。3.5 测试怎么落单元测试锁定业务逻辑E2E 守住关键路径t3code 的测试策略分两层。第一层是Vitest 单元测试主要覆盖 router 内的业务函数。tRPC 的 context 可以注入 mock所以测试不需要起数据库也不要走真实网络// tests/unit/article.test.ts import { describe, it, expect, vi } from vitest; import { articleRouter } from ~/server/routers/article; import { createTestContext } from ./helpers; describe(article router, () { it(创建文章时自动关联当前登录用户, async () { const ctx createTestContext({ session: { user: { id: user-1, role: USER } }, }); const caller articleRouter.createCaller(ctx); const result await caller.create({ title: 这是一篇测试文章, content: 内容超过十个字的约束要求, tags: [], }); expect(result.authorId).toBe(user-1); }); it(未登录用户不能创建文章, async () { const ctx createTestContext({ session: null }); const caller articleRouter.createCaller(ctx); await expect( caller.create({ title: 测试, content: 内容, tags: [] }) ).rejects.toThrow(); }); });这里面的createCaller是 tRPC 提供的能力让你可以在不启动服务器的情况下直接调用 router 中的函数。配合 mock context可以把权限、校验这类核心逻辑都锁死。第二层是Playwright 端到端测试守的是用户注册 - 登录 - 创建文章 - 列表可见这条关键路径// tests/e2e/create-article.spec.ts import { test, expect } from playwright/test; test(登录用户可以创建文章, async ({ page }) { await page.goto(/login); await page.getByLabel(邮箱).fill(testexample.com); await page.getByLabel(密码).fill(password123); await page.getByRole(button, { name: 登录 }).click(); await page.goto(/articles/new); await page.getByLabel(标题).fill(端到端测试文章); await page.getByLabel(正文).fill(这段正文用来验证完整流程是否走得通。); await page.getByRole(button, { name: 发布 }).click(); await expect(page.getByText(端到端测试文章)).toBeVisible(); });这两层测试侧重点不同单元测试快、粒度细适合开发者频繁运行E2E 慢、但覆盖真实链路适合在 CI 和发布前执行。在 t3code 项目中我的习惯是写路由逻辑时顺手补单测每次 PR 前跑一次 E2E。这个节奏既不占用太多时间又能及时兜底。4. 常见问题与排查技巧实录4.1 类型改了IDE 还提示旧类型这个问题的概率极高几乎每个用 Prisma 的人都会遇到。现象是改了schema.prisma重新跑了prisma generate代码里还是显示旧字段。排查思路按顺序来检查 Prisma Client 是否真的重新生成了。看node_modules/.prisma/client的时间戳。重启 TypeScript 服务。在 VS Code 里按CtrlShiftP执行 TypeScript: Restart TS Server。如果你的项目用了 monorepo 或 pnpm检查 Prisma 的output配置是否指向了自定义目录。最后一步删掉.next目录或dist重新跑next dev。我自己的习惯是每改一次 Schema就顺手重启一次 TS Server后来已经变成肌肉记忆基本不会为这个浪费超过一分钟。4.2 tRPC 在客户端报类型 undefined 或 never这个问题通常出现在你试图手动标注 tRPC 的返回值类型时。tRPC 的类型推导依赖AppRouter这个聚合类型如果前端代码里写死了某个手动的 interface一旦类型对不上就会出现never或undefined的提示。正确做法是// src/trpc/react.ts import { type AppRouter } from ~/server/api/root; import { createTRPCReact } from trpc/react-query; export const api createTRPCReactAppRouter();千万不要自己写interface ArticleDTO然后把 tRPC 返回值强制as过去。这种做法等于亲手切断类型闭环后面所有收益都没了。排查技巧打开tsc --noEmit看报错是不是出现在类型不匹配的位置。如果项目里有任何any或as unknown as优先清掉它们绝大多数类型问题立刻消失。4.3 测试环境数据互相污染用 Vitest 测试 tRPC Router 时如果直接连开发数据库跑几次测试后数据就会混在一起断言时好时坏。我最早的教训就是测试通过了点开开发库一看全是测试数据。解决思路有两个方向方向一测试数据库隔离。单独建一个test数据库跑测试前执行prisma db push --force-reset保证每个测试批次的初始状态是干净的。缺点是慢适合做 E2E。方向二事务回滚。在写单元测试时把每个测试包在事务里测试结束直接回滚不落任何脏数据。这个方案更快但要注意 tRPC 的 context 里的 Prisma Client 需要从测试工具里获取同一个实例。我的推荐组合单测用事务回滚 mock 数据E2E 用独立测试库 跑前重置。两边的策略不同但都能保证测试结果可重复。4.4 服务端组件和客户端组件数据对不上Next.js App Router 里Server Component 直接读取数据库拿到的日期是Date对象客户端组件从 RPC 拿到的日期序列化后是字符串。两个组件如果混用类型不会报错但运行时比较日期就会有问题。避免方法在模型层面约定所有传给前端的日期统一格式。我的做法是在 Zod 输出 schema 里把日期统一转成 ISO 字符串export const articleOutputSchema createArticleSchema.extend({ id: z.string(), createdAt: z.date().transform((d) d.toISOString()), updatedAt: z.date().transform((d) d.toISOString()), });这个转换发生在 API 出口处确保前端拿到的永远是字符串不再出现同一个字段两种类型这种诡异问题。4.5 “use client” 边界的失控这个问题的表现是项目跑着跑着发现几乎每个组件文件头部都多了一行use client。表面上看 React 也没报错但整个应用变成了假服务端渲染首屏性能越来越差。排查技巧很简单从页面入口往下检查凡是只在交互回调里用到的状态比如useState、useEffect、onClick那一层才需要 use client。如果某个组件没有用到任何客户端 hooks那它绝对不应该出现这行标注。我后来给自己定了一条规矩新组件写成纯函数组件等真正需要交互了再加 use client。这个习惯让项目里的 RSC 边界收敛得很好打包体积和首屏指标都明显改善。5. 关于 t3code我想分享的三个经验习惯如果要总结这半年实操里最有价值的东西我会给出三个词单一事实来源、机器生成优先、边界细粒度控制。单一事实来源指的是数据库 Schema 是类型的源头Zod 是校验规则的源头环境变量是配置的源头。其他地方需要数据和规则时永远是引用而不是复制。你会发现复制粘贴然后改一改这种看起来效率很高的做法恰恰是技术债最密集的来源。机器生成优先的意思是能交给工具自动生成的类型绝对不要手写。Prisma 自动生成 ClienttRPC 自动推导返回类型Zod 从 schema 自动推导 TS 类型。人在这个环节的作用是定义规则而非重复劳动。边界细粒度控制有两层含义。第一层是组件上的 use client 边界越细越好。第二层是权限和校验的边界收得越窄攻击面越小。我见过一些项目权限设置粗到所有人都能调创建接口这种问题 t3code 的类型系统虽然不会直接拦截但如果路由设计时把 public 和 protected 分得很清楚往往能通过 code review 阶段发现。最后分享一个小技巧每次新改动完成后我习惯跑一遍tsc --noEmit vitest run再把 TypeScript 服务和开发服务器都重启一次。这个组合检查动作看着简单但配合 t3code 的体系能让绝大部分类型没刷新、测试没过、缓存影响开发的问题暴露在提交前。t3code 不是什么神秘的框架它真正的价值是让你盯着一条流畅的类型链路把事情做完从数据库表结构出发所有不确定性在编译期被消化测试在改动之后快速给出反馈。这套方法论值得在实际项目里试一次试过回不去。
阅读完成 · 觉得有帮助?