ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

T3 Stack 全栈项目 t3code 完整复盘:从初始化到部署

T3 Stack 全栈项目 t3code 完整复盘:从初始化到部署

如果你在开发者社区里刷到过t3code这个词,大概率会把它和 T3 Stack 联系起来。没错,t3code 就是我维护的一个全栈项目仓库,核心是用 T3 Stack 做一套代码片段管理工具。它能干什么?一句话:把散落在本地和聊天记录里的代码片段集中收藏、打标签、按语言筛选,还能一键复制甚至分享给朋友。适合什么人看?正在学 Next.js 全栈、想搞清楚 tRPC 和 Prisma 怎么配合的人,或者纯粹想找一个能跑起来的 T3 Stack 参考项目的人。这篇文章我会从项目设计、技术选型、初始化、数据模型、前后端实现到部署排错,完整复盘一遍。

1. 项目整体设计:t3code 想做成什么样

1.1 从痛点出发的功能清单

我最初想做 t3code,是因为自己经常在开发中遇到一个尴尬场景:一段调试了很久才跑通的配置代码,今天调完,下周再用就忘了放哪儿了。微信收藏夹里一大堆代码截图,本地 notes 里混着各种临时记录,真要找的时候只能硬翻。

所以 t3code 的第一个版本,功能列表非常克制。它只做四件事:

  • 展示所有已保存的代码片段,按创建时间倒序排。
  • 新建片段时填写标题、正文内容、编程语言,并支持挂多个标签。
  • 首页提供语言筛选框,可以快速过滤出 JavaScript、Python、Go 等特定语言的片段。
  • 每条片段右侧有一个复制按钮,点一下就把完整代码复制到剪贴板。

这个功能范围看起来简单,但已经足够覆盖 T3 Stack 里大部分核心能力:Prisma 负责数据写入和查询,tRPC 负责前后端接口,Next.js 负责页面渲染,Tailwind 负责外观。如果一开始就把权限、评论、点赞都塞进来,项目复杂度会被迫拉高,反而不利于搞清楚技术栈本身的协作方式。

另外我在设计时坚持了一个原则:所有操作都不需要手动刷新页面。创建完片段后自动回到列表页,列表页数据自动更新。这个体验优化在传统开发里需要写一堆状态同步逻辑,但在 T3 Stack 里几乎是由 React Query 的缓存失效机制白送的,后面会详细说。

1.2 技术选型的取舍逻辑

先解释一下 T3 Stack 到底是什么。它不是一个单一框架,而是一套组合方案,常见组成是 TypeScript、Tailwind CSS、Next.js、tRPC、Prisma。也有人把这套东西叫全栈开发全家桶,因为它从数据库到样式到前端交互全都有明确分工。

我选择这套组合,核心原因是“类型安全可以贯穿一整个请求链路”。你在 Prisma 里定义的数据模型,生成出的 TypeScript 类型可以直接被 tRPC 的 Router 使用;tRPC 的 query 返回类型,又可以自动映射到前端 React Hooks 的返回值。也就是说,从数据库字段到前端渲染,类型是咬死的,字段名写错、类型不对,在编译期就会报错,不用等运行时。

如果把项目改成传统前后端分离,用 Next.js 只做前端,后端单独用 Express 或者 NestJS,那至少要多维护一份 OpenAPI 文档或者手写类型声明。对于 t3code 这种轻量工具,这属于过度设计。REST 接口虽然通用,但每次加字段都要手动同步类型,频率一高就容易出现前后端不一致的隐患。

tRPC 在这套架构里替代的是 REST 层。它不需要你用 URL 去描述动作,直接调用 TypeScript 函数就行。我刚开始用的时候也觉得不够“工程化”,后来发现团队协作反而更简单,因为接口定义就是函数签名,后端改了签名,前端立刻报红,根本不会等到联调才发现参数对不上。

状态管理方面,t3code 没有引入 Redux 或者 Zustand。原因很简单:服务端数据状态全部由 React Query 管,本地表单状态只需要 useState。两个状态域分得清清楚楚,没必要再塞一个全局状态库把水搅浑。这个取舍在多人协作时尤其重要,代码评审一看就知道哪些是服务端数据、哪些是本地临时态。

2. 从零初始化 t3code:环境准备与目录解读

2.1 开发环境的最低配置

在跑 create-t3-app 之前,先把环境准备好。我实际用的配置是 Node 20.11,包管理器用 pnpm,数据库先用 SQLite,后面部署到生产环境时再切 PostgreSQL。

工具推荐版本备注
Node.js18.0 以上,推荐 20create-t3-app 要求较新的 Node,20 是稳定选择
包管理器pnpm 8+npm 和 yarn 也能用,但 pnpm 安装速度更快
数据库SQLite 本地,PostgreSQL 生产本地零配置,生产避免 SQLite 的 serverless 限制
Git2.30+初始化仓库后方便回滚每一步实验

这里有个经验分享:不要在 Node 16 上折腾。T3 Stack 里的 Next.js 14 和 tRPC 10 已经明确要求 Node 18+,我在初期踩过坑,安装依赖能成功,但 dev server 经常内存溢出,后来换了 Node 20 才稳定。

2.2 create-t3-app 初始化命令与交互选项

初始化 t3code 我直接用了官方脚手架,命令是:

pnpm create t3-app@latest t3code

交互过程中会有很多选择项,我当时选了这些:

  • Next.js:选 App Router。
  • TypeScript:必选,这是 T3 Stack 的根基。
  • Tailwind CSS:必选,样式方案。
  • tRPC:必选,后面所有接口都走它。
  • Prisma:必选,数据模型和数据访问都靠它。
  • NextAuth.js:暂时不选,t3code 第一版不需要登录系统。

如果你像我一样暂时不需要鉴权,可以在初始化时去掉 NextAuth。后面要加也不是很难,但脚手架会多生成一堆示例代码,反而容易干扰理解核心流程。

初始化完成后,项目里会出现一堆文件和目录。初学者容易被吓到,我整理一下最关键的几个,其余可以先不用管:

  • src/app:Next.js App Router 的页面目录,所有页面都在这里。
  • src/server/api:tRPC 的 Router 和上下文配置,后端逻辑都在这层。
  • src/server/db.ts:Prisma Client 的单例实例,供 tRPC Router 调用。
  • prisma/schema.prisma:数据模型定义,改数据库表就是改这个文件。
  • src/env.mjs:环境变量校验,启动时用 Zod 检查缺失变量。

2.3 环境变量与数据库连接配置

create-t3-app 会生成一个.env.example,第一次启动前要把它复制成.env,否则框架会因为找不到环境变量直接退出。

cp .env.example .env

默认的.env里其实只有两个重点变量。t3code 本地用 SQLite,所以DATABASE_URL就是本地文件名:

DATABASE_URL="file:./db.sqlite"

如果你用的是 Postgres,需要改成连接串:

DATABASE_URL="postgresql://user:password@localhost:5432/t3code"

这里想多说一句:Next.js 项目的.env文件千万不要提交到 Git。你可以在.gitignore里直接写死.env,提交到远程仓库的只保留.env.example。这不仅是为了保护密钥,更重要的是,如果团队其他人拉下来代码,能靠.env.example知道需要配置哪些变量,而不是对着空仓库发呆。

3. 数据模型与后端逻辑搭建

3.1 Prisma Schema 设计思路

t3code 的第一个版本只有两个核心模型:代码片段(Snippet)和标签(Tag)。关系很简单,一个片段可以有多个标签,一个标签可以挂在多个片段上,标准的多对多关系。

我在prisma/schema.prisma里定义的数据模型是这样的:

model Snippet { id String @id @default(cuid()) title String content String @db.Text language String @default("plaintext") tags Tag[] @relation("SnippetTags") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Tag { id String @id @default(cuid()) name String @unique snippets Snippet[] @relation("SnippetTags") }

有几个设计细节想说明一下。主键我用了cuid()而不是autoincrement(),因为在实际生产中,数据库可能从单实例迁移到分布式环境,自增 ID 容易冲突,而 cuid 可以在任何节点独立生成。本地开发可能看不出区别,但养成这个习惯以后少踩坑。

content字段加上了@db.Text,这是告诉 Prisma 在 SQLite 里用 TEXT 类型而不是 VARCHAR。如果不加,默认会生成有限长度的字符串类型,一段完整的代码随便就超了。这个细节不注意,等到保存长一点的脚本时会直接报数据超长错误。

language字段没有做成单独的模型。有人可能有疑问,为什么不设计成 Language 表?因为语言这是一个有限的枚举集合,直接存字符串最省事。等真正需要维护语言的元数据时,再抽表不迟。

3.2 tRPC Router 与业务流程实现

数据模型有了,接下来就是写后端逻辑。T3 Stack 里后端逻辑都放在 tRPC Router 中。我在src/server/api/routers/snippet.ts里实现了两个接口:查出所有片段和新建片段。

import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { z } from "zod"; export const snippetRouter = createTRPCRouter({ getAll: publicProcedure.query(({ ctx }) => { return ctx.prisma.snippet.findMany({ include: { tags: true }, orderBy: { createdAt: "desc" }, }); }), create: publicProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().min(1), language: z.string().default("plaintext"), tags: z.array(z.string()).optional(), }) ) .mutation(async ({ ctx, input }) => { return ctx.prisma.snippet.create({ data: { title: input.title, content: input.content, language: input.language, tags: input.tags?.length ? { connectOrCreate: input.tags.map((name) => ({ where: { name }, create: { name }, })), } : undefined, }, }); }), });

这里要说清楚 tRPC 的一个核心概念:publicProcedure表示公开可访问的过程,query对应 GET 类型的读取操作,mutation对应写操作。如果以后要加登录,只需要把publicProcedure换成protectedProcedure,然后在中间件里校验 session,不用改 Controller 层的业务代码。

getAll里的include: { tags: true }是 Prisma 的关联查询,它会一并返回这个片段关联的标签数组。我建议在列表页默认就把标签带出来,否则前端还要为了标签单独发请求,体验会差很多。

create里的connectOrCreate是 Prisma 处理多对多关联的利器。它的逻辑是:如果标签已经存在,就连接已有记录;如果不存在,就创建这个标签。每一行代码都很直观。

输入参数校验我用了 Zod。因为 tRPC 官方默认就集成了 Zod,不用单独引入其他验证库。在前后端类型共享的背景下,Zod 的 schema 既能在服务端做校验,又能在前端自动推导出入参类型。你不需要写两份同名的 interface,这就是类型安全的红利。

3.3 挂载 Router 到根路由

写好的 Router 要挂载到根路由里,才能在客户端被调用。在src/server/api/root.ts中,把snippetRouter放进去:

import { snippetRouter } from "~/server/api/routers/snippet"; export const appRouter = createTRPCRouter({ snippet: snippetRouter, }); export type AppRouter = typeof appRouter;

注意最后一行导出的AppRouter类型。这个类型会作为 tRPC 前后端的“契约”,前端所有的 Hooks 都基于它推断返回类型和入参类型。如果这里漏了export type,客户端就没法获得类型提示。

挂载完之后,我习惯先执行一次迁移,把表结构同步到本地数据库:

npx prisma db push

db push适合开发阶段快速同步 schema。如果你用的是 Postgres 并且已经上了生产,那最好用prisma migrate dev生成迁移文件,而不是直接 push。这是两种场景的管理方式,本地怎么快怎么来,生产可不行。

4. 前端页面与交互实现

4.1 App Router 下的页面结构

t3code 的页面结构简单直接,全部放在src/app目录下:

  • src/app/page.tsx:首页,展示全部代码片段。
  • src/app/new/page.tsx:新建片段页面。

App Router 目录结构就是 URL 结构。new文件夹对应的就是/new路径,不需要额外配置路由表。这种约定优于配置的设计对于小项目非常友好,少一个路由配置文件,就少一处出错的可能。

首页布局用 Tailwind 写起来很快。我这儿给一个最直接的骨架思路:

<div className="mx-auto max-w-4xl px-4 py-8"> <header className="mb-8 flex items-center justify-between"> <h1 className="text-2xl font-bold">t3code 片段库</h1> <Link href="/new" className="rounded-md bg-blue-600 px-4 py-2 text-white"> 新建片段 </Link> </header> <div className="grid gap-4"> {snippets.map((snippet) => ( <div key={snippet.id} className="rounded-lg border p-4 shadow-sm"> {/* 片段内容 */} </div> ))} </div> </div>

Tailwind 的优势在这个时候体现得很明显:你不用切到 CSS 文件去给类名命名,所有的间距、圆角、阴影、字号全部在模板里用 class 完成。对于一个人维护的项目,这套工作流省下来的时间很可观。

4.2 用 tRPC Hooks 连接前后端

前端调用 tRPC 接口的方式非常简单。在page.tsx里加上"use client",然后用api这个自动生成的 Hooks 对象:

"use client"; import { api } from "~/trpc/react"; export default function HomePage() { const { data: snippets, isLoading } = api.snippet.getAll.useQuery(); if (isLoading) { return <div className="p-8">加载中...</div>; } return ( // 渲染 snippets ); }

这里有个关键边界问题:tRPC 的 Hooks 依赖 React Query,而 React Query 的缓存存在于客户端,所以使用useQuery的组件必须标记为 Client Component。如果你放在 Server Component 里,会直接报错useQuery is not a function或者类似的水合错误。

新建页面的提交逻辑是:

"use client"; import { useRouter } from "next/navigation"; import { api } from "~/trpc/react"; export default function NewSnippetPage() { const router = useRouter(); const utils = api.useUtils(); const createSnippet = api.snippet.create.useMutation({ onSuccess: async () => { await utils.snippet.getAll.invalidate(); router.push("/"); }, }); function handleSubmit(formData: FormData) { createSnippet.mutate({ title: String(formData.get("title")), content: String(formData.get("content")), language: String(formData.get("language")), tags: [String(formData.get("tags"))], }); } return ( <form action={handleSubmit} className="mx-auto max-w-2xl space-y-4 p-8"> {/* 表单字段 */} </form> ); }

这段代码里最重要的就是invalidate()方法。它是 React Query 的缓存失效机制,我会在创建成功后调用它,让之前缓存的片段列表强制重新请求。这一步保证了返回首页后,新创建的片段立刻出现在列表顶部,不需要手动刷新浏览器。我在没有了解这个机制之前,曾经用别的方式绕过缓存,结果导致数据不同步,改起来比直接使用正确机制更复杂。

4.3 细节体验:语言筛选与复制按钮

首版 t3code 的体验有两个细节,我想展开讲讲。

第一个是语言筛选框。我在首页放了一个select下拉框,选择语言后只展示对应语言的片段。实现方式是在前端根据language字段过滤。虽然数据量大了之后这样做不够高效,但第一版完全够用。后续如果片段数量上万,再改成把语言参数传给后端 query 也不迟。

第二个是复制按钮。每个片段卡片右上角放了一个小按钮,点击后调用浏览器的剪贴板 API:

async function handleCopy(content: string) { await navigator.clipboard.writeText(content); alert("已复制到剪贴板"); }

这个 API 在 HTTPS 环境下才能正常工作,本地开发时 localhost 不受影响。如果你部署到 HTTP 的测试环境,复制功能可能静默失败,这一点建议提前考虑到。

代码高亮我暂时没有上重型库。第一版直接用<pre><code>加 Tailwind 的灰底样式就够用。如果你想让 t3code 更完整一点,可以研究一下 Shiki 或 Prism,它们是更常规的选择,但不要一开始引入,否则后续难度会超出主线学习目标。

5. 上线部署与常见问题排查记录

5.1 构建与部署流程

t3code 整个项目构建起来非常简单,因为 T3 Stack 默认就是围绕 Next.js 设计的,最省心的部署方式是 Vercel。

本地先验证构建是否通过:

pnpm build

如果一切正常,项目会输出.next目录。然后把代码推送到 Git 仓库,在 Vercel 里导入项目,填写环境变量即可。这里有个极其容易踩的坑:如果你还沿用本地 SQLite 的DATABASE_URL,在 Vercel 的 serverless 环境里会频繁报错,因为函数实例的文件系统是临时的,连数据库文件都写不进去。

所以上生产前,我把 t3code 的数据库切到了 PostgreSQL。操作步骤也很简单:

  1. 在 Vercel 的 Storage 模块创建一个 Postgres 数据库。
  2. 把生成的连接串填到项目环境变量里。
  3. 本地执行npx prisma migrate deploy,让生产库把表结构同步上。
  4. 重新部署,验证读写正常。

如果不想用 Vercel 的 Storage,也可以用任何云数据库服务的 Postgres 实例,只要连接串正确就没问题。

5.2 典型问题速查表

我在开发 t3code 的过程中,遇到过一些非常磨人的问题,这里整理成表格,方便你直接对照。

问题现象主要原因解决方法
npx prisma db push失败,报数据库连接错误SQLite 文件路径不对,或数据库文件被占用检查.env里的DATABASE_URL,确认为file:./db.sqlite,关闭其他数据库管理工具
前端调用api.snippet.create时无类型提示类型没更新,dev server 缓存了旧类型重启 dev server,或重新执行pnpm dev,让 tRPC 重新生成类型
Tailwind 样式没生效,页面看着像无样式 HTMLTailwind 的内容路径没配置在tailwind.config.ts里确认content包含src/app/**/*.{tsx,ts}
创建片段成功后列表不刷新没有调用utils.snippet.getAll.invalidate()在onSuccess回调里执行缓存失效操作
部署到 Vercel 后,写入数据成功但读取时数据丢失SQLite 无法用于持久化存储切换数据库为 PostgreSQL
Next.js 报错SerializableState或者水合不匹配Server Component 和 Client Component 混用,渲染结果不一致检查页面是否用了"use client",确认不会把 Date 对象直接传给组件
tRPC 报No "create" exists in the union of ...类型不匹配,Router 路径不对或 ctx.prisma 访问失败核对root.ts挂载名,确保与前端调用名一致

这些问题中,最让我记忆深刻的是第二个。tRPC 强类型虽然爽,但它依赖类型生成器在 dev 运行时动态更新。如果你像我一样经常改 Router 签名,建议改完之后等 dev server 自动 reload 几秒,再去写前端代码,不然编辑器会给你错误的红色波浪线,容易造成误判。

5.3 避开 T3 Stack 的三个隐藏坑

第一个坑是 React Server Component 和 Client Component 的边界。tRPC Hooks 只能在 Client Component 里反复强调,但仍然有人会下意识在默认的 Server Component 页面里写useQuery。我建议从一开始就把页面根部标记为"use client",这样才能避免水合错误。

第二个坑是 Prisma 在 serverless 平台的连接数问题。Vercel 的 serverless 函数按需创建,如果用 Postgres,每个函数实例都会尝试建立数据库连接,连接池很快被打满。解决方式是在server/db.ts里使用 Prisma 官方推荐的加速连接方案,或者给数据库加一个连接池代理。这个问题在个人项目里不明显,但流量稍高就会爆发。

第三个坑是升级依赖时要谨慎。T3 Stack 是多个框架的叠加,任何一方升级大版本都可能牵一发动全身。比如 Next.js 升级大版本后,tRPC 适配器可能不兼容;Prisma 升级大版本后,生成的 Client 类型缓存要重新构建。我后期吃了一次升级的亏之后,形成了自己的习惯:小版本升级直接上,大版本升级先查 Upstream 的兼容性说明,绝不盲目执行pnpm update。

最后再分享一个小技巧

我在实际维护 t3code 的过程中,最受用的是把整个项目拆成“可复现的最小步骤”来做:先让数据库读写通,再让 tRPC 接口通,最后才碰页面样式。看起来多花时间,但每一步出了问题都能立刻定位。

还有一个更小但很实用的细节,就是给每个 Router 都加一个getLatest之类的简单 query,作为“探针接口”。前端联调时如果什么数据都拿不到,先用这个探针确认基础链路是否正常,能省掉很多无意义排查。

如果你自己也在搞类似的 T3 Stack 项目,建议先不要急着堆功能,把一个最简单的 CRUD 流程跑通,比任何教程都能让你更快理解这套组合的打法。t3code 会持续迭代,后续我计划加上全文搜索和用户区分,但这都是把根基打牢之后的事。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进