☰
T3 Stack全栈实践:用Next.js、tRPC和Prisma构建代码片段管理工具
2026/10/26 8:42:23 网站建设 项目流程

1. t3code 到底是个什么项目

写代码这些年,我电脑里散落着几十上百个临时写出来的片段:一段正则、一个递归函数、某个库的用法示例、一坨处理数据的冷门写法。今天写出来明天就想不起来放哪了,微信文件传输助手和备忘录都已经囤成了垃圾堆。这就是我动手做 t3code 的原因——一个面向开发者自己的、轻量的代码片段管理与分享工具。

t3code 这个项目,简单说就是一套全栈 Web 应用:你可以把代码片段按语言、标签、可见性管理起来,支持语法高亮、在线分享、收藏和搜索。它解决的问题非常具体——程序员需要一个比记事本靠谱、比团队 Wiki 轻得多的地方,来存放那些“不值一提但下次还会用”的代码。它适合所有前端、后端、全栈工程师拿来即用,也适合正在学 TypeScript 和全栈开发的朋友当作一个完整项目的参考样本。

整个项目的核心吃的是 T3 Stack 这一套组合拳:Next.js 做应用框架,TypeScript 贯穿前后端,tRPC 写 API,Prisma 操作数据库,Tailwind CSS 负责样式。当初选这组技术栈就是冲着“端到端类型安全”去的——前端调接口的时候,类型直接能推断出来,不需要维护两份类型定义,不用纠结接口文档过期的问题,这在做这种以 CRUD 为主的中小型工具时体验是真的好。

2. 技术选型解析:为什么把宝押在 T3 Stack 上

2.1 这五个组件分别干了什么活

T3 Stack 不是某个单独的框架,而是一套互相咬合的技术组合。我先用大白话把这几个角色捋一遍。

Next.js 是整个应用的地基。它负责页面渲染、路由、SSR/SSR 水合这些事。Next.js 的 App Router 把前端页面拆成了 Server Component 和 Client Component,哪些内容在服务器上渲染、哪些在浏览器里跑,代码里直接标出来,对开发者非常友好。比如 t3code 的片段列表页适合在服务端渲染,方便搜索引擎收录公开页面;而编辑器页面必须放在客户端,因为它要处理实时的输入状态和语法高亮。

TypeScript 不用多说,给 JavaScript 套上了一层类型约束。它最大的价值不光是写代码的时候有自动补全,而是当你修改数据模型时,编译器能帮你在第一时间找出所有受影响的地方。

tRPC 是这套组合里最亮眼的角色。它和 REST、GraphQL 都不一样——不需要 URL 路由、不需要接口文档、不需要代码生成器。你在服务端定义一个查片段的 procedure,前端直接就能拿到类型安全的调用函数。一旦数据结构变了,前后端所有用了这个数据的地方,编辑器里立刻标红。这对一个前后端都由自己维护的项目来说,省掉的不只是写文档的功夫,是整个联调阶段的心智负担。

Prisma 负责数据库访问。它在 schema 文件里定义数据模型,然后自动生成类型安全的客户端代码。和手写 SQL 相比,Prisma 的查询 API 更接近写代码而不是拼字符串;和 TypeORM 相比,Prisma 的 schema 声明式写法更直观,迁移工具也更省心。

Tailwind CSS 则是样式层的答案。原子化的工具类写法,不需要起类名、不需要切 CSS 文件,改样式直接在 JSX 里改类名。对这种工具型应用来说,开发速度是第一位的,Tailwind 在这方面简直是指哪打哪。

2.2 为什么不用 REST 或 GraphQL

做 t3code 之前我犹豫过要不要用常规方案:Express 写 REST API,或者用 GraphQL 上 Apollo。后来都否了。

REST 的问题不在于过时,而在于类型割裂。前端要拿到/api/snippets返回的数据结构,只能靠打开浏览器看响应,或者祈祷接口文档没过期。一旦字段改了名,前端跑起来才发现报错,而这种低级问题本来在写代码的时候就能被类型系统拦住。

GraphQL 解决了一部分类型问题,但它引入了额外的复杂度:要写 schema、写 resolver、处理缓存策略。一个小团队维护一个小工具,给每个查询写 resolver 和 memoization,真的有点大炮打蚊子。

T3 Stack 的哲学是“类型安全优先”。tRPC 让前端调用后端像调用本地函数一样,前提是你接受了它“内部魔法多一点”这个代价。实际体验下来,t3code 里重构一个字段时,改动量从“改后端 + 改前端 + 改文档”变成了“改 schema + 改一处 API 定义”,而所有用到它的前端的类型引用错得一行不差。

2.3 这个选型有没有踩坑的地方

也不能光夸。tRPC 目前的情况是 v10 和 v11 两个大版本并存,网上大量教程还停留在 v10 的写法,而 create-t3-app 最新版已经切到 v11。如果你按老教程抄代码,会碰到createTRPCNext变成createTRPCReact、initTRPC.create()的中间件写法调整这些变化。我在项目初期就踩过一次这个坑,后面章节会专门讲怎么排查。

另一个要接受的点是:T3 Stack 偏“全栈同构”思路,如果以后想把 API 层独立出来暴露给第三方应用,tRPC 的 HTTP 接口没有 REST 那么容易消费。不过 t3code 是纯自用工具,这个代价完全可以接受。

3. 核心功能拆解与数据库设计

3.1 这个工具需要哪些功能

代码片段管理工具,光能存代码是不够的。我在整理需求阶段把所有能想到的场景写了下来,最后收敛成下面这组核心功能:

  • 创建片段:填写标题、内容、编程语言、标签,选择可见性(公开 / 私密)。
  • 片段列表:按语言筛选、按标签筛选、按关键词搜索。
  • 片段详情页:语法高亮展示,一键复制,支持复制直链分享给同事。
  • 收藏机制:把别人的公开片段收藏到自己名下,方便回看。
  • 用户系统:登录后才能创建,但不登录也能浏览公开片段。

这个功能列表一点都不花哨,但它覆盖了“存代码”这个主场景身边的所有延伸需求。做完之后你会发现,工具类应用最重要的不是功能多,而是每个功能都做得顺手。

3.2 Prisma 数据模型是怎么设计的

数据库用 SQLite 起步,后期要迁 PostgreSQL 也简单,Prisma 换一下 provider 再处理下类型差异就行。我定义的模型大致如下:

model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? snippets Snippet[] favorites Favorite[] } model Snippet { id String @id @default(cuid()) title String content String language String @default("plaintext") visibility String @default("public") tags String authorId String author User @relation(fields: [authorId], references: [id]) favorites Favorite[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Favorite { userId String snippetId String user User @relation(fields: [userId], references: [id]) snippet Snippet @relation(fields: [snippetId], references: [id]) createdAt DateTime @default(now()) @@id([userId, snippetId]) }

这里有几个细节值得展开说。

tags字段我用的是逗号分隔的字符串,而不是建第三张关联表。按常规关系型数据库的规范做法,标签应该拆成 Tag 模型加多对多关系。但对于个人工具来说,这个设计的额外复杂度完全不划算。字符串存标签,搜索时用contains就行,展示时拆个数组就行。等你真的碰到一次需要按标签做复杂聚合统计的时候,再迁过去也不迟。

visibility字段用字符串而不是布尔值,是为了留扩展余地。“公开 / 私密”现在就两种状态,但是以后可能加“仅链接可见”,那布尔值就不够用了。

Favorite用联合主键@@id([userId, snippetId])保证同一个人不能重复收藏同一个片段,这属于在数据层就把约束写死,而不是等代码里 if 判断。

3.3 tRPC Router 层的组织方式

tRPC 的 Router 我按业务模块拆成三个文件:userRouter、snippetRouter、favoriteRouter,然后在根 router 里合并。标准做法如下:

import { router } from "../trpc"; import { snippetRouter } from "./snippet"; import { userRouter } from "./user"; import { favoriteRouter } from "./favorite"; export const appRouter = router({ snippet: snippetRouter, user: userRouter, favorite: favoriteRouter, }); export type AppRouter = typeof appRouter;

snippetRouter内部的核心 procedure 大致长这样:

export const snippetRouter = router({ list: protectedProcedure .input( z.object({ query: z.string().optional(), language: z.string().optional(), visibility: z.enum(["public", "private"]).optional(), }) ) .query(async ({ ctx, input }) => { return ctx.prisma.snippet.findMany({ where: { authorId: ctx.session.user.id, ...(input.language ? { language: input.language } : {}), ...(input.query ? { OR: [ { title: { contains: input.query } }, { content: { contains: input.query } }, ], } : {}), }, orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1), content: z.string().min(1), language: z.string().default("plaintext"), visibility: z.enum(["public", "private"]).default("public"), tags: z.string().default(""), }) ) .mutation(async ({ ctx, input }) => { return ctx.prisma.snippet.create({ data: { ...input, authorId: ctx.session.user.id, }, }); }), });

注意每个输入都用 Zod 做了校验。很多人嫌多写一行校验麻烦,但 Zod 在 tRPC 里不只是校验,它还承担了“类型推导来源”的角色。前端调用trpc.snippet.create时,编辑器能根据这里的 Zod schema 直接推断出入参应该长什么样,弹窗自动提示字段名和类型。这就是“端到端类型安全”的体验,不是写在文档里的口号。

4. 实操过程:从零把 t3code 跑起来

4.1 初始化项目和关键配置

新建项目的命令是:

npx create-t3-app@latest t3code

脚手架会让你选择要装哪些模块。我勾了 Next.js、TypeScript、Prisma、tRPC、Tailwind CSS 和 NextAuth.js。这里最需要注意的是:现在的 create-t3-app 默认走 App Router,目录结构和老的 Pages Router 差很多。网上很多教程还是/pages/api/trpc、/pages/index.tsx的写法,在你这个新项目里通通不适用。

初始化完成后,第一件事是配置环境变量。.env里最重要的两个值:

  • DATABASE_URL:数据库连接串,开发环境本地 SQLite 就写file:./db.sqlite,部署到生产环境换 PostgreSQL 或 MySQL。
  • NEXTAUTH_SECRET:NextAuth 的会话签名密钥,直接用已发布版本的脚手架会在.env.example里给提示。

还有一个 dev 阶段容易忽略的配置:NextAuth 的NEXTAUTH_URL。本地没配这个值时,登录回调经常会跳到 localhost 之外的奇怪地址,我第一次跑的时候就碰见过,后面排查章节里细说。

4.2 认证与权限控制的实现细节

t3code 的登录我用的是 NextAuth.js 魔改版——create-t3-app 已经内置了对它的封装,只需要在auth.ts里配置 providers。

我配置的是 GitHub Provider 加 Credentials Provider 双通道。GitHub 登录适合生产环境,Credentials 用于本地调试,因为每次开发调试都走 GitHub OAuth 的话,重定向回调会被 GitHub 的限流折腾得够呛。

// app/api/auth/[...nextauth]/route.ts import NextAuth from "next-auth"; import GitHub from "next-auth/providers/github"; import Credentials from "next-auth/providers/credentials"; const handler = NextAuth({ providers: [ GitHub({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }), Credentials({ name: "Dev Login", credentials: { email: { label: "Email", type: "email" }, }, async authorize(credentials) { const user = await prisma.user.findUnique({ where: { email: credentials?.email }, }); return user ?? null; }, }), ], }); export { handler as GET, handler as POST };

权限控制方面,tRPC 的protectedProcedure是拦路虎。它其实就是个中间件,校验ctx.session是否存在。看了源码你就会发现它很简单:

export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new Error("Unauthorized"); } return next({ ctx: { session: ctx.session } }); });

对于“只能看自己私密片段”这种细粒度控制,我是在 query 的 where 条件里加一个判断:私密片段只对作者本人可见。

4.3 前端页面的核心实现逻辑

页面结构上,我用的是典型的 T3 分层:服务端组件负责取数据,客户端组件负责交互。

列表页app/page.tsx是服务端组件,直接调用 tRPC 的 server caller 拉取公开片段的列表:

import { api } from "~/trpc/server"; export default async function HomePage() { const snippets = await api.snippet.listPublic(); return <SnippetList snippets={snippets} />; }

而创建片段的表单和编辑器这些交互逻辑都集中在app/(editor)/components里的客户端组件中。语法高亮我选了 Shiki,因为它是 TextMate 语法加上 VS Code 主题,高亮效果和 IDE 完全一致。封装一个CodeBlock组件:

"use client"; import { useEffect, useState } from "react"; import { codeToHtml } from "shiki"; export function CodeBlock({ code, lang }: { code: string; lang: string }) { const [html, setHtml] = useState(""); useEffect(() => { codeToHtml(code, { lang, theme: "github-dark" }).then(setHtml); }, [code, lang]); return <div dangerouslySetInnerHTML={{ __html: html }} />; }

Shiki 的体积不算小,且每次调用都要解析语法文件。所以高亮逻辑放在客户端而且做了缓存——同一个片段同一个语言,只高亮一次,后续直接复用结果。这个决策在生产环境很关键,因为如果高亮放在服务端组件里,每次页面刷新都要重新跑一遍高亮,CPU 和带宽都浪费在“重复劳动”上。

4.4 部署到线上环境的完整步骤

t3code 我最后部署在 Vercel 上,数据库用了 Neon 的 PostgreSQL 免费额度。部署步骤其实相当线性:

  • 在 Neon 创建项目,拿到DATABASE_URL。
  • 把 GitHub 仓库连接到 Vercel,填好环境变量,包括DATABASE_URL、NEXTAUTH_SECRET、如用 GitHub 登录还要配 OAuth 回调域名。
  • 部署前先在本地跑一次生产构建,确认next build没报错,再推代码触发布署。

有个容易忽略的点:Vercel 构建时如果你的项目依赖 Prisma 的 postinstall 自动生成 client,一定要在postinstall脚本里加上prisma generate。否则服务器上跑的时候 Prisma 客户端根本没生成,直接报PrismaClientInitializationError。

数据库迁移也不要手动在服务器上跑prisma migrate deploy。Neon 提供了psql连接串,我本地跑npx prisma migrate deploy,把迁移 SQL 直接执行到云端数据库,然后前端应用通过连接串正常访问。整个过程不需要在 Vercel 上执行任何数据库命令。

4.5 关键参数的选择与优化

有一点必须单独拎出来说:Next.js 的缓存策略。App Router 默认情况下,服务端组件的渲染结果会被缓存。这意味着我在HomePage里调用的api.snippet.listPublic(),在正式环境可能一天都不重新执行,新创建的公开片段首页永远看不到。

解决方法是给页面指定revalidate时间,或者调用revalidatePath手动刷新。对 t3code 这种实时性要求不高的工具,我在页面里加了:

export const revalidate = 60;

一分钟刷新一次,既能保证新内容基本及时出现,又不会频繁打数据库。如果你连这分钟级延迟都不能接受,那就把列表页变成客户端组件,配合 tRPC 的useQuery走实时请求。两种取舍我都试过,最终选中间态,平衡体验和服务端负载。

5. 实战踩坑记录与排查思路

5.1 环境与版本问题速查

T3 Stack 迭代太快,最大的坑来自版本不匹配。我把这次开发中遇到的典型问题、现象和解决方案整理成了速查表。

现象根因解决方案
tRPC 客户端报createTRPCClient is not a function混用了 v10 和 v11 的 API统一为 v11:客户端用createTRPCReact,服务端用trpc/server的 caller
Prisma 部署时找不到 modelprisma generate没有在安装依赖后执行package.json 的postinstall加prisma generate
NextAuth 登录后跳回http://localhost:3000NEXTAUTH_URL没配置或配置成了旧地址在.env里明确NEXTAUTH_URL指向当前环境的域名
首页新数据不出现App Router 默认静态缓存设置revalidate或调用revalidatePath
Shiki 高亮出现白屏代码里用了dangerouslySetInnerHTML但运行时缺少 DOM确保CodeBlock组件是客户端组件,而不是服务端组件
visitor相关类型报错Prisma 生成的类型和手写的类型不一致每次改 schema 后跑prisma generate并重启 dev server

这里面最坑的是第一条。T3 Stack 的文档和社区教程一大半还停留在 tRPC v10 时代,你按老教程写createTRPCNext,结果在新项目里根本不导出这个函数。排查方法是看 package.json 里 tRPC 的版本号,如果是^11,所有 client 的构造方式都得按新文档来。这个错误看起来神秘,实际上就一句话——版本对齐。

5.2 tRPC 类型丢失的经典翻车现场

再分享一个特别典型的坑。你在服务端定义 router 后,前端组件里这么写:

const snippetQuery = trpc.snippet.getById.useQuery({ id });

trpc.snippet.getById这里的.getById在编辑器里应该有完整的自动补全和参数校验。但如果AppRouter类型没有正确传给前端的createTRPCReact泛型,你会发现.snippet下面根本不显示任何方法,全部变成.something调用然后报错。

原因出在根组件或者 API handler 的类型传递链断了。tRPC 的类型安全依赖一个类型“从服务端爆到客户端”的过程:appRouter定义后传给createTRPCReact<AppRouter>(),中间任何一个环节用了any或者没写泛型,整个链条就断了,类型推断立刻失效。排查思路就是顺着引用链往上翻,找到第一处丢失泛型的地方,把AppRouter显式补上。这是 tRPC 新手最容易蒙圈的问题,也是理解“端到端类型安全”的关键点——它不是魔法,而是你把类型像一个信号一样,从源头一直传到目的地,中间任何一环都不能断。

5.3 数据库连接与生产环境的内存坑

t3code 部署后跑了一段时间,我开始收到 Vercel 上偶发的连接超时告警。排查后发现是数据库连接池的问题:Serverless 的每个请求实例都在创建新的 PrismaClient 实例,没有复用连接。Prisma 官方文档明确建议全局单例。

但是全局单例在 Serverless 环境里有个边界:冷启动时实例可能被拉起多个,每个实例都有自己的一份连接池。Neon 的免费额度对并发连接数有限制,一并发多就容易被拒。

我的解决办法是用pg连接串参数控制连接池大小:

DATABASE_URL="postgresql://user:pass@host/db?connection_limit=5"

再配合 Prisma 的PISCINA_POOL_SIZE环境变量把连接数压到 3 以内。这个数字不是拍脑袋定的,是观察了 t3code 实际 QPS 后算出来的:单个实例同时最多也就处理 3~5 个请求,每个请求占用一个连接,压到 3 已经够用。这也顺带解决了 Neon 免费版连接数上限的硬约束。

6. 扩展方向:从工具到平台的几条路

t3code 当前这个版本基本覆盖了我最初的使用诉求。但作为博主,我习惯性地把项目往长远想了几个方向,这些念头不一定每个都落地,但确实值得记录一下。

第一个方向是加 AI 能力。代码片段的搜索目前是 PostgreSQL 的contains匹配,对模糊想法式搜索支持很差。接入 Embedding 后用向量检索,能实现“搜‘防抖函数’但匹配到debounce实现”。这就是 AI 嵌入现有工具的第一个自然切入点,不牵强、不堆砌。

第二个方向是支持团队空间。一个多人共享的代码库,按项目分组,成员之间互相评论、互相 fork 片段。这个改动会牵动整个数据模型——Snippet上要加projectId、Team和Membership两张新表。但整体架构不用大改,Prisma 的 schema migration 和 tRPC 的 router 扩展都能平滑支撑。

第三个方向是做成 Cli 工具。给 t3code 写一个t3code命令行,支持t3code add把本地文件塞进片段库、t3code get把片段拉到本地。这样它就成了一个真正的开发伴侣,而不只是网页工具。这个方向的投入产出比很高,因为一套 tRPC API 已经被前端用了,Cli 只是多一个 HTTP 客户端。

这三个方向里,我目前最认可第一个——AI 搜索。因为它的核心价值最直接:代码管理工具的价值不在于存储,而在于“需要的时候能找回来”。找不回来的代码,和不存在没有区别。把搜索做好,才是这类工具真正的护城河。

最后说一点实际体会。t3code 这个项目从初始化到第一版上线,前后用了一个长周末的时间。大多数时间没有花在写业务代码上,而是花在了“版本不对齐”和“类型断链”这种基础设施问题上。这也是我写这篇文章的原因——T3 Stack 的文档精美但分散,很多经验要自己踩过一轮才记住。你要是准备动手做一个自己的全栈小工具,希望这份踩坑记录能替你省掉几次搜索引擎来回折腾的时间,直接进入“写功能”而不是“调环境”的舒服状态。

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

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

立即咨询