☰
T3 Stack全栈实践:用TypeScript打造类型安全的代码片段管理工具
2026/10/26 8:45:15 网站建设 项目流程

去年我整理硬盘,把一堆“烂尾仓库”拎出来晒了晒,其中一个叫 t3code 的项目让我有点恍惚:当初只是想验证一下“端到端类型安全到底能省多少事”,结果做着做着就变成了一个能提交到生产环境跑的全栈代码片段管理工具。所谓 t3code,简单说就是围绕 T3 Stack(Next.js + TypeScript + Tailwind CSS + tRPC)搭建的一套轻量级 Web 应用,核心功能是让开发者快速收藏、归类、检索自己的代码片段,顺手把认证、数据库读写、API 层全部串在一起。

如果你正准备进入全栈开发,或者已经在 Next.js 里折腾了一阵子但总觉得前后端类型各管各的,那么这个项目的思路会很对胃口。它不追求花哨,只解决一个具体问题:如何用一套技术栈把数据库、服务端逻辑和 React 界面之间的类型缝隙补上。下面我会从选型、数据模型、tRPC 路由、认证、部署到各类奇怪的报错,完整复盘整个落地过程。

1. 项目整体设计与选型思路

1.1 为什么选择 T3 Stack

先说结论:T3 Stack 不是某个框架,而是一组以 TypeScript 为绝对核心的最佳实践集合。它把 Next.js、tRPC、Tailwind CSS、Prisma 和 NextAuth 塞进同一个开发范式里,让数据从数据库到 UI 全程保持类型一致。这套东西背后有一个很实在的痛点——传统全栈项目里,后端接口和前端调用永远像两个讲着不同方言的人,前端写axios.get('/api/snippets')的时候,根本不知道返回的到底是{ data: ... }还是别的什么结构,只能靠手写 interface 或者 Postman 里的 JSON 去猜。

用 t3code 之后你会发现,几乎不需要再写接口文档,也不用手动定义返回类型。比如我在tRPC路由器里定义一个查询片段列表的 procedure,前端直接 import 这个 procedure,TypeScript 会自动把返回类型推导出来,字段名写错、类型写错,在编译阶段就报红,根本不给你运行时 debug 的机会。这种体验带来的效率提升,比我想象中还要明显。

选这套技术栈还有一个现实原因:团队协作时,最怕“约定式对接”。A 觉得接口返回code,B 觉得应该是status,两边各退一步改成statusCode,结果下游又有人写成status_code。T3 的类型安全能直接把这种低级错误消灭在编译器里,哪怕只有一个人开发,也能省掉大量自我怀疑的时间。

1.2 需求梳理与技术栈映射

t3code 的需求非常明确:用户注册登录、创建代码片段、给片段打标签、按语言筛选、一键复制。对标到技术栈里,几乎是一一对应的:

  • 用户认证用 NextAuth.js 的 Credentials 方案,搭配 Prisma 存储用户表和 session。
  • 数据库用 SQLite 起步,因为本地开发零配置;等部署到服务器时再切换 PostgreSQL,Prisma schema 里只需改 provider 和连接串。
  • 后端逻辑全部走 tRPC,不再额外创建 REST API,因为 tRPC 本身就是通过 HTTP 传输的函数调用,体积小、类型强。
  • 界面用 Tailwind CSS,Fast Refresh 体验好,写 UI 时不用纠结类名嵌套,顺手加了 dark mode 支持。
  • 表单校验用 Zod,既能做服务端输入校验,又能被 tRPC 自动推断出前端类型。

你可能觉得需求这么简单,没必要上 Prisma 或者 tRPC。但真正的价值不在于功能复杂,而在于把整条链路的类型打通。比如从 Prisma 查询出来的 snippet 对象,会自动带上createdAt、updatedAt这样的字段,tRPC 的 output 校验可以精确剥离掉不需要返回的敏感字段,前端拿到的类型永远和实际数据结构吻合。这个收益在小项目里看不明显,一旦功能变多,组件层级变深,优势就会被放大。

2. 基础设施搭建与开发环境配置

2.1 初始化项目与目录规划

初始化直接用了create-t3-app,命令一行搞定:

pnpm create t3-app@latest t3code

交互式选项里,我勾选了 Next.js App Router、TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth,数据库选 SQLite。这样生成的目录结构非常规整:

t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ # Next.js App Router 页面 │ ├── pages/api/ # NextAuth 等 API 路由 │ ├── server/ │ │ ├── api/ # tRPC router 定义 │ │ ├── auth/ # NextAuth 配置 │ │ └── db.ts # Prisma 客户端单例 │ └── trpc/ │ ├── client.ts │ └── server.ts

有一个很容易踩的坑:不要手动改生成器里的provider = "prisma-client-js"去兼容旧习惯。最新版 Prisma 默认使用 Prisma Client 生成方式,如果你自己改成别的,跑prisma migrate时会产生一堆莫名其妙的问题。我一开始没用官方生成器,结果客户端和服务端引用的 PrismaClient 类型不匹配,tRPC 的 context 写入后查不到字段,排查了大半天才反应过来是生成策略不一致。

2.2 数据库模型设计(Prisma schema)

t3code 的 schema 设计很直接,核心就是用户、代码片段、标签、收藏关系。我当时写的 schema 大概长这样:

model User { id String @id @default(cuid()) name String? email String @unique emailVerified DateTime? passwordHash String? snippets Snippet[] favorites Favorite[] accounts Account[] sessions Session[] } model Snippet { id String @id @default(cuid()) title String content String language String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt author User @relation(fields: [authorId], references: [id], onDelete: Cascade) authorId String tags SnippetTag[] favorites Favorite[] } model Tag { id String @id @default(cuid()) name String @unique snippets SnippetTag[] } model SnippetTag { snippetId String tagId String snippet Snippet @relation(fields: [snippetId], references: [id], onDelete: Cascade) tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) @@id([snippetId, tagId]) } model Favorite { userId String snippetId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) snippet Snippet @relation(fields: [snippetId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) @@id([userId, snippetId]) }

细节上要注意三件事:第一,所有主键都用 CUID 而不是自增整数,避免并发环境下猜测业务量;第二,外键全部加onDelete: Cascade,删除用户时不会留下孤儿数据;第三,多对多关系最好用显式中间表,比如SnippetTag和Favorite,虽然 Prisma 支持隐式多对多,但显式表后续可以扩展字段(比如收藏时间),也更方便做验证。

写完 schema 后跑一次迁移:

npx prisma migrate dev --name init

这里我要提醒一句:如果你的项目之前跑过测试数据,迁移时经常会出现“外部键约束失败”。原因是你改 schema 后某些关联关系变了,旧数据不满足新约束。最稳妥的方法是先备份数据,再执行migrate reset,不要怕把数据清空,开发阶段数据本来就是临时产物。

2.3 开发环境与 Mock 数据

开发阶段我写了一个简单的seed.ts,用 Prisma 往本地库里插入几十条各个语言的片段,方便前端联调。重点在于,seed 数据要能覆盖边界情况:超长代码、空内容、同标题片段、无标签片段,这样开发页面时才能保证 UI 的容错性。

mock 数据里我故意放了几个字段不全的记录,比如没有language的旧片段,前端渲染时就要做默认值兜底。这个细节直接暴露了 tRPC 的一个优势:如果你在服务端 procedure 里加了z.object({ language: z.string() })这样的输出校验,旧数据会直接报类型错误,逼着你尽快清理数据,而不是让脏数据悄悄流到页面上。

3. 核心功能实现路径

3.1 搭建 tRPC 路由

t3code 的 API 层全部由 tRPC 承担,根路由统一通过createTRPCRouter聚合。一个典型的 snippet 查询 procedure 长下面这样:

import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "../trpc"; export const snippetRouter = createTRPCRouter({ list: publicProcedure .input( z.object({ language: z.string().optional(), tag: z.string().optional(), search: z.string().optional(), }) ) .query(async ({ ctx, input }) => { return ctx.db.snippet.findMany({ where: { ...(input.language ? { language: input.language } : {}), ...(input.tag ? { tags: { some: { tag: { name: input.tag } } } } : {}), ...(input.search ? { OR: [ { title: { contains: input.search } }, { content: { contains: input.search } }, ], } : {}), }, include: { author: { select: { name: true, email: true } }, tags: { include: { tag: true } }, }, orderBy: { updatedAt: "desc" }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1), content: z.string().min(1), language: z.string().default("plaintext"), tags: z.array(z.string()).default([]), }) ) .mutation(async ({ ctx, input }) => { const tagObjs = await Promise.all( input.tags.map((name) => ctx.db.tag.upsert({ where: { name }, update: {}, create: { name }, }) ) ); return ctx.db.snippet.create({ data: { title: input.title, content: input.content, language: input.language, authorId: ctx.session.user.id, tags: { create: tagObjs.map((tag) => ({ tagId: tag.id })), }, }, }); }), });

这里有个非常关键的体会:tRPC 的 input 校验不是摆设。你可以在z.string()上继续叠加.max(5000)这类限制,这样前端表单提交超长内容时,tRPC 会直接返回校验错误,接口层根本不会打到数据库。以前写 REST 接口,我总要在 controller 里手动写 if-else 判断字段长度,现在这些逻辑全部收敛到 Zod schema 里,代码量和出错概率一起降下来了。

还要注意protectedProcedure的用法。它和publicProcedure唯一的区别是会在校验前先检查 session 是否存在,如果用户没登录,tRPC 会抛回一个UNAUTHORIZED错误。这比在业务代码里手动写if (!ctx.session) return null要优雅得多,而且前端也能通过错误码自动跳转登录页。

3.2 实现认证与权限控制

t3code 的认证用的 NextAuth.js,Credentials provider 加 Prisma adapter。配置大概是:

export const authOptions: NextAuthOptions = { adapter: PrismaAdapter(prisma), providers: [ CredentialsProvider({ name: "credentials", credentials: { email: { label: "邮箱", type: "email" }, password: { label: "密码", type: "password" }, }, async authorize(credentials) { const user = await prisma.user.findUnique({ where: { email: credentials?.email }, }); if (!user) return null; const valid = await bcrypt.compare(credentials!.password, user.passwordHash!); return valid ? user : null; }, }), ], callbacks: { session({ session, user }) { if (session.user) session.user.id = user.id; return session; }, }, };

这里我踩过一个比较隐蔽的坑:如果不用 Prisma Adapter,而是自己建 Session 表,那么sessioncallback 里的 user 参数可能拿不到完整的用户对象,后续在 tRPC context 里取session.user.id时会拿到undefined,一旦某个 procedure 需要按用户过滤数据,页面就会出现“明明登录了却什么都查不到”的诡异现象。解决办法就是使用官方 Adapter,并确保 Prisma schema 里有Account、Session这些模型。

另外,密码加密建议用 bcrypt,成本因子设定为 10 左右足够。不要觉得后端加密是小事,直接把用户密码明文存进数据库的案例我见过太多,这种错误一旦上线就是事故。哪怕只是个人项目,也应该从第一天就养成正确的安全习惯。

3.3 前端页面与交互(Tailwind)

前端部分我没有引入组件库,全部用 Tailwind 手搓。原因很简单:t3code 的界面复杂度不高,组件库反而会带来样式覆盖和打包体积的问题。用 Tailwind 的 Atomic CSS 方式,写出来的类名虽然看着多,但每个类都是最终样式的一部分,很容易通过条件渲染控制状态,比如:

<div className={cn( "rounded-lg border p-4", snippet.language === "typescript" ? "border-blue-300 bg-blue-50" : "border-gray-200 bg-white" )}>

配合@tailwindcss/typography插件展示代码块非常方便,代码内容用<pre>包裹,再套一层proseclass,代码样式基本不用手动调。如果你要在页面里高亮代码语法,推荐 lightweight 的react-syntax-highlighter,或者直接用 Prism 的轻量封装,别一上来就引 monaco-editor,那可是个几百 KB 的大块头。

交互上最麻烦的是标签输入。我实现了一个简单的标签编辑框:用户输入标签名后回车,自动把它添加到列表里并显示成一个小胶囊。这个交互用纯 React 状态就能搞定,不需要额外库。但要注意状态同步问题:如果标签列表是被 tRPC 的 query 返回的,那么添加完标签后,必须用utils.snippet.invalidate()重新拉取数据,否则标签数据停留在缓存里,体验很割裂。

const utils = trpc.useUtils(); await createSnippet.mutateAsync(data); await utils.snippet.invalidate();

这行invalidate是 tRPC 客户端里最常用的缓存刷新手段,习惯之后,你会离不开它。

4. 部署上线与性能优化

4.1 构建生产版本与 Docker 部署

t3code 上线时,我选择用 Docker 打包 Next.js 服务加 PostgreSQL 数据库。容器化最大的好处是把环境差异隔离掉,不会出现“本地跑得好好的,服务器上起不来”的尴尬。我的Dockerfile大概长这样:

FROM node:20-alpine AS base FROM base AS deps WORKDIR /app COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile FROM base AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN pnpm db:generate RUN pnpm build FROM base AS runner WORKDIR /app ENV NODE_ENV=production COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static EXPOSE 3000 CMD ["node", "server.js"]

这里值得注意的坑是:Next.js 的 standalone 模式必须在next.config.mjs里显式开启:

const nextConfig = { output: "standalone", }; export default nextConfig;

不开这个配置,node server.js根本起不来。另外,Prisma Client 需要在构建阶段生成,也就是在pnpm build之前执行prisma generate。很多第一次部署的人会漏掉这步,导致容器内找不到 Prisma Client 的二进制文件,一启动就报PrismaClientInitializationError。

Docker 部署的数据库连接串建议通过环境变量注入,不要写死在镜像里。我在docker-compose.yml里用 POSTGRES_USER、POSTGRES_PASSWORD 等环境变量来配置,然后用一个 init 容器跑prisma migrate deploy,确保表结构先建好再启动应用。

4.2 常见性能坑与优化手段

全栈应用最容易出现的性能问题集中在 N+1 查询上。以片段列表页为例,如果你在 tRPC 的 query 里只用了findMany没有 include 关联数据,前端组件里再依次拿snippet.author.name,那每渲染一条 snippet 都会触发一次数据库查询,列表一长就会卡。正确做法是在 Prisma 查询里提前 include:

include: { author: true, tags: { include: { tag: true } }, }

这样一次查询就能把需要的数据全部加载出来,实测下来列表的响应时间从 500ms 级降到 50ms 级,体感很明显。

另一个优化点是 Next.js 的动态渲染与静态渲染的取舍。t3code 里大部分页面依赖用户登录状态,不适合做成纯静态页面。但片段列表其实可以做成“按语言缓存”的静态路径,配合revalidate增量更新。我是这样处理的:列表页继续用服务端渲染,但同时在 tRPC 端给 procedure 加了staleTime缓存,避免用户频繁翻页时大量重复请求数据库。

const listQuery = trpc.snippet.list.useInfiniteQuery( { language, tag }, { getNextPageParam: (lastPage) => lastPage.nextCursor, staleTime: 30_000 } );

4.3 环境变量与安全配置

部署时最容易忽略的就是环境变量的管理。Next.js 项目里.env文件不要提交进 Git 仓库,必须在.gitignore里忽略掉。我在 t3code 中使用了三种环境:开发环境.env、生产环境实际环境变量、GitHub Actions 里的 Secrets。几个关键变量:

  • DATABASE_URL
  • NEXTAUTH_SECRET
  • NEXTAUTH_URL

NEXTAUTH_SECRET一定要设置,否则 NextAuth 会在控制台打警告,并且在生产环境 JWT 签名可能不稳定。生成一个强随机串很简单:

openssl rand -base64 32

如果你用的是内置 Credentials provider,务必在环境变量里禁止明文密码日志,同时设置登录限流策略。个人项目虽然不面临大规模攻击,但写好这些安全细节,以后接团队项目时习惯已经养成。

5. 常见问题与排查技巧实录

5.1 类型不匹配的几类报错

tRPC 虽然类型很爽,但接错类型时编译器报错也很酸爽。我最常碰到的是三类:

第一类,服务端返回类型和前端 useQuery 的泛型不匹配。原因是你在createTRPCRouter里用了any返回值,或者 Prisma 查询里用了include导致返回类型变复杂,而前端某个组件里手写了 interface 覆盖。解决办法永远只有一个:让 TypeScript 自动推导,不要自己手动定义返回类型。

第二类,session 用户类型不对。NextAuth 默认的session.user没有id字段,你在 procedure 里取ctx.session.user.id时会报“类型上不存在属性 id”。需要做一个 Session 类型扩展:

declare module "next-auth" { interface Session { user: { id: string; name?: string | null; email?: string | null; }; } }

第三类,Zod 输入类型和 Prisma 查询条件不匹配。比如language字段在数据库里是可空字符串,但 Zod 输入时设成了必填,传到findMany的 where 里就会报类型不能分配给StringFilter。遇到这种情况,我会选择在 Zod 上允许空串,再由服务端逻辑把空串转化成undefined,保持数据库边界类型兼容。

5.2 会话过期与数据不一致

用户登录一段时间后操作,发现页面跳回登录页,这是 NextAuth 的 session 过期策略在起作用。默认有效期是 30 天,但如果你在 tRPC context 里读取 session 的方式不统一,会导致部分 procedure 认为用户未登录。

我当时遇到过这样一个诡异故障:有的 procedure 从ctx.auth取 session,有的直接从req.session拿,两边读到的 session id 不一致,结果同一个用户同一时刻,一个接口认为已登录,另一个接口却返回 401。排查到最后,发现是我在服务端初始化 tRPC 时,没有统一从 NextAuth 的getServerSession获取 session,而是混用了两种方案。修法是写一个统一的 context 工厂:

export async function createTRPCContext(opts: CreateNextContextOptions) { const session = await getServerSession(authOptions); return { db: prisma, session, }; }

这样所有 procedure 拿到的 session 都是同一个来源,再也不会出现“一半接口认为登录了”的怪状态。

5.3 一些小而有用的避坑清单

除了上面两条,我还整理了一份日常开发很实用的避坑清单,照着检查能省不少时间:

  • Prisma 变更后一定先执行prisma generate,再重启开发服务器,否则 tRPC 里引用的 PrismaClient 类型还是旧版本,怎么改代码都不生效。
  • tRPC 的 procedure 名称不能重复,而且建议按资源分组命名,比如snippet.list、snippet.create,否则前端调用时很容易引用错方法。
  • Tailwind 的热更新偶尔会不生效,这通常不是代码问题,而是进程缓存,删掉.next目录重启就能解决。
  • 部署 PostgreSQL 后,注意DATABASE_URL里要带 SSL 参数,比如?sslmode=require,否则数据库服务商可能会拒绝连接。
  • 别在客户端组件里直接 import 服务端代码。Next.js App Router 下,只要组件里引用了@server/db这类模块,构建时就会报“客户端不可用”错误,应该通过 tRPC 调用,而不是直连数据库。
  • tRPC 的 ErrorFormatter 一定要配置,否则前端拿到的错误信息是默认的英文格式,排查起来费劲。我当时加了一层统一格式化,把UNAUTHORIZED转成中文“登录已过期,请重新登录”,体验好很多。

如果你认真把这些坑实打实踩一遍,做出一副“我从零跑通 T3 Stack 全栈项目”的实践记录,你会发现,t3code 真正值钱的地方并不是那个代码片段管理工具本身,而是它逼着你把“类型安全”四个字从口号变成了肌肉记忆。我自己最大的感受是:以前写完后端接口总还要再写一份接口文档,写完接口文档还担心前端联调时对不上字段;现在这些焦虑基本消失了,因为编译器已经把能抓出来的错全替我抓完了。

最后再分享一个后续可以扩展的方向:给 t3code 加上 Markdown 渲染和 AI 自动打标签的能力。代码片段的 content 大多是不规则文本,用 LLM 提取语言类型和语义标签,比手动打标签省力得多。你可以在 tRPC 的 procedure 里接入一个异步任务队列,把 AI 调用变成一个 mutation 请求,前端提交完代码后,后台自动补齐标签,这也是 TypeScript 全栈项目里一个很有意思的玩点。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询