ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

T3 Stack全栈实践:用tRPC+Prisma打造t3code代码片段管理工具

T3 Stack全栈实践:用tRPC+Prisma打造t3code代码片段管理工具

前阵子折腾全栈项目时,我给自己定了一个明确目标:不做那种“看起来厉害但根本没人用”的 Demo,而是从真实需求里长出一个能每天打开的工具。最后落地的项目叫 t3code。它不是一个框架,也不是什么商业平台,而是我基于 T3 技术栈亲手写的在线代码片段管理工具。T3 这个组合在 TypeScript 社区里越来越常见,通常指 Next.js、TypeScript、Tailwind CSS、Prisma 和 tRPC 这一整套全栈方案。t3code 解决的核心问题特别朴素:临时写一段代码、想加个语法高亮的分享链接、过几天还能按标签找回来,没必要为此开一个 Git 仓库,也不想把代码往聊天工具里一贴就散落各处。如果你正在研究 create-t3-app,或者想看看 tRPC 的类型安全在实际项目里到底怎么发挥作用,这篇内容会把从设计、建表、写接口到部署的完整过程拆开讲。

1. 项目设计与思路拆解

1.1 一个很具体的痛点

我在平时的工作里经常要跨团队传代码片段。最原始的方式是复制到聊天软件,对方收到之后缩进全乱,长一点的代码还会被折叠,最后往往还要截图或者重新整理一份格式。用 Git 仓库又太重,一个评估中的想法、一段报错日志、一条数据库查询,不值得为一个文件去创建仓库、写提交信息、再维护权限。市面上的在线代码分享平台也用过,但免费额度、私密限制、网络延迟这些因素让体验始终差一点。

t3code 的雏形就是在这样的背景下出现的:用户粘贴代码,系统自动生成一个短链接,打开链接就能看到带语法高亮的代码。后续我再补上标签分类、语言筛选、访问计数这些轻量功能,让它变成一个能真正每天用的小工具。这个项目承载的不只是“存代码”这个动作,更重要的是整理和检索。代码片段一旦积累多了,散落在各个聊天记录和本地文件里,最后往往是永远找不回来。有一个统一的入口,用标签和语言维度去检索,效率会高很多。

1.2 为什么选 T3 Stack,而不是自己拼一套前后端

做这种小而全的工具,技术选型最大的考量不是“性能有多极致”,而是“一个人怎么稳定维护一套全栈代码”。T3 Stack 的核心思路正好契合这个场景:整个项目就是一个 TypeScript 全栈应用,数据库、服务端接口、前端页面全在一个代码仓库里,类型可以一路打通。

如果拆成前端 Next.js + 后端 Express,中间需要定义 REST API、手写接口文档、维护两套 TypeScript 类型,还要处理 CORS。一个人做小项目最怕的就是这种重复劳动。T3 Stack 里的 tRPC 直接让前端像调用本地函数一样调用服务端逻辑,输入输出类型由 TypeScript 编译器自动推导。再加上 Prisma 根据数据库定义生成完整类型,从数据表到页面组件的类型链路就没有断层了。简单说,t3code 里改一个数据库字段,前端引用到这个地方的代码在编译期就会报错,而不是等到线上运行时才发现。

选择 T3 Stack 还有一个很现实的原因:创建项目可以通过 create-t3-app 一条命令完成,基础脚手架已经帮你把 Next.js、Tailwind、Prisma、tRPC 的工程配置全部串好。与其从零开始拼装,不如站在社区已经验证过的最佳实践上,把精力集中在业务逻辑上。

1.3 功能范围与数据模型设计

t3code 第一个版本我只保留了 MVP 功能,避免一开始就把登录、权限、团队空间这些重功能堆上来。最终确定的功能是:

  • 创建代码片段:填写标题、选择代码内容、自动或手动指定语言。
  • 生成短链接:每个片段有一个唯一且难猜的 slug。
  • 标签分类:支持添加多个标签,方便后续检索。
  • 语法高亮展示:详情页按语言类型进行高亮。
  • 访问计数:记录每个片段的查看次数。

数据模型设计上,核心是Snippet和Tag两张表。Snippet负责存代码片段本身,Tag负责分类,两者通过多对多关系关联。这样的设计能覆盖“一个片段有多个标签,一个标签下有多个片段”的检索需求。在最开始我没有把用户体系加进来,因为第一版要尽快验证核心场景;后面如果要做私密片段,再接入 NextAuth 并增加authorId字段也不迟。

2. 核心技术点解析

2.1 全栈类型安全链路到底解决什么问题

很多刚接触 T3 Stack 的人会把“类型安全”当成一种锦上添花的东西,觉得反正运行时会检查。但 t3code 的开发体验让我对这件事有了更实际的认识:传统全栈项目里,数据库字段改了,后端接口可能还能正常编译,前端却因为拿不到新字段而出现一系列运行时错误,排查链条很长。

在 t3code 里,这条链路是数据库到 Prisma Client,再到 tRPC Router,最后到 React 组件。Prisma Schema 里的字段一旦被修改,重新执行prisma generate后,全项目的 TypeScript 类型会同步更新。tRPC 的 Router 输入输出类型不需要手写,前端调用api.snippet.bySlug.useQuery()时,返回值类型就是 Prisma 查询的真实返回类型。这种“改一处,全链路报错”的体验,写小项目的时候可能感觉不到,一旦功能多起来,会帮你省很多修接口对接问题的时间。

用生活里的类比来说,这相当于你在定做家具时,所有柜子的尺寸图纸是同一个模型自动生成的,而不是靠人手工抄写。手工抄写容易漏,自动生成至少不会出现“图纸上写着 80 厘米、实际柜子做出来是 90 厘米”这种低级错误。

2.2 tRPC 的 Router 设计与 Procedure 拆分

tRPC 的核心是 Router 和 Procedure。Router 相当于把一组相关接口放在同一个命名空间里,Procedure 则分为query和mutation两类。

在 t3code 中,我创建了一个snippetRouter,把对代码片段的所有操作都放在里面。创建片段用mutation,因为它会修改数据;查询详情用query,因为它只读;增加访问次数也用mutation,虽然它只修改一个计数,但本质上依然是对数据的写操作。

import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { z } from "zod"; import { nanoid } from "nanoid"; const snippetCreateInput = z.object({ title: z.string().trim().min(1).max(100), content: z.string().trim().min(1), language: z.string().trim().min(1).default("plaintext"), tagNames: z.array(z.string().trim().min(1).max(30)).default([]), }); export const snippetRouter = createTRPCRouter({ create: publicProcedure .input(snippetCreateInput) .mutation(async ({ ctx, input }) => { const slug = nanoid(8); const tags = [...new Set(input.tagNames)].map((name) => ({ name })); const snippet = await ctx.prisma.snippet.create({ data: { slug, title: input.title, content: input.content, language: input.language, tags: { connectOrCreate: tags.map((tag) => ({ where: { name: tag.name }, create: tag, })), }, }, include: { tags: true }, }); return snippet; }), bySlug: publicProcedure .input(z.object({ slug: z.string().min(1) })) .query(async ({ ctx, input }) => { return ctx.prisma.snippet.findUnique({ where: { slug: input.slug }, include: { tags: true }, }); }), increaseViews: publicProcedure .input(z.object({ slug: z.string().min(1) })) .mutation(async ({ ctx, input }) => { return ctx.prisma.snippet.update({ where: { slug: input.slug }, data: { views: { increment: 1 } }, }); }), });

这里有两个细节值得注意。一是nanoid(8)生成的短码长度足够短,又能提供较好的随机性;二是connectOrCreate会对标签名做去重,避免同一个标签被重复创建。整个过程里我没有写任何手动类型定义,snippet这个返回值已经自动拥有了tags字段的类型,前端直接就能拿到。

2.3 Prisma 数据模型与迁移细节

Prisma Schema 是 t3code 所有数据类型的源头。我在设计表的时候没有过度规范化,也没有为了“看起来专业”而加入大量无关字段。一段代码片段需要的属性,只有内容、标题、语言、标签、访问次数和时间戳。

model Snippet { id String @id @default(cuid()) slug String @unique title String content String language String @default("plaintext") views Int @default(0) isPublic Boolean @default(true) tags Tag[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Tag { id String @id @default(cuid()) name String @unique snippets Snippet[] }

在类型选择上,id使用cuid()而不是自增整数,主要是为了防止外部通过 ID 遍历所有片段。slug是用于短链接的公开标识,所以它必须@unique。language没有做成数据库枚举,而是普通字符串。原因很简单:代码语言的种类太多,而且前端高亮库支持的语言集合和数据库枚举很难永远保持一致,不如存字符串,后续要在前端做语言选择列表时也方便扩展。

Snippet和Tag的多对多关系是 Prisma 自动处理的,我只需要在两边模型里互相加上字段,数据库中间表由迁移工具自动创建。执行迁移时,create-t3-app 项目里已经配置好了 Prisma 相关脚本,直接运行npx prisma migrate dev --name init就能在本地数据库创建这两张表。

2.4 Tailwind 和高亮方案为什么这样选

t3code 不是设计导向的项目,UI 部分追求的是快速搭建和响应式可用。Tailwind CSS 在这方面的优势很明显:不需要写一堆 CSS 文件,按类名组合就能完成布局,而且 T3 脚手架默认集成了 Tailwind,开箱即用。

代码高亮是代码片段工具的关键体验。常见的方案有 Prism 和 Shiki,我最终选了 Shiki。原因是 Shiki 使用 TextMate 语法做解析,高亮结果和现代编辑器里看到的效果基本一致,对一些复杂语言的支持比 Prism 更好。渲染方式上,我在服务端把 marked 或高亮 HTML 生成好,再在页面上通过dangerouslySetInnerHTML插入。考虑到 Shiki 本身比较大,对这个工具来说,直接按需加载常见语言,避免一次性引入全部语法文件,页面打开速度会好很多。

3. 实操过程与核心环节实现

3.1 从 create-t3-app 初始化项目

动手的第一步是执行初始化命令。create-t3-app 会问你选哪些能力,我建议第一版只选这四项:Next.js、TypeScript、Tailwind CSS、Prisma、tRPC。NextAuth 这类权限方案等真正需要私密片段时再加,不要一开始就塞进大量样板代码。

npm create t3-app@latest t3code

初始化完成后,项目结构已经是 App Router 模式,src/server/api/trpc.ts、src/server/db.ts、src/app这些目录都已经生成。这里不需要额外手动配置路径别名,脚手架默认配好了@/和~/映射。

我第一次跑起来后,先做了一件事:把默认的示例页面清空,只保留一个空的状态,然后用一个简单的health查询确认 tRPC 链路是通的。这样做的好处是能尽早排除环境问题,而不是直接开始写功能却不知道是前后端没连上还是环境变量有问题。

3.2 定义 Prisma 模型并执行迁移

项目初始化后,我先在prisma/schema.prisma中替换掉默认的 Post 示例,写入上面提到的Snippet和Tag模型。这里的重点是要想清楚slug这个字段:它承担的不只是数据库唯一性,还直接体现在 URL 里。我用nanoid生成,长度为 8,碰撞概率已经很低。

模型写好后,执行迁移命令:

npx prisma migrate dev --name init

这个命令会创建迁移文件,并把表结构同步到本地开发数据库。如果是本地开发且选择了 SQLite,默认连接字符串是file:./dev.db,不需要额外启动数据库服务。这也是 T3 项目适合快速验证的重要原因。

正式环境里如果使用托管 PostgreSQL,需要在部署平台设置DATABASE_URL环境变量,并且把prisma/schema.prisma里的provider改成"postgresql"。不要在本地 SQLite 和线上 PostgreSQL 之间用同一份迁移记录来回切换,容易产生迁移冲突。

3.3 实现 tRPC 接口并挂载到根 Router

写好的snippetRouter还需要挂载到appRouter中才能被前端访问。create-t3-app 生成的src/server/api/root.ts里有createCallerFactory和mergeRouters的操作,添加一行即可:

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

这样在前端调用时,路径是api.snippet.create、api.snippet.bySlug、api.snippet.increaseViews,层级清楚,IDE 补全也很友好。路由此刻写完后,最好先用src/server/api/root.ts里提供的createCaller写一个简单的 Node 脚本做自测,确认数据库连接和查询逻辑都没问题,再去接前端页面。很多时候前后端联调出了问题,先判断哪一段用自己的独立调用能通过,可以少走很多弯路。

3.4 前端页面与 mutation 调用

t3code 的首页承担的是“创建代码片段”的核心操作。我把它做成一个简单的表单:标题、语言选择、标签输入、代码内容文本域。提交逻辑用 tRPC 的useMutation完成。

"use client"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { api } from "~/trpc/react"; export default function CreateSnippetPage() { const router = useRouter(); const createSnippet = api.snippet.create.useMutation(); const [title, setTitle] = useState(""); const [content, setContent] = useState(""); const [language, setLanguage] = useState("typescript"); const [tagInput, setTagInput] = useState(""); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); const tagNames = tagInput .split(",") .map((tag) => tag.trim()) .filter(Boolean); const snippet = await createSnippet.mutateAsync({ title, content, language, tagNames, }); router.push(`/snippet/${snippet.slug}`); } return ( <form onSubmit={handleSubmit} className="space-y-4 p-6"> <input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="标题" className="w-full rounded-md border p-2" /> <select value={language} onChange={(e) => setLanguage(e.target.value)} className="w-full rounded-md border p-2" > <option value="typescript">TypeScript</option> <option value="javascript">JavaScript</option> <option value="python">Python</option> <option value="go">Go</option> <option value="rust">Rust</option> <option value="plaintext">Plain Text</option> </select> <input value={tagInput} onChange={(e) => setTagInput(e.target.value)} placeholder="标签,用逗号分隔" className="w-full rounded-md border p-2" /> <textarea value={content} onChange={(e) => setContent(e.target.value)} placeholder="粘贴代码" rows={15} className="w-full rounded-md border p-2 font-mono" /> <button type="submit" className="rounded-md bg-blue-600 px-4 py-2 text-white"> 创建片段 </button> </form> ); }

详情页的路由是/snippet/[slug]。在 Next.js App Router 里,我使用的是客户端组件模式,通过useQuery获取片段数据。页面加载后,根据slug调用bySlug查询,再把代码内容交给高亮组件渲染。另外用increaseViews在useEffect里增加访问数。要注意这里的计数最好是页面加载后异步触发,不要阻塞主内容展示。

3.5 部署到线上与配置环境变量

t3code 部署我选择的是 Vercel。构建时,Next.js 会自动执行依赖安装和构建命令,但 Prisma 需要手动处理prisma generate。在package.json的postinstall脚本里加上prisma generate,可以保证安装依赖后同步生成 Prisma Client:

{ "scripts": { "postinstall": "prisma generate" } }

如果用的是托管 PostgreSQL,数据库本身不会像 Vercel 的 Serverless Function 那样无状态,需要单独创建数据库实例并把连接字符串设置为项目环境变量。部署失败最常见的原因就是DATABASE_URL没有配置,Prisma 在构建阶段找不到数据库连接直接报错。还有一点,如果本地用的 SQLite,不要把dev.db文件提交到 Git,也绝对不要在线上用这个文件当持久化数据库,Serverless 环境的文件系统是临时的,数据库会被重置。

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

4.1 tRPC 类型报错:路由没有正确挂载

我遇到过的第一个诡异问题是,明明在snippetRouter里写好了create这个 procedure,前端却始终无法从api.snippet.create上拿到类型提示。排查到最后,发现是root.ts里的appRouter没有把snippetRouter合并进去。TypeScript 只在编译时报“属性不存在”,不会告诉你哪里漏挂了。这种问题很容易被忽略,所以写 tRPC 路由的固定习惯应该是:先改root.ts,再改业务 router,最后刷新前端页面。

另一个相关问题是在 mutation 的返回值里手动声明类型。tRPC 会自动推断返回值类型,如果你在代码里额外写一个接口类型,很可能和真实返回的 Prisma 对象不一致。最好的做法是不要限制返回类型,让它由 Prisma 查询自动推导。

4.2 Prisma 迁移时出现的环境变量错误

在本地第一次执行prisma migrate dev时,我遇到了Environment variable not found: DATABASE_URL的报错。这是因为 create-t3-app 默认会在.env文件里写入示例变量,但如果你改了 schema 里的 provider,或者复制了别人的迁移文件,容易导致.env里的变量名对不上。

解决办法很简单:确认prisma/schema.prisma里的数据库 provider 和.env里的DATABASE_URL是匹配的。本地 SQLite 用file:./dev.db,远程 Postgres 用真实的连接串。另外,.env文件默认不会提交到 Git,如果换了一台电脑开发,记得复制一份本地配置。

4.3 Next.js 客户端组件与服务端组件的调用边界

用 create-t3-app 生成的模板里,tRPC Provider 是包在根布局里的,但它本质上是客户端 Provider。如果在一个没有"use client"的组件里调用api.snippet.bySlug.useQuery,就会遇到类似useQuery must be used within a TRPCProvider的错误。原因是这个组件被当成服务端组件执行,而 React hooks 只能在客户端组件里使用。

解决方法是给调用 tRPC hooks 的页面或组件显式添加"use client"。如果某些数据你想在服务端获取,可以改用createCaller在 Server Component 里直接调用 tRPC procedure,再把结果作为 props 传给子组件。这个取舍看项目需求,t3code 为了简单统一,主要页面都使用了客户端调用。

4.4 代码高亮首屏闪烁与加载性能

代码高亮最影响体验的是首屏渲染。我在最开始把所有 Shiki 语言包一次性导入,结果详情页的 JS bundle 变得很大,打开速度明显变慢。后来改成按需导入,只加载页面里实际出现的那几种语言,体积立刻下来不少。

另一个比较隐蔽的问题是,Shiki 在服务端生成的 HTML 如果直接插入到 React 组件里,需要确认类名和全局样式都正确加载。如果发现高亮没有生效,先检查样式文件是否被 Tailwind 配置覆盖,再检查内容是否被转义。代码高亮组件本质上是把代码转换成 HTML 展示,因为代码本身是用户输入,渲染前做转义是安全底线。

4.5 常见问题速查表

现象可能原因处理方式
前端没有api.snippet.create类型router 未挂载到appRouter检查src/server/api/root.ts
Prisma 迁移报环境变量错误DATABASE_URL不一致检查.env和 schema provider
页面报useQuery must be used within provider服务端组件调用了 hooks添加"use client"
代码高亮样式缺失高亮 CSS 未引入检查全局样式和主题配置
部署后新增片段无法写入线上数据库未建表执行prisma migrate deploy
打开短链接很快但内容加载慢高亮语言包一次性加载按需导入语言包

5. 一些实际体验和后续想法

整个 t3code 做下来,我最明显的感觉是:T3 Stack 并不是把一堆依赖堆在一起,而是用 TypeScript 把数据库、服务端、前端串成一条线。过去全栈开发里最常见的“前端后端类型对不上”问题,在这个项目里几乎没出现过。你改字段、加接口、重命名变量,类型系统在你保存代码的那一瞬间就已经告诉你哪里有遗漏了。

如果让我重新做一遍,我会把标签搜索和代码片段的导入导出功能放在一起规划,因为这两个功能会让“存代码”真正变成“管理代码”。代码片段的导入导出功能看似简单,但设计好统一的文件格式,会让后续迁移到其他工具时非常省心。还有一个很小的细节:短链接的slug最好避免容易混淆的字符,比如数字 0 和字母 O,否则在电话里读链接时很容易出错。

如果你最近也想用 T3 Stack 写点什么,我的建议是别一上来就追求大而全。先把一条最简单的创建到展示链路跑通,再逐步加标签、搜索、计数这些功能。当类型系统帮你把边界都约束好之后,后面每加一个功能,心里都会很踏实。

RELATED READING

延伸阅读

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