news 2026/10/28 4:29:48

用T3 Stack从零构建代码片段管理工具:全栈类型安全实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用T3 Stack从零构建代码片段管理工具:全栈类型安全实践

t3code,这名字拆开看就很有意思:t3 指 The T3 Stack,code 说的是我天天要面对的那堆代码。说人话,这是我用 T3 Stack 从零搭建的一个代码片段管理工具。平时写项目总有那么几段“每次都得翻旧项目才能找到”的东西——Next.js 的模板代码、Prisma 的复杂查询、某个只敢复制不敢手敲的正则。t3code 就是干这个的:把散落在各个仓库和聊天记录里的代码片段集中管理,支持标签、搜索、公开分享,而且因为整个技术栈是端到端类型安全的,后面想加插件、加 AI 检索都顺得很。

这篇内容适合两类人。一是想搞明白 T3 Stack 全家桶(Next.js + tRPC + Prisma + Tailwind)怎么串联干活的全栈开发者;二是想直接照抄一个“带数据库、带接口、带界面、能部署上线”的完整应用的实操党。我会把从初始化、数据建模、写接口、做页面到部署的过程全部摊开讲,每个关键选择为什么这么做,也一并说清楚。

1. 项目概述与思路拆解

1.1 为什么不是传统全栈方案

如果只是做一个能用的代码片段工具,最省事的路线其实是 Next.js 全栈 + 几个 API Route + 一个 SQLite 文件。我一开始确实这么试过,但很快自己给自己挖了坑:API Route 本质上走的是字符串 URL,前端得手写 fetch 路径,后端返回的 JSON 在前端零条件、零提示。项目小的时候还能忍,一旦加了标签、搜索、分页,改一个字段名就得全局搜索替换,那个酸爽我到现在还记得。

MERN(MongoDB + Express + React + Node)就不用多说了,前后端两套 TypeScript 环境,中间靠 JSON 契约硬扛。跑起来没问题,但维护时总觉得同时在维护两个项目,且两个项目之间唯一的联系是“我们约定好了”。

T3 Stack 的思路从根本上换了赛道。它用 tRPC 把后端的 procedure 变成前端可以直接调用的函数,类型从数据库 Schema 一路推导到 React 组件。中间不用手写 interface、不用定义 DTO、不用维护 API 文档,因为编译器帮你把这些全干了。我选 T3 Stack 做 t3code,不是因为它新潮,而是它把我在意的几件事全占了:端到端类型安全、极少的样板代码、清晰的边界。

1.2 需求定位与最小可用版本规划

动笔写代码之前,我给 t3code 定了一份硬性需求清单,顺便画出了第一版的范围。我始终觉得小工具最怕一开始就追求什么都要,所以第一版只做这六件事:

功能模块具体要求优先级
片段管理新增、编辑、删除、查看代码片段必须
标签系统一个片段多个标签,支持按标签筛选必须
搜索标题和内容的关键词模糊搜索必须
公开分享片段可标记为公开,生成只读链接可以后置
用户登录区分“我的片段”和“全部公开片段”必须
响应式手机浏览器能正常浏览和编辑必须

技术选型上,T3 Stack 方案把 Next.js 14、tRPC、Tailwind CSS、Prisma 都打包好了,我只需要决定数据库。第一版用 SQLite,本机开发零配置。生产部署时再切 Postgres,Prisma 在两者之间切换基本就是改一行 provider 的事,这个灵活性是我敢从 SQLite 起步的底气。

回头复盘,这个规划最大的作用不是“列清单”,而是让我在写代码时有了说不的底气。比如分享链接功能,开发到一半我想加点赞数,因为不在清单里,所以果断没做。小工具最忌边写边加需求,先把闭环跑通,比什么都重要。

2. 环境准备与项目初始化

2.1 create-t3-app 初始化命令与模块选择

t3code 用的脚手架是 create-t3-app,目前已经非常成熟。初始化命令很简单:

npm create t3-app@latest t3code cd t3code npm install npm run dev

交互式命令行会问你要启用哪些模块。我给 t3code 勾选的是:Next.js(App Router)、tRPC、Prisma、Tailwind CSS,认证方案选的 NextAuth.js。这里必须多说一句:enter 默认推荐的全选方案对大多数项目都合适,但如果你知道自己用不到某块(比如我一开始用不上 Next.js 的 ESLint 定制),直接取消勾选,别让无用的依赖躺进 node_modules。

还有一个新手最容易忽略的点:create-t3-app 默认会打开 TypeScript 严格模式、ESLint、Prettier,并配置好@/路径别名。这些表面看起来是“束缚”,实际上它们是 T3 体验的地基。尤其是严格模式下,tRPC 的 input 校验和 output 推断才能完整生效,所以强烈不建议关闭。我见过有人为了省事把strict改回false,结果没几天类型错误就满天飞。

初始化完成后,验证环境最好的办法不是急着写业务,而是先跑通一个最简单的 tRPC 查询。在任意页面组件里调用一次api.snippet.list.useQuery(),能在浏览器看到返回数据,说明从数据库到接口到前端的链路已经通了,后面写业务才有底气。

2.2 目录结构的心智模型与服务端边界

初始化完成后的目录结构,核心部分长这样:

t3code/ prisma/ schema.prisma src/ app/ page.tsx layout.tsx api/ trpc/ [trpc]/route.ts snippets/ page.tsx [id]/page.tsx components/ server/ db.ts auth.ts trpc/ router/ index.ts snippet.ts server.ts client.ts react.tsx .env

第一次看src/trpc底下拆出 server.ts、client.ts、react.tsx 三个文件,很多人会懵。实际用下来你会发现这个拆分特别干净:

  • server.ts:服务端侧 tRPC 初始化,里面创建了createTRPCRouter和publicProcedure、protectedProcedure
  • client.ts:浏览器侧的调用封装,导出api
  • react.tsx:React 的 Provider 和 hooks 封装,前端组件里api.snippet.list.useQuery就是这里提供的

这个结构的心智模型可以理解成:服务端只负责暴露过程,客户端只负责消费过程,中间不需要定义任何共享的接口类型。整个链路里唯一要维护的类型是数据库 Schema,其他类型全部由它推导出来。

3. 核心功能实现:数据模型、API 与页面联动

3.1 Prisma 数据模型设计与三个细节

t3code 的核心数据模型,我定义在prisma/schema.prisma里:

generator client { provider = "prisma-client-js" } datasource db { provider = "sqlite" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) name String? email String? @unique snippets Snippet[] createdAt DateTime @default(now()) } model Snippet { id String @id @default(cuid()) title String description String? language String @default("plain") content String isPublic Boolean @default(false) tags Tag[] authorId String author User @relation(fields: [authorId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Tag { id String @id @default(cuid()) name String @unique snippets Snippet[] }

这个模型看起来简单,但三个设计细节值得展开讲讲。

第一,为什么主键用 String cuid 而不是 MySQL/SQLite 常用的自增 int?因为片段 ID 会出现在分享链接的 URL 里,比如/snippets/clj2k9x0t0000abc12345。自增 ID 很容易被遍历扫库,cuid 则完全没有这个风险。而且 String 主键在 Prisma 里做游标分页特别顺手,直接传上一个 ID 就行。

第二,Tag 和 Snippet 之间没有手工建中间表,而是利用 Prisma 的隐式多对多关系。底层确实自动生成了一张_TagToSnippet表,但我不用维护它,创建片段时直接用connectOrCreate就能把标签关联好,省掉一坨 CRUD 代码。

第三,updatedAt交给 Prisma 的@updatedAt自动刷新。这个字段对“按最近更新排序”的列表特别有用,完全不用在业务代码里手动更新时间。

3.2 tRPC Router 完整实现:列表、创建、更新、删除

t3code 最核心的 snippet router 我尽量写得直观,包含列表、创建、更新、删除四个核心能力:

import { z } from "zod"; import { createTRPCRouter, publicProcedure, protectedProcedure } from "../trpc"; export const snippetRouter = createTRPCRouter({ list: publicProcedure .input( z.object({ keyword: z.string().optional(), tag: z.string().optional(), cursor: z.string().optional(), }) ) .query(async ({ ctx, input }) => { return ctx.db.snippet.findMany({ where: { ...(input.keyword ? { OR: [ { title: { contains: input.keyword } }, { content: { contains: input.keyword } }, ], } : {}), ...(input.tag ? { tags: { some: { name: input.tag } } } : {}), }, take: 20, ...(input.cursor ? { skip: 1, cursor: { id: input.cursor } } : {}), orderBy: { updatedAt: "desc" }, include: { tags: true }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1), content: z.string().min(1), language: z.string().default("plain"), isPublic: z.boolean().default(false), tags: z.array(z.string()).default([]), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.snippet.create({ data: { title: input.title, content: input.content, language: input.language, isPublic: input.isPublic, authorId: ctx.session.user.id, tags: { connectOrCreate: input.tags.map((name) => ({ where: { name }, create: { name }, })), }, }, }); }), update: protectedProcedure .input( z.object({ id: z.string(), title: z.string().min(1).optional(), content: z.string().min(1).optional(), language: z.string().optional(), isPublic: z.boolean().optional(), }) ) .mutation(async ({ ctx, input }) => { const existing = await ctx.db.snippet.findUnique({ where: { id: input.id }, }); if (!existing || existing.authorId !== ctx.session.user.id) { throw new Error("片段不存在或无权操作"); } return ctx.db.snippet.update({ where: { id: input.id }, data: { title: input.title ?? existing.title, content: input.content ?? existing.content, language: input.language ?? existing.language, isPublic: input.isPublic ?? existing.isPublic, }, include: { tags: true }, }); }), delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const existing = await ctx.db.snippet.findUnique({ where: { id: input.id }, }); if (!existing || existing.authorId !== ctx.session.user.id) { throw new Error("片段不存在或无权操作"); } await ctx.db.snippet.delete({ where: { id: input.id } }); return { ok: true }; }), });

这段代码看起来只是普通的 CRUD,但里面有几个非常值得说的点。

先说list的游标分页。我用了take: 20限制每页条数,配合cursor参数实现“加载更多”。前端把上一次返回列表的最后一条记录 id 传回来,后端skip: 1跳过游标本身,就能拿到下一页。这比传统的 offset 分页在深翻页时性能稳定得多,尤其 SQLite 下效果明显。

再说是connectOrCreate。创建片段时传的 tags 是字符串数组,我通过connectOrCreate对每个标签名做“有则连接,无则创建”的操作。这样标签表永远不会出现同名记录,而且不用我手动去查标签 ID。这个 API 设计得非常省心。

最后是权限判断。update 和 delete 都做了“作者校验”,existing.authorId !== ctx.session.user.id时直接抛错。这里要注意,tRPC 的错误默认会返回给前端,所以生产环境最好对错误消息脱敏,别把内部逻辑细节暴露给未授权用户。

3.3 客户端与服务端取数的正确姿势

t3code 的页面里同时用到了客户端取数和服务端取数两种姿势,这也是 T3 Stack 比较灵活的地方。

先说客户端。代码片段列表用了一个客户端组件:

"use client"; import { api } from "~/trpc/react"; export function RecentSnippets() { const [snippets] = api.snippet.list.useSuspenseQuery({ keyword: undefined, tag: undefined, }); return ( <ul> {snippets.map((s) => ( <li key={s.id}> <a href={`/snippets/${s.id}`}>{s.title}</a> <span>{s.language}</span> </li> ))} </ul> ); }

useSuspenseQuery是 tRPC 对 React Query 的封装,配合 Next.js App Router 的 Suspense 边界可以做流式 SSR。用户第一次打开首页时,服务端已经把数据渲染进 HTML,浏览器端不会出现空白闪烁。而且查询结果会自动缓存,用户从列表页点进详情页再返回,列表几乎秒开。

再说服务端取数。对于详情页这类偏静态的页面,我在服务端组件里直接调用了 tRPC 的 caller:

import { appRouter } from "~/trpc/server"; const detail = await appRouter.snippet.detail({ id: params.id });

这一步让我感觉非常舒服:客户端和服务端用的是同一套 API 封装,完全不用写两套取数逻辑。服务端组件适合拿“首屏就要展示、不需要频繁交互”的数据,客户端组件适合拿“用户操作后需要更新”的数据。实际开发时我遵循一个简单原则:能服务端取数就服务端取数,只有确定要交互的地方才在客户端取数。

3.4 认证方案:从 NextAuth 到手动 Session 的调整

create-t3-app 默认整合了 NextAuth.js,理论上开箱即用。我在 t3code 早期确实用了 NextAuth,但很快遇到两个具体痛点。

一是 NextAuth 的 Session 要绑定 tRPC 的 context,需要写额外的 adapter 代码,才能把session.user.id稳定地塞进每个 procedure。二是 OAuth 提供商在本地环境和个人服务器上的配置很繁琐,回调地址、密钥、token 刷新策略稍有不慎就翻车。

对 t3code 这种个人项目,我最后决定简化认证,改成手动 Session 方案:登录用 GitHub OAuth 换取用户信息,然后本地签发一个带签名的 JWT 存在 HttpOnly Cookie 里。tRPC 的createContext里读 Cookie、解析 JWT,把用户 ID 挂到ctx.session。前端通过一个/auth/me的 tRPC 查询拿当前用户信息。

这个方案爽在流程短,省掉了一堆 OAuth 回调的状态同步。但我也明确一下适用边界:如果项目需要正经的社交登录、多角色权限、密码找回这些能力,还是老老实实用 NextAuth。个人小工具用简化方案可以,生产级应用别省这个功夫。

4. 样式、编辑器与交互细节

4.1 Tailwind 主题系统与暗色模式

Tailwind CSS 部分我不打算写太长,只说三个在 t3code 里实际用到的经验。

第一个是 CSS 变量驱动的代码高亮主题。我定义了两套 CSS 变量挂在:root和.dark下,亮色和暗色模式下代码编辑器的配色会自动跟着走。这个比硬编码颜色值要优雅得多,因为整个应用的主题只有一个数据源。

第二个是代码字体栈。字体我用了系统字体优先的写法:

font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;

不建议打包 web font。我试过引入一个代码字体,结果切换页面时出现了明显的字体闪烁,观感很差。系统字体栈在国内网络环境下反而是最优解。

第三个是响应式。片段列表的宽度我用max-w-6xl限制在 1152px,避免大屏显示器上内容拉得太远。编辑区域在lg断点以下是全屏布局,以上是左右分栏,这个用 Tailwind 的分断点类几行就能写完。

4.2 CodeMirror 6 编辑器集成与初始化坑

编辑器的选型我最后选了 CodeMirror 6,没上 Monaco。理由很现实:Monaco 体积大、初始化慢,worker 在 Next.js 里的配置非常繁琐。CodeMirror 6 模块化程度高,按需加载后几百 KB 就能跑起来,对“编辑几百行代码片段”的场景完全够用。

核心初始化代码如下:

import { EditorView, basicSetup } from "codemirror"; import { javascript } from "@codemirror/lang-javascript"; new EditorView({ doc: "const x = 1", extensions: [ basicSetup, javascript(), editorTheme, EditorView.updateListener.of((update) => { if (update.docChanged) { onChange(update.state.doc.toString()); } }), ], parent: containerRef.current, });

这里有个值得记下来的坑:在 Next.js 客户端组件里创建 EditorView 时,容器元素必须已经有宽高,否则编辑器会以 0 高度渲染,看起来就是一片空白。我第一次遇到时完全懵了,后来排查了很久才发现是 DOM 布局未完成。解决方法是把初始化放到requestAnimationFrame里,确保布局完成后再挂载:

useEffect(() => { const frame = requestAnimationFrame(() => { new EditorView({ ... }); }); return () => cancelAnimationFrame(frame); }, []);

还有一个坑是更新监听器的清理。如果组件卸载后 editor view 没有销毁,会报内存泄漏。记得在 cleanup 里调用view.destroy(),这是我在做过多次编辑操作后从浏览器性能面板发现的。

5. 部署实战与问题排查记录

5.1 Vercel 部署与环境变量清单

t3code 部署选的是 Vercel,没有自己买服务器。主要考虑是 Next.js 在 Vercel 上部署零配置,构建命令都不用改。真正要花时间处理的是数据库和环境变量。

数据库我换成了 Neon 的免费 Postgres 实例,然后本地执行两个命令完成切换:

npx prisma generate npx prisma migrate deploy

这里特别提醒一个我踩过的坑:生产环境的DATABASE_URL必须在 Vercel 的环境变量里配置,而且 Prisma 的 schema 里provider改了之后,本地必须先执行npx prisma migrate dev生成一份兼容 Postgres 的迁移文件,再提交到代码库。我一开始直接把 SQLite 生成的迁移文件推到生产库,结果执行失败,表结构对不上。

环境变量清单方面,t3code 需要配置的有这几个:

变量名用途示例
DATABASE_URLPostgres 连接串postgres://user:pass@host/db
JWT_SECRET手动 Session 签名密钥随机 32 字节字符串
NEXTAUTH_SECRET如果保留 NextAuth随机字符串
GITHUB_CLIENT_IDOAuth 登录用GitHub 应用提供的 ID
GITHUB_CLIENT_SECRETOAuth 登录用GitHub 应用提供的密钥

我习惯在本地建一个.env.example列好所有变量,避免换机器后到处翻配置。

5.2 高频问题排查速查表

开发和部署 t3code 过程中,我记录了一批高频问题,整理成表格,方便遇到时直接查:

现象原因解决
tRPC 查询返回 404路由未在 appRouter 注册检查src/trpc/router/index.ts是否 import 并挂载
Prisma 类型与数据库模型不同步修改 schema 后未迁移运行npx prisma migrate dev并重启 dev server
代码编辑器显示空白容器元素没有高度用requestAnimationFrame延迟初始化 EditorView
protectedProcedure 一直报未登录Cookie 里的 JWT 过期重新登录,或调大 JWT 有效期配置
生产环境查到不到表没执行prisma migrate deploy在 Vercel 构建命令里加postinstall脚本
列表接口数据不刷新React Query 缓存了旧数据用trpc.useUtils().snippet.list.invalidate()手动失效
搜索中文不生效SQLite 的 contains 对某些字符集不友好中文搜索在 SQLite 下建议先trim再用,生产切 Postgres

这里想说一个排查方法论。t3code 报错时,我第一件事永远是看 tRPC 的输入输出形状。zod 的错误信息通常已经把字段名说得非常清楚,比如"title": "Invalid input: expected string, received undefined"。先把输入校验改对,再去翻 Prisma 日志,大部分问题其实出在参数没对上。

5.3 性能优化与后续扩展方向

部署稳定后,我还做了一轮轻量性能优化。一是给Snippet表的updatedAt和isPublic加了索引,因为列表页最常用的排序字段是updatedAt,过滤字段是isPublic,加上索引后查询速度有了明显提升。二是在详情页用 Next.js 的generateStaticParams把公开片段生成了静态页面,访问者不会每次都打到数据库。

SQLite 转 Postgres 之后,搜索终于可以从contains升级成pg_trgm的模糊搜索,性能提升了不止一个档次。不过这个对 t3code 这种规模的项目属于锦上添花,小数据量下差别并不明显。

后续我想加的方向还有三个:浏览器剪藏插件,方便在阅读技术文章时一键收藏代码;全文搜索的强制索引,让内容更长的片段也能快速命中;以及一个公开页面统计访问量的简单计数。这些都排在下个版本,不会推翻现在的架构。

最后说一点个人体会。t3code 对我来说最大的收获不是“我写了一个代码工具”,而是真正理解了 T3 Stack 所谓“类型安全,不是玄学,是流程”。从 Prisma 的数据库模型,到 tRPC 的过程定义,再到 React 组件,所有层的名字都被编译器串在一起,它会在绝大多数情况下替你挡住“改错字段名”这种低级错误。整个项目写下来,我少写了大概两百行 interface 和 DTO,但重构时从来没有因为漏改某个字段而失眠。

如果你也想复刻一个类似的项目,我的建议是三点:第一,第一版做到“列表 + 详情 + 新建 + 编辑”就停手,别贪多;第二,从第一天就开启严格 TypeScript 模式并保持 ESLint 全绿,别等代码过万行再回头补类型;第三,早点做部署,别在本地磨到最后一刻。因为生产环境的数据库、环境变量、域名问题,通常比业务代码更早暴露你的盲区,早撞早修,后面会顺很多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/28 4:29:20

NF5280M5 V2.8装机指南:BMC带外管理、RAID配置与固件升级避坑

简介&#xff1a;浪潮Inspur NF5280M5服务器用户手册&#xff08;V2.8&#xff0c;2024年7月发布&#xff09;面向技术支持工程师与产品维护工程师&#xff0c;提供服务器规格、硬件操作、故障诊断及维护工作的参考依据&#xff0c;适合具备服务器基础的专业人员用于日常运维、…

作者头像 李华
网站建设 2026/10/28 4:29:18

MySQL性能优化全攻略:从配置到索引实战

1. MySQL优化入门&#xff1a;从基础配置到实战调优刚接触MySQL数据库时&#xff0c;我们往往只关注基本的CRUD操作&#xff0c;但随着数据量增长和业务复杂度提升&#xff0c;性能问题会逐渐暴露。记得我第一次负责的电商项目&#xff0c;当用户量突破10万时&#xff0c;首页加…

作者头像 李华
网站建设 2026/10/28 4:27:41

ECC双线解析:椭圆曲线加密与SAP LSMW批量导入实战

1. ECC 到底指什么&#xff1a;从三个高频场景说起第一次看到“ECC”这三个字母&#xff0c;很多人会愣一下——它太短了&#xff0c;短到在不同圈子里指向完全不同的东西。我在实际工作中至少在三类完全不相干的场景里撞见过它&#xff1a;做后端接口签名时它是椭圆曲线密码学…

作者头像 李华
网站建设 2026/10/28 4:27:38

Agent-Reach:让AI Agent真正够得着外部世界的工具编排实践

Agent-Reach 这个名字第一次看到的时候&#xff0c;我下意识以为是某个新出的网络爬虫框架&#xff0c;或者又是一个包装了 LLM 接口的套壳工具。直到我把它的仓库拉下来跑通第一个 demo&#xff0c;才意识到它想解决的问题其实很朴素&#xff1a;让 AI Agent 真正能"够得…

作者头像 李华
网站建设 2026/10/28 4:26:52

Cloudflare Workers 与 AI Agent 融合:构建可扩展的运行时框架

1. 从标题到落地&#xff1a;cloudflare-os 到底想解决什么问题第一次看到cloudflare-os这个标题&#xff0c;我下意识以为又是一个把 Linux 塞进浏览器沙箱的玩具项目。真正翻完仓库结构、跑通本地开发链路之后才发现&#xff0c;它想做的事情比“网页里跑个系统”务实得多&am…

作者头像 李华
网站建设 2026/10/28 4:26:51

claude-mem 深度解析:为 Claude Code 构建持久化记忆与上下文注入机制

1. 项目缘起与核心定位第一次看到claude-mem这个名字&#xff0c;我的直觉是&#xff1a;这应该是一个给 Claude 这类大语言模型做“记忆管理”的工具。事实也确实如此。claude-mem是一个为 Claude Code 提供持久化记忆能力的开源项目&#xff0c;它解决的是一个非常具体、也非…

作者头像 李华