ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

t3code:一次跑通TypeScript全栈端到端类型安全的脚手架

t3code:一次跑通TypeScript全栈端到端类型安全的脚手架 1. t3code 是什么一次跑通全栈类型安全的实际体验第一次拿到 t3code 这个项目的时候我心里其实带着不少问号。市面上的全栈脚手架太多了create-react-app、Next.js 官方模板、各种 CLI 生成器每个都宣称自己能开箱即用结果真正用下来要么类型定义散落各处要么前后端数据格式全靠口头约定要么首次部署就被环境变量折腾得焦头烂额。t3code 给我的第一感觉不太一样它把端到端类型安全从口号变成了默认行为而不是需要你自己后期补上的约束。这个项目本质上是一套面向现代化 Web 应用的全栈脚手架和开发工具链围绕 TypeScript 构建把数据从数据库到 API 层再到前端组件全程用类型串起来。我第一次跑通它是在一个内部管理系统的需求里那个系统涉及用户权限、订单数据、多端展示最头疼的就是后端改了字段类型前端还蒙在鼓里直到接口报错才反应过来。用了 t3code 之后后端模型一变前端编译立刻标红问题的发现时间从线上事故提前到了保存代码的那一刻。这篇文章我想从实际使用的角度聊聊 t3code它选了什么技术栈、为什么这么选、从零怎么跑通、真实项目里会遇到哪些坑以及它对项目规模的适应边界。如果你正在为全栈项目的类型安全头疼或者纠结 Next.js 生态里怎么组织数据层和 API 层这篇应该能给你一些参考。2. 把三套类型声明变成一套数据契约传统全栈开发里我印象最深的一个痛苦场景是这样的数据库用一个模型后端接口返回一个 DTO前端再手写一个 interface。三套类型定义维护在完全不同的位置修改一个字段往往要同时动三个地方。t3code 的核心思路就是用一套定义贯穿三层彻底砍掉重复的类型声明。2.1 类型源头数据库 schema 不是表结构文档而是代码的起点t3code 默认把数据库 schema 作为整个类型链路的地基。你在 schema 文件里定义的每一张表、每个字段、每个关联关系都会自动生成对应的 TypeScript 类型。这个生成的类型不是简单的和数据库结构长得像而是严格映射了数据库的约束包括字段是否可空、默认值类型、枚举值的集合、外键关联的目标表。我在项目里定义了一张订单表包含状态字段数据库层面限定了只能取 pending、paid、cancelled 三个值。t3code 根据 schema 生成类型之后前端写条件判断时自动补全里只会出现这三个值手写一个 expired 立刻编译报错。这种约束力是传统后端返回字符串前端猜值的方式给不了的。2.2 一改全改表结构变更后错误出现在哪一步当我需要给用户表加一个头像字段时t3code 的行为让我第一次体会到类型系统作为约束网络的含义。我在 schema 里加上 avatarUrl 字段然后执行迁移命令。这时候数据库表更新了生成的类型也更新了前端所有用到用户数据的地方都会在编译期收到提示。有的地方是明确报错——比如某个表单提交的数据里没带这个字段有的地方只是警告——比如某个展示组件还没处理空值情况。这里有一个细节值得注意t3code 生成的类型默认是严格模式新增字段后所有读取操作都必须显式处理新字段。这就逼迫开发者在改表结构的同时把消费方的逻辑一并梳理清楚。以前那种数据库先改了接口层跑通就行前端后面再说的松弛状态在这个工具链里是活不下去的。刚开始可能觉得烦但这种烦恰好避免了上线之后的数据错位。3. 技术栈选型背后的取舍逻辑t3code 不是把现在热门的库随便拼一拼它有明确的选型逻辑。理解这套逻辑比单纯会用脚手架重要得多。技术栈的真正价值不在单个库多强大而在它们组合起来之后类型信息能不能无缝流动。3.1 框架层为什么是 Next.js 而不是独立后端加前端项目选择了 Next.js 作为应用框架这个决策很务实。全栈脚手架要解决的核心问题之一是你只有一个代码仓库但你需要同时处理服务端和客户端逻辑。Next.js 的 App Router 允许同一项目里既跑服务端组件又跑客户端组件数据获取、路由、渲染策略可以在不同层级灵活选择。配合 TypeScript页面组件的 props 类型可以直接从数据查询结果推断出来不需要中间人转换。如果你习惯的是Express 写 API React 写前端的分离架构你会发现 t3code 的逻辑不太一样API 层不是独立的 HTTP 接口集合而是嵌在应用框架里的内部调用。后续我会详细讲 tRPC 在这套体系里的角色这里先记住一个结论——这种设计牺牲了一部分接口复用性换来了大量的类型推导便利。如果你的核心业务是开放 API 给别人调用那 t3code 不是最优选择但如果你做的是产品自身的完整应用这套组合拳非常顺手。3.2 通信层tRPC 凭什么替代 REST 和 GraphQL这一节我想聊聊 t3code 最核心的通信层选型——tRPC。很多第一次接触的朋友会问为什么不是 REST为什么不是 GraphQL我个人的理解是tRPC 解决的痛点正好是 REST 和 GraphQL 都绕不开的类型边界问题。REST 的接口定义是天然的字符串约定。前端要请求 /api/orders 拿到订单列表后端返回什么结构前端只能通过文档或者抓包去认知。即便你用 OpenAPI 生成客户端代码也总有类型定义滞后于实现的时候。GraphQL 的类型系统虽然严格但它引入了一套独立的 schema 语言和查询解析层学习成本和运行时开销都不小。tRPC 的做法聪明在函数即接口。你在服务端写一个函数导出到 router 里前端直接按函数调用。函数的入参类型、返回类型完全由 TypeScript 编译器自己推导不需要生成任何额外的类型文件也不需要手写 schema。我改造一个查询接口时后端把返回的订单对象里加了折扣信息前端调用处立刻就能通过代码提示拿到 discount 字段连接口文档都不用看。当然 tRPC 不是银弹。如果以后要把同一套能力开放给第三方程序HTTP 接口仍然是更通用的方案。t3code 也留了余地Next.js 本身就是完整的 HTTP 服务需要的时候你完全可以加一个传统的 API Route。这就是后话了。3.3 数据层Prisma 与 Drizzle 的取舍t3code 在数据库这一层给了两个选择Prisma 和 Drizzle。两者的共同点是都支持从 schema 生成完整的 TypeScript 类型区别在于抽象层级和运行时行为。Prisma 是更成熟的方案它的 client 封装层次高查询写法接近自然语言比如db.order.findMany({ where: { status: paid } })。它自带迁移工具迁移文件可以审查、可以回滚非常适合业务条件复杂的项目。代价是它有一层 query engine体积和启动开销略大调试起来多了一层间接性。Drizzle 则更贴近 SQL 本身它是轻薄的查询构建器生成的 SQL 几乎裸奔性能损耗极小同时类型推导能力很强。它的 schema 定义方式就是 TypeScript 代码没有单独的文件格式。如果你对性能敏感、希望完全掌控 SQL 行为Drizzle 更合适。我实际用下来中小型业务项目选 Prisma 更省心因为迁移管理和查询 API 的学习成本低对 SQL 有深度定制需求的场景Drizzle 更顺手。t3code 把两个都支持而且切换成本不高因为上层的 tRPC 调用不关心具体 data client 是谁只要类型正确就行。4. 从零跑通 t3code环境准备与目录结构剖析理论聊了不少接下来进入实操。这一节我从零开始带你跑通一个 t3code 项目顺便解析它目录结构背后的设计意图。4.1 环境要求与脚手架安装t3code 对本地环境的要求不复杂Node.js 版本需要 18 以上包管理器推荐 pnpm因为它的依赖安装速度和磁盘占用都比 npm 友好。数据库方面Prisma 默认支持 PostgreSQLDrizzle 则兼容更多数据库。我一开始用的是本地 PostgreSQL如果你不想装数据库也可以先用 SQLite 跑通流程schema 定义方式一样。安装命令很直接pnpm create t3codelatest my-app命令执行后会问你几个交互式问题项目名称、用哪套数据层、是否需要鉴权模块、是否启用 Internet 相关的配置。我建议第一次使用时都选上因为 t3code 的鉴权模块和后续部署配置是联动的后面再补会比较麻烦。安装完成后的依赖下载时间取决于网络状况。我遇到过一次 pnpm 安装超时的问题后来把 registry 切到了国内镜像才顺利跑完。这个坑后面在排查章节再细说。4.2 目录结构每一层放什么是规定动作一个初具规模的 t3code 项目目录大约是下面这个样子src/ app/ # Next.js App Router 页面 server/ api/ # tRPC router 定义 db/ # Prisma/Drizzle client 实例 auth/ # 鉴权逻辑 trpc/ server.ts # 服务端 tRPC 上下文构建 client.ts # 客户端 tRPC 调用封装 prisma/ schema.prisma # 数据库 schemaPrisma 方案 drizzle/ schema.ts # 数据库 schemaDrizzle 方案注意 src/server/db 和 src/server/api 是刻意分离的。数据层不直接暴露给 API 层以外的代码所有对数据库的操作都通过 tRPC router 暴露。这种约束保证前端无法绕过 API 层直接碰数据库类型边界和权限边界在同一个位置生效。src/trpc 下面的 server.ts 承担了一个非常重要的职责创建每个请求上下文的 tRPC 实例。上下文里通常会放入当前登录用户信息、数据库 client、请求头解析结果。后面你在定义任何 router 时都可以从上下文里取当前用户做权限校验。这个设计让每个接口都能感知当前用户成了默认习惯而不是靠开发者自觉在每个接口里都调用一次鉴权函数。4.3 环境变量与数据库初始化的正确顺序跑通项目之前环境变量必须配好。t3code 提供了一个 .env.example 文件里面列出了所有需要的变量名。我第一次就是吃了没仔细看这个文件的亏跳过配置直接启动结果页面能打开数据请求全部报错。常规需要配置的变量包括数据库连接串、鉴权相关的密钥、如果是部署后还需要填写外部服务的回调地址。特别注意Next.js 的环境变量分两种NEXT_PUBLIC_ 前缀的变量会暴露给浏览器端其他变量只存在于服务端。t3code 的鉴权和数据库连接串都属于敏感服务端变量千万不要加前缀。配置完环境变量后执行数据库同步命令pnpm db:push这个命令会根据 schema 直接创建或更新数据库表结构适合开发阶段快速迭代。后续要上生产环境则需要改用正式的迁移流程这个我在部署章节专门展开。5. 一次完整功能从按钮到数据库的类型流动工具链的价值不能只靠看文档理解最好亲手跟着走一遍完整的请求链路。这一节我用一个创建待办事项的功能拆解 t3code 里从按钮点击到数据库写入的全过程重点看类型信息是怎么沿着调用链流动的。5.1 数据库 schema 先行功能开始前我先在 schema 文件里定义 todo 表model Todo { id String id default(cuid()) content String done Boolean default(false) createdAt DateTime default(now()) updatedAt DateTime updatedAt }定义好之后执行pnpm db:push数据库和类型同步更新。此时 TypeScript 项目里已经可以引用 Todo 类型了前端代码自动拥有对这个结构的感知。5.2 在 tRPC router 中声明接口接下来在 src/server/api/routers/todo.ts 里定义创建待办的逻辑import { z } from zod; import { createTRPCRouter, publicProcedure } from ~/trpc/server; const createTodoInput z.object({ content: z.string().min(1).max(200), }); export const todoRouter createTRPCRouter({ create: publicProcedure .input(createTodoInput) .mutation(async ({ ctx, input }) { return ctx.db.todo.create({ data: { content: input.content, done: false, }, }); }), });注意这里用 zod 声明了一个输入校验规则。zod 的作用是同时承担运行时校验和静态类型推导z.object 定义不仅会在请求到达时校验数据格式它的 TypeScript 类型也会被自动推断成 input 参数的类型。也就是说你在 mutation 回调里拿到的 input已经是完全类型安全的。t3code 要求每个 router 都要组织到统一的根 router 里这样前端才能从单一入口拿到所有调用方式。根 router 在 src/server/api/root.ts 里把 todoRouter 注册进 routers 字段就可以。5.3 前端的类型安全调用现在打开前端页面调用刚才定义的这个接口。t3code 要求用 React Query 管理服务端状态这个封装层带来两个好处自动缓存、请求去重、失败重试属于第一层价值第二层价值才是关键——调用方式和你调用本地异步函数一模一样但函数签名完全来自服务端定义。import { api } from ~/trpc/client; function AddTodoButton() { const utils api.useUtils(); const createTodo api.todo.create.useMutation({ onSuccess: () { utils.todo.list.invalidate(); }, }); return ( form onSubmit{(e) { e.preventDefault(); const formData new FormData(e.currentTarget); createTodo.mutate({ content: String(formData.get(content)), }); }} input namecontent placeholder输入待办内容 / button typesubmit添加/button /form ); }当你在编辑器里敲 api.todo.create 时方法名、参数结构、返回值结构全部有代码提示。输入框字段拼错了编译阶段就会被拦截。这种体验和 REST 接口的最大区别在于REST 接口的错误往往发生在运行时——请求发出去了后端返回 400前端才想起来参数格式不对而 t3code 把这类错误提前到了写代码的瞬间。5.4 类型沿着链路的校验不是魔法是结构有人可能会觉得这种体验很神奇其实背后的机制并不神秘。tRPC 的服务端 router 在被创建时TypeScript 编译器就已经计算出了完整的类型签名。当客户端引用 api.todo.create 时它用的实际上是服务端类型通过模块引用链直接传递过来的结果。整个过程中没有代码生成步骤、没有解析 schema 的环节就是纯 TypeScript 编译器力所能及的类型推断。理解了这一点你就能明白为什么修改服务端代码时编辑器会在客户端文件里立刻出现红色波浪线。其实不是另一端收到了通知而是同一套编译器在检查同一个模块图里的类型变化。这个机制简单、可靠也没有运行时开销。6. 生产环境部署与上线前的关键配置项目开发完总要上线的。t3code 的部署和其他 Next.js 应用有些共性但也存在几个容易被忽略的特殊点。这一节讲的是我实际部署两次之后总结出的关键配置。6.1 选择部署方式一体化部署与分离部署Next.js 应用最常见的部署方式是推送到 Vercel 或者支持 Next 构建的 Node.js 环境直接把整套应用跑起来。t3code 在容器化部署时要注意构建阶段需要生成 Prisma Client如果选了 Prisma 方案否则容器启动时会因为找不到 client 而崩溃。更稳妥的做法是在 Dockerfile 里明确三条命令的顺序pnpm install pnpm db:generate pnpm builddb:generate 的作用是根据 schema 生成数据库 client这一步不能被 pnpm build 隐含执行。如果你把整个项目放到 Kubernetes 里跑还需要把迁移命令单独拆成一个初始化 Job避免多个实例同时执行迁移引发锁竞争。这个细节很多教程不会写但真实生产环境踩一次就知道了。6.2 环境变量管理和密钥轮换上一节提到过环境变量的敏感性这里展开多说一点。生产环境里服务端密钥必须以环境变量的方式注入不能硬编码到代码仓库也不能写进 Docker 镜像。我在项目里用了一个简单的约定所有敏感环境变量集中在一个 .env.production 文件但该文件严格加入 .gitignore。部署平台上单独配置这些变量通过平台的 secret 管理能力注入到运行时环境中。还有一个容易踩的坑Next.js 会把 NEXT_PUBLIC_ 前缀的变量构建进客户端产物。如果你修改了这类变量必须重新执行 pnpm build因为它们不是运行时读取的而是构建时打包的。我曾经改了 NEXT_PUBLIC_API_BASE 忘了重新构建线上页面仍然请求旧地址排查了半天才发现是构建缓存作怪。6.3 监控与错误上报的接入方式生产环境没有监控等于裸奔。t3code 服务端的错误通常有两类一类是 tRPC 过程内的逻辑错误一类是 Next.js API Route 层面的错误。针对 tRPC 错误在创建根 router 时统一处理是比较高效的方式。我在实际的 t3code 项目里给每个 router 统一加了一个错误格式化器把 zod 校验错误、数据库约束错误、未授权错误分别映射到不同的 HTTP 状态码同时把错误信息发送到错误追踪平台。这样前端可以通过错误码判断是参数问题还是权限问题从而给出不同的用户提示而不是一律网络错误。日志方面推荐按请求维度记录。每个请求的 tRPC 路径、输入摘要、耗时、错误码都打出来后续排问题会非常省力。我在排查线上问题时经常用到的就是这套日志能够直接定位到某个用户在某次操作时具体是哪一步失败了。7. 移植到真实业务时的性能优化与规模边界一个脚手架项目跑通 Demo 很容易真正让它进入生产系统需要考虑性能和架构扩展。这一节我聊聊 t3code 在真实业务里如何优化性能以及它适合的规模边界。7.1 tRPC 请求的响应体大小控制全栈应用中一个容易被忽视的性能问题是接口返回了太多前端用不到的数据。t3code 的 tRPC 查询默认会把数据库里查到的完整对象返回给前端。如果一个表有 30 个字段前端列表页只需要其中 5 个那剩下的 25 个字段就是浪费既增加了传输体积也拖慢了序列化时间。解决方案是在 router 里做投影查询——只查询需要的字段。比如获取待办列表时只 select id、content、done 三个字段。tRPC 的类型系统会同时把返回类型收窄前端调用处的类型提示也会同步变少。这一步既减负网络传输又保持了类型安全是我在项目优化时优先检查的地方。7.2 React Query 的缓存复用与失效策略t3code 默认集成 React Query但它的默认配置比较保守。在真实项目里合理配置缓存时间能显著减少重复请求。比如用户的基本信息在会话期间基本不会变化可以把 staleTime 设置成 5 分钟而待办列表的完成状态可能随时变化staleTime 就设短一点。缓存失效的关联关系也需要维护。创建数据之后通常会 invalidate 对应的列表查询。我在项目里遇到过一个缓存脏数据问题用户在一个 tab 里修改了设置切回另一个 tab 时看到的数据还是旧的因为相关查询没有被正确失效。后来我把所有关联查询的失效逻辑都集中到一个工具函数里每次数据变更时统一处理才彻底解决这个问题。7.3 项目成长到什么阶段需要拆出独立服务t3code 非常适合单体全栈应用但它也有边界。当你的项目发展到一个阶段不同模块的调用频率差异巨大、某个模块需要独立扩缩容、或者你需要以 API 形式对外提供服务这时就该考虑拆分独立服务了。我个人的判断标准是三个信号同时出现再动手第一单个 Next.js 进程的 CPU 长期高负载主要是数据处理和序列化开销第二团队分化成明显的两组各自负责的业务领域几乎没有交叉第三外部系统开始频繁调用你系统中的某些接口。在此之前维持 t3code 的单体架构其实是更高效的选择。单体架构的部署、调试、类型维护成本都更低过早拆分微服务只会把复杂性从代码层转移到运维层。7.4 批量操作与长任务的类型处理最后补一个容易被遗漏的场景批量导入和异步任务。t3code 的 mutation 默认是同步等待返回结果但如果业务里有导入几千条数据的长任务直接在前端等待会超时。我建议对这类操作采用任务式设计思路mutation 里只创建一个任务记录并立即返回真正的处理逻辑丢到一个异步队列里执行完成后再通过另一个查询接口获取任务状态。类型定义上任务记录和状态查询都是普通的数据库模型t3code 天然支持。这种设计避免了浏览器请求超时的问题也让用户能感知到任务进度体验反而更好。8. 我实际踩过的三个坑和一些零散心得前面几章基本都是顺利路径上的操作这一章分享我在 t3code 使用过程中真实踩过的坑。这些坑未必是大问题但每个都耗费了我少则半小时、多则一整天的排查时间写出来希望你能直接绕开。8.1 开发和生产环境数据库结构不同步第一个坑发生在开发环境用 db:push 快速同步 schema生产环境却忘记执行迁移命令。结果是线上接口一直报数据库字段不存在前端却毫无察觉因为类型定义都是最新的。这种类型正确、运行时报错的情况最迷惑人。我的教训是从项目初始化第一天就建立迁移文件的习惯。开发环境可以用 db:push 图快但每次 schema 变更都要同时生成一份迁移文件。上线前先执行 db:migrate 确认迁移可以正常应用再执行构建。如果团队有自动化部署流程最好把迁移命令作为发布流程的前置步骤。8.2 依赖镜像问题导致安装失败我第一次用 t3code 是在网络环境不太稳定的情况下pnpm install 反复失败。后来查了日志发现是部分依赖包下载超时。切换到国内镜像源之后就恢复正常了。这里的建议是如果你所在位置的网络访问 npm 官方源不稳定尽早配置镜像源可以节省很多重复安装的时间。8.3 客户端渲染与服务端渲染的边界问题t3code 使用 Next.js App Router客户端组件和服务端组件的边界容易混淆。最常见的问题是某个组件里用了浏览器的 localStorage却在服务端渲染阶段被调用导致 hydration 报错。我的处理原则是所有涉及浏览器专有 API 的逻辑都放到 useEffect 里执行或者封装成动态加载的组件。这个规则简单但能避免大多数渲染异常。如果你在 t3code 里集成第三方 UI 库也要留意这个边界。部分 UI 库内部默认使用 window 对象直接作为客户端组件引入如果它在服务端渲染阶段被引用就可能报错。用 dynamic 导入并标记 ssr: false 可以解决。8.4 类型安全不等于逻辑正确最后说一个心理层面的事t3code 帮你消除了类型层面的错误但业务逻辑的 bug 依然存在。类型系统保证的是你传的参数结构是对的但无法保证A 和 B 两个字段之间的业务联动是否符合预期。不要因为类型全是安全的就放松对逻辑测试和边界用例的检查。我在项目里仍然保留了完整的集成测试流程类型安全是质量保障的一部分但不是全部。9. 后续我计划在这个方向上做的事t3code 这套工具链跑通之后我对类型安全驱动开发这个方向多了很多思考。接下来我想尝试几个方向一是给项目接入 OpenTelemetry把 tRPC 每次调用的链路追踪数据接进监控面板这样可以观察到哪些接口的耗时集中在数据库层哪些集中在序列化层。二是做一个通用的权限控制模块把基于角色的权限判断抽象成 tRPC 中间的工厂函数不同 router 通过声明式配置来启用权限。三是在团队内部推广类型优先的协作模式让后端同学先把 schema 定义提交前端同学基于生成的类型先开发界面再把业务逻辑补上这样两端可以并行推进。最后分享一个小技巧t3code 项目的 package.json 脚本里通常包含了 start、dev、build 等标准命令但建议你额外加两个脚本——clean 用于清除 node_modules 和 build 产物reset 用于重置数据库并重建种子数据。这两个脚本在频繁切换分支或调整 schema 时能节省不少时间。我自己的项目里已经用这两个脚本救过好几次场了。
返回列表