ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

T3栈全栈开发实战:TypeScript+tRPC+Next.js打造类型安全应用

T3栈全栈开发实战:TypeScript+tRPC+Next.js打造类型安全应用 1. 项目整体设计与技术选型思路1.1 t3code 到底是什么解决什么痛点先说清楚结论t3code并不是某个开源框架的名字而是我在实际工作中沉淀的一套基于 T3 技术栈的编码实践项目代号。所谓 T3 栈核心就是TypeScript Tailwind CSS tRPC这三件套的组合通常还会搭配 Next.js、Prisma、NextAuth.js 一起使用。t3code这个项目要解决的核心问题其实非常朴素过去写一个全栈应用前端、后端、数据库三端各写各的类型对不上、接口文档过时、联调成本高改一个字段可能要牵扯三处代码。T3 栈的价值在于用 TypeScript 把这三端串成一条完整的类型安全链路让编译器在开发阶段就帮我们挡住一大半低级错误。很多朋友第一次听到 T3 栈时会觉得它是一个很新的东西其实它只是把已经成熟的工具用一种更舒服的方式组合起来。TypeScript 负责全链路类型系统Tailwind CSS 负责样式层的原子化表达tRPC 负责前后端接口调用。这个组合最吸引人的地方在于你在服务端写一个查询函数前端不用手写 API 请求代码也不用维护一份接口定义文件类型和调用方式会直接“同步”到客户端。t3code就是围绕这套组合做的一个完整示例工程。适合谁来参考如果你正在做 Next.js 全栈项目或者对“类型安全”很在意又或者你已经被前后端接口对不上、联调效率低下折磨过那这篇文章的实操内容可以直接套用到你的项目里。如果你是刚接触 TypeScript 或 Next.js 的新手跟着走一遍也能建立起对现代全栈开发流程的完整认知。1.2 为什么选 T3 栈类型安全不是玄学先说 TypeScript。现在再争论“要不要用 TypeScript”其实已经没有意义了真正的问题是怎么用好它。在 t3code 项目里TypeScript 承担的角色不只是给变量加类型标注而是贯穿整个应用的类型契约制定者。从prisma.schema里定义的数据模型到 tRPC router 里的输入输出类型再到 React 组件里接收的 props全链路都在同一个类型体系里运转。这意味着你在数据库里加了一个字段保存后类型会自动传导到前端如果哪个组件没适配编辑器里立刻就会标红。Tailwind CSS 的选择理由更偏向工程效率。业务项目里 80% 的样式其实都是“排列、间距、颜色、圆角、阴影”这些组合。用原子类直接在 JSX 里写省去了频繁切换 CSS 文件的成本。有人会诟病 className 太长但配合组件拆分这其实不是问题一个按钮组件的样式是固定的你只需要封装一次后面所有用到的地方都是干净的接口。Tailwind 还天然解决了样式作用域问题不会出现全局样式互相覆盖的灾难。tRPC 是最让人“上头”的环节。传统前后端通信要写 REST 接口、定义 DTO、手写请求方法还要维护一份常常过期的接口文档。tRPC 直接用一个函数调用代替 HTTP 请求的书写成本前端调用trpc.post.list.useQuery()后端定义同名 procedure类型和用法全部自动推导。这种体验就像在本地调用一个异步函数只不过这个函数跑在服务器上。1.3 方案取舍的深层逻辑选型过程中我也做过其他方向的权衡。比如为什么不直接用 GraphQLGraphQL 确实也能做到类型安全但你需要额外配置 schema、resolver、codegen整条链路重了不少。对于中小型项目来说tRPC 的学习成本更低心智负担更小。再比如为什么不干脆用 Next.js 的 API Routes React Query这当然可行但你需要手动维护 API 路径、请求类型、错误处理而这些 tRPC 都帮你封装掉了。t3code项目的最终技术栈是Next.js 14App Router TypeScript 5 Tailwind CSS 3 tRPC v10 Prisma SQLite。SQLite 的选用是为了本地开发零配置起步不需要单独装数据库服务这在演示和教学场景下是一个巨大的便利。生产环境切换到 Postgres 只需要改一行环境变量和 connection stringPrisma 的适配层把这种切换成本降到了最低。2. 环环相扣的初始化实战2.1 初始化前的环境准备动手之前需要确认本机环境Node.js 18建议 20 LTS我实际使用的是 20.11、pnpm 包管理器、Git。Node 版本这块不用太纠结只要满足 Next.js 14 的要求就行。包管理器我推荐 pnpm除了安装速度快更重要的是它严格的依赖隔离能避免很多“我机器上明明是好的”这类玄学问题。你用 npm 或者 yarn 也完全没问题但有一点必须统一锁文件要提交到仓库里别在团队里混用不同包管理器否则 node_modules 结构不一致会引发各种诡异 Bug。我见过太多次因为 npm 和 yarn 混用导致的依赖版本错乱问题了这是新手最容易忽视的细节。2.2 create-t3-app 初始化流程初始化使用社区官方脚手架create-t3-app这条命令会自动完成所有基础配置。用 pnpm 执行pnpm create t3-applatest交互式命令行会让你填项目名称我们输入t3code。接着是技术栈选项清单按需勾选Next.js必然项选 App RouterTypeScript默认开启无需取消Tailwind CSS必选这是 T3 的 TtRPC必选这是 T3 的第三个 TPrisma必选负责数据库访问NextAuth.js如果后续要做登录注册就选上我这个项目暂时没做认证所以没有勾选还有一个容易被忽略的选项是ESLint / Prettier建议都开启。ESLint 帮你拦住低级错误Prettier 统一代码风格省去团队里格式化争论。初始化完成后脚手架会自动安装依赖并生成完整的目录结构。注意初始化时如果网络不稳定部分依赖下载会失败。最直接的解决办法是删除 node_modules 和锁文件后重装pnpm install。如果公司网络受限需要提前配置好 npm 镜像源。2.3 目录结构逐层解读初始化的目录结构体现了 T3 栈的核心思想我们逐层拆解t3code/ ├── prisma/ │ └── schema.prisma # 数据模型定义 ├── src/ │ ├── pages/ # 页面路由如果选的是 Pages Router │ ├── app/ # App Router 路由 │ ├── server/ │ │ ├── api/ # tRPC 路由注册 │ │ ├── auth/ # 认证相关本项目未启用 │ │ ├── db.ts # Prisma 客户端实例 │ │ └── trpc.ts # tRPC 上下文与初始化 │ ├── styles/ # 全局样式 │ ├── trpc/ # 前端 tRPC 客户端封装 │ └── utils/ # 工具函数 ├── .env # 环境变量数据库连接等 └── package.json关键点有两个。第一src/server/trpc.ts是整个 tRPC 能力的核心注册文件所有 API procedure 的上下文比如当前用户信息、数据库实例都从这里创建。第二src/trpc目录对应前端调用所需的 client 和 hooks 封装。这两侧的代码由脚手架自动生成我们实际开发时主要往src/server/api/routers目录里添加业务 router。3. 核心功能实现一个完整的全栈 CRUD3.1 定义数据模型Prisma Schema我实现了一个真实的业务场景文章管理。包含分类和文章两张表文章通过外键关联到分类。prisma/schema.prisma的核心定义如下generator client { provider prisma-client-js } datasource db { provider sqlite url env(DATABASE_URL) } model Category { id String id default(cuid()) name String unique slug String unique posts Post[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Post { id String id default(cuid()) title String content String published Boolean default(false) categoryId String category Category relation(fields: [categoryId], references: [id]) createdAt DateTime default(now()) updatedAt DateTime updatedAt }这里主键用的不是自增数字而是cuid()原因在于分布式环境下自增 ID 容易在数据合并或迁移时产生冲突cuid 由时间戳随机数生成不依赖中央序列在导出导入、多环境部署场景下都更稳。多对多场景你可能会想用manyToMany但 Prisma 目前更推荐显式定义中间表来维护关系比如文章标签就是三张模型。定义好 schema 后执行两条命令pnpm prisma migrate dev --name init pnpm prisma generate第一条命令创建数据库表结构并生成迁移记录第二条命令生成类型安全的 Prisma Client。这里特别强调generate必须每次在 schema 变更后执行否则 Prisma Client 无法反映最新的模型而你编辑代码时看到的类型提示也会是旧的。我把postbuild: prisma generate加到了 package.json 的构建流程里防止部署时漏掉这一步。3.2 服务端 tRPC Router 编写服务端 API 的编写方式非常直接。所有业务逻辑都组织在src/server/api/routers目录下。文章管理的 router 长这样import { z } from zod; import { createTRPCRouter, publicProcedure } from ../trpc; export const postRouter createTRPCRouter({ list: publicProcedure .input(z.object({ category: z.string().optional() })) .query(async ({ ctx, input }) { return ctx.db.post.findMany({ where: input.category ? { category: { slug: input.category } } : undefined, include: { category: true }, orderBy: { createdAt: desc }, }); }), byId: publicProcedure .input(z.string()) .query(async ({ ctx, input }) { return ctx.db.post.findUnique({ where: { id: input }, include: { category: true }, }); }), create: publicProcedure .input(z.object({ title: z.string().min(1), content: z.string().min(1), categoryId: z.string(), published: z.boolean().default(false), })) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: input }); }), });注意到几个关键设计。第一publicProcedure表示任何人都能访问等接入认证后可以替换为protectedProcedure。第二输入校验全部交给 Zod 完成Zod 的类型推导能力让我们不需要手写接口的Input和Output类型。第三也是 tRPC 的杀手锏你写了这份 router 之后前端就能拿到完全对应的类型推导拼错一个字段名都会在编译期报警而不是等到运行时给你一个 500 或者 400。list这个 query 我选了按分类 slug 过滤的写法而没有直接传分类 ID因为前端路由上体现的多是语义化的 slug比如/posts/tech。语义化标识作为对外暴露的查询条件内部 ID 作为关联约束这种区分在建模时是个重要的习惯。3.3 前端调用与服务端无缝衔接前端代码需要考虑的反而更少这是好事。在服务端写好了postRouter之后要在根 router 里挂载// src/server/api/root.ts export const appRouter createTRPCRouter({ post: postRouter, category: categoryRouter, });前端通过生成的 React hooks 调用import { api } from ~/trpc/react; export function PostList({ categorySlug }: { categorySlug?: string }) { const { data, isLoading, error } api.post.list.useQuery({ category: categorySlug, }); if (isLoading) return div加载中.../div; if (error) return div加载失败{error.message}/div; return ( div classNamegrid gap-4 {data?.map((post) ( article key{post.id} classNamerounded-lg border p-4 h2 classNametext-xl font-semibold{post.title}/h2 p classNametext-sm text-gray-500{post.category.name}/p p classNamemt-2 line-clamp-3{post.content}/p /article ))} /div ); }注意data的类型它是Post { category: Category }[]是服务端返回结构的精确映射。这就是 tRPC 最让人踏实的地方前后端口径不一致的问题从架构层面被消灭了接口文档、“手写 DTO”这些概念都变成了历史名词。新写一个 mutation 也同样简单调用api.post.create.useMutation()提交成功后刷新列表即可。这里我踩过一个坑mutation 之后需要手动执行utils.post.list.invalidate()来刷新缓存如果不做这步新建的数据不会立刻出现在列表里。这一点和 React Query 的失效机制一致写业务代码时很容易忘。3.4 页面组装与 Tailwind 样式落地有了 API 能力页面组装就是纯粹的 UI 工作。我用 Tailwind 构建了一个简易的发布页面表单字段包括标题、正文、分类和发布开关。表单提交部分的代码const createPost api.post.create.useMutation({ onSuccess: () { utils.post.list.invalidate(); router.push(/); }, }); function handleSubmit(e: React.FormEvent) { e.preventDefault(); const formData new FormData(e.currentTarget); createPost.mutate({ title: String(formData.get(title)), content: String(formData.get(content)), categoryId: String(formData.get(category)), }); }UI 上用 Tailwind 类控制间距和边框整个开发过程不需要碰 CSS 文件。Tailwind 的daisyUI或shadcn/ui组件库可以进一步提速但我建议在学原理阶段先手写样式这样对布局和响应式断点会有更扎实的理解。4. 常见问题与排查技巧实录4.1 Prisma 数据库连接与迁移问题问题一DATABASE_URL未设置初始化后第一件必做的事是配置.env。脚手架会生成一个.env模板但不会自动填入值。直接用相对路径配置 SQLiteDATABASE_URLfile:./db.sqlite如果不设置就运行prisma migrate会直接报错提示环境变量缺失。这个问题很蠢但很多人遇到过包括我自己。问题二迁移历史冲突本地开发时经常要改 schema有时直接改了 schema 但没有跑 migration下一次migrate dev时会提示 migration history 不一致。解决办法是在非生产环境直接prisma migrate reset清库重来但生产环境千万别这么干。更稳妥的方式是遵从migrate dev --create-only先生成 SQL检查无误后再migrate dev执行。4.2 tRPC 类型推导失效的排查问题一前端 api 对象找不到新加的 router我遇到过两次这种情况。root router 里已经挂载了新 router但前端始终无法识别。排查后发现是tsc的 watch 模式没完全重启或者 dev server 缓存了旧的类型声明。解决方案是重启 dev server如果重启后问题依旧检查src/trpc/react.tsx中引用的AppRouter类型是不是来自root.ts确实有同事直接 import 了错误路径的 router 类型。路径写错时前端类型肯定不对。问题二Zod 输入类型收窄不理想tRPC 的输入类型由 Zod schema 推导如果你用了.transform()或.refine()推导出的类型可能是精炼后的类型而不是原始输入类型。这时候需要显式声明.input(z.object({ id: z.string() }).transform((val) ({ id: val.id })))这个 transform 的输出类型会被当作 procedure 的输入类型。理解这一点排查 Zod 相关类型问题会快很多。4.3 常见错误速查表错误现象可能原因处理方式PrismaClientInitializationError数据库文件不存在或路径错误检查.env的DATABASE_URL执行prisma migrate devtRPC 调用返回 404router 未挂载到 root router检查root.ts确认已添加对应 router前端 TypeScript 报错Property does not existtRPC 类型未更新重启 dev server删除.next缓存构建失败Type errorZod 类型和实际返回类型不一致检查 mutation/query 的返回值和模型字段是否对应样式不生效Tailwind 扫描路径配置不全确认tailwind.config.ts的 content 字符串包含./src/**/*.{ts,tsx}表单中文无法正常提交字符编码问题确保页面设置了 UTF-8且数据库编码正确SQLite 本身无此问题迁移到 MySQL/Postgres 时注意4.4 两个值得复制的实战习惯第一个习惯是写 seed 数据。开发阶段没有数据列表页永远是空的你很难判断布局。我在prisma/seed.ts里写了几条示例数据然后加入 package.json 脚本prisma:seed: tsx prisma/seed.ts执行pnpm prisma db seed即可填充基础数据这比起手打样本快得多。seed 脚本里我反复调用 upsert 保证幂等性重复执行不会堆积重复数据。第二个习惯是给 tRPC procedure 加合理的错误处理。默认情况下Zod 校验失败会抛异常前端收到一个笼统的Bad Request。更友好的做法是在 router 里用try/catch包裹业务逻辑并通过TRPCError抛出结构化错误码import { TRPCError } from trpc/server; if (!category) { throw new TRPCError({ code: NOT_FOUND, message: 分类不存在, }); }这样前端拿到错误提示就能直接展示给用户而不是看到一行技术文档式的英文报错。5. 前后端交互的进阶优化5.1 乐观更新体验CRUD 功能跑通并不意味体验就合格。写评论或编辑文章时如果每次都要等服务器返回后才刷新界面整个操作会显得很迟钝。tRPC 基于 React Query天然支持乐观更新。以一个更新文章标题的例子说明const updateTitle api.post.updateTitle.useMutation({ onMutate: async ({ id, title }) { // 取消正在进行的查询避免覆盖乐观更新 await utils.post.list.cancel(); // 读取当前缓存快照 const prev utils.post.list.getData(); // 直接修改缓存中的文章标题 utils.post.list.setData(undefined, (old) old?.map((post) post.id id ? { ...post, title } : post) ); return { prev }; }, onError: (err, _vars, context) { // 回滚原来的标题 utils.post.list.setData(undefined, context?.prev); }, onSettled: () { // 最终重新拉取服务器数据保证一致 utils.post.list.invalidate(); }, });这个模式的核心价值在于响应是即时的无论网络多慢界面都先给用户反馈。连带思路要提的是不适合乐观更新的场景是写操作依赖服务端计算结果的逻辑比如金额计算这种就别乐观更新了老老实实等真实返回。5.2 服务端查询与数据复用Next.js App Router 下 tRPC 的另一个好搭档是 Server Components。你可以直接在服务端调用 tRPC procedureimport { api } from ~/trpc/server; async function Page({ params }: { params: { slug: string } }) { const posts await api.post.list.query({ category: params.slug }); return PostGrid posts{posts} /; }这种方式没有额外的客户端网络请求页面渲染性能更好同时类型依然是全链路安全的。实际项目中公开数据比如文章详情可以用服务端查询而需要交互的数据比如点赞、收藏则交给客户端 hooks。两条路径和谐共存不会互相冲突。6. 部署上线与监控补全6.1 SQLite 到生产数据库的切换本地用 SQLite 太舒适等要部署到生产时必须慎重。SQLite 并发性能有限生产推荐切换到 Postgres。修改方法很简单.env里换成 Postgres 的连接串然后在 schema 文件把 provider 改成postgresql执行prisma migrate deploy。这里有个关键点连接串不要写在仓库里放到部署平台的环境变量中.env始终通过.env.example模板来约定字段。6.2 部署到 VercelT3 栈项目部署到 Vercel 是相当顺滑的。Vercel 会自动识别 Next.js 项目你只需要在后台配置好环境变量。有一个容易踩的坑Vercel 的构建环境是只读文件系统而 Prisma 在构建时需要生成 client。我的做法是在 package.json 的 build 脚本里加上prisma generate确保构建时生成正确类型的 client。另外构建阶段不需要执行 migrate因为数据库迁移应该在发布流程中单独跑或者在 Vercel 的 Prebuild 命令里显式运行prisma migrate deploy二选一别重复执行。6.3 日志、监控与错误收集上线之后不能当瞎子。我个人推荐接一套监控方案Sentry 负责前端与后端异常收集Logflare 或类似服务收集应用日志。tRPC 的错误会通过 JSON 返回前端Sentry 的 Next.js SDK 会自动捕捉 unhandled rejection 和部分 fetch 错误但要确保把 tRPCError 也传播给 Sentry。加上按错误码和路径分组的维度后线上问题定位会快很多。部署完成后再次加强环境NODE_ENVproduction、DATABASE_URL指向生产库、NEXTAUTH_SECRET如果启用认证随机生成。Vercel 里可以关闭自动休眠以便内部工具保持响应但不用过度追求 Serverless 性能调优先把服务跑稳比什么都重要。7. 常见问题的最后一轮梳理再整理几个我从t3code项目排障过程中总结出的高频问题方便你做 checklist。依赖/环境类create-t3-app初始化时 Node 版本过低提示需要 18升级 Node 后解决。安装后pnpm dev报Cannot find module把 node_modules 重装一遍同时检查 pnpm 版本。Tailwind class 不生效检查tailwind.config.ts中的 content 是否覆盖了src/**。Prisma 类prisma migrate dev报错Environment variable not found: DATABASE_URL原因和解决办法上文已说。字段改了但前端类型没变记得prisma generate并重启 dev server。生产环境用 SQLite 导致并发写入锁 DB尽早切到 Postgres。tRPC 类新加 router 后前端api对象 404确认 root router 已挂载。前端报NOT_FOUND或UNAUTHORIZED确认 procedure 用的是publicProcedure还是protectedProcedure。输入校验不通过返回 400利用 Zod 的.message()给中文提示提升联调效率。开发体验类mutation 成功后 UI 没更新调用invalidate刷新缓存。类型推导不出来的地方把鼠标悬停在调用点看懂推导链路往往能定位问题。强烈建议用 ESLint 规则typescript-eslint/consistent-type-imports强制import type避免运行时 import 类型造成模块加载问题。t3code做到这个阶段已经是一个非常完整的中小型全栈应用了。它覆盖了模型建模、API 实现、前端消费、部署上线和问题监控的全流程。个人体验下来T3 栈最大的爽感在于每次改动字段后整个前后端围绕字段的类型都跟着同步那种“编译器帮我检查一切”的安心感是传统 REST 开发完全给不了的。如果你正在规划自己的下一个全栈小项目完全可以拿这个套路练手把认证加进去再补一个后台管理系统就变成了一个能直接对外展示的作品。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表