t3code 实践笔记:一个类型安全的全栈代码片段管理工具,从选型到上线的完整复盘
先说说 t3code 是什么。这个项目名字拆开看就是 t3 + code,t3 指最近几年很火的 T3 Stack,也就是 TypeScript、Tailwind CSS、tRPC 这一套以类型安全为核心的全栈技术组合,code 部分则是一个开发者的日常刚需——代码片段的创建、管理、分享。所以 t3code 本质上就是一个基于 T3 技术栈构建的全栈代码片段管理与分享平台,解决的是"代码到处散落、临时写的小工具没有地方放、想分享一段代码还要走一遍格式化流程"这类碎片化问题。
我自己平时写前端比较多,经常遇到一个场景:调试了很久的一段配置、一个工具函数,可能过两周就要重新找回来,但存在本地文件里容易丢,放在 Gist 上又感觉太重。于是干脆自己动手做了一个轻量级的代码片段管理工具,支持按标签检索、语法高亮、一键生成分享链接。这篇博文就是 t3code 从零搭建的完整复盘,包括技术选型背后的思考、核心功能的实现方式、我实际部署上线时踩过的坑。适合正在学 T3 Stack 的读者,也适合想做一个类似工具但不知道从哪下手的开发者参考。
1. 定位与选型:t3code 到底要做一个什么样的项目
1.1 从名字说起:t3 不是营销噱头,是类型安全的方法论
很多刚接触 T3 Stack 的人会问,t3 到底指什么。官方解释里 T3 是三个 T 打头的技术:TypeScript、Tailwind CSS、tRPC,但很多人忽略了一点——T3 的核心不只是这三个库,而是一套"在保证类型安全的前提下追求最小复杂度"的工程理念。你会发现 T3 体系下,几乎没有运行时类型检测的库,因为从 API 层到数据库层,类型都是编译期唯一确定的,这就是所谓的"类型安全全链路"。
回到 t3code 这个项目,我选择 T3 Stack 并不是为了追逐热度,而是这个场景太适合了。
代码片段管理工具的核心操作就是增删改查加分享,这些操作全部需要和数据库交互,而且前端需要拿到带完整类型提示的数据。如果按照传统前后端分离的思路来写,你需要同时维护 REST API 文档、TypeScript 接口定义、前端请求封装三套东西,任何一个字段变了都要同步改三处。tRPC 的做法是让你在服务端定义函数,前端直接 import 类型,整个链路里你不写一次 JSON Schema,也不写一份多余的 interface 声明,所有类型推导是自动完成的。
所以 t3code 的定位非常明确:一个面向开发者个人的、轻量的、类型安全第一位的代码片段管理工具。不是要做成一个复杂的协作平台,核心诉求就是快、稳、少维护负担。
1.2 功能规划:先想清楚边界,再动手写代码
做个人工具最容易犯的毛病是功能越加越多。我一开始也幻想着要做成带 AI 推荐、团队协作、评论系统的大平台,后来冷静下来想,一个个人工具最核心的价值应该是"存得进去、找得出来、分享得出去"这三点。
最终我把 t3code 的功能边界划定在以下范围:
- 代码片段的基本增删改查,字段包括标题、描述、代码内容、语言类型、标签列表
- 语法高亮展示,支持常见语言如 JavaScript、TypeScript、Python、Go、JSON、Shell
- 标签系统,用于快速分类和过滤,不搞复杂的层级分类
- 分享功能,每个片段可以生成一个只读链接,不需要登录就能查看
- 全局搜索,按标题和代码内容的关键字搜索
我把这些功能按照优先级排了序。第一版先做 CRUD 和语法高亮,因为没有这两个功能的代码管理工具没有意义。搜索和标签放在第二优先级。分享链接是最后才加的,因为我发现很多场景下我需要把一个片段发给别人看,这时候给链接比复制粘贴要专业得多。
这里有个经验值得分享:个人项目里,功能边界一定要跟着真实需求走,而不是跟着想象力走。我在做之前花了两天时间观察自己平时是怎么处理代码片段的,结果发现 80% 的操作是"快速保存"和"搜索找回",这个观察直接决定了后续的开发重心。
1.3 技术栈对比:为什么不用 Express + React 或者工程化的 Next.js + Rest API
技术选型这块,我当初其实有四个备选方向:传统的前后端分离(Express + React)、全栈 Next.js 写 REST API、Next.js + GraphQL、以及最后采用的 Next.js + tRPC。这里分享一下我自己的对比逻辑,不一定适合所有项目,但至少可以给正在纠结选型的读者一个参考。
先看传统前后端分离。这种方式最大的问题是维护成本,Express 写接口、React 单独起一个开发服务、跨域配置、接口文档、类型不一致,每一项都在消耗注意力。对一个小工具来说,这套基建太重了。
再看 Next.js + REST API。把 API 写在 Next.js 的 API Routes 里,省去了前后端分离的部署问题,但类型安全仍然是个麻烦。你仍然需要手写请求函数、接口返回值的类型、错误处理逻辑,routing 和类型检查是分开的。
GraphQL 的方案我也认真考虑过,因为 GraphQL 的 schema 具备类型推导能力,配合 codegen 也能做到前后端类型一致。但 GraphQL 的复杂度在于 schema 定义、resolver 拆分、缓存策略,对一个 CRUD 为主的工具来说属于过度的工程投入。
tRPC 的出现正好卡在这个平衡点上。它不需要定义 schema,直接在 TypeScript 函数上建立远程调用,类型是从函数签名直接推导出来的。前端调用后端方法就像调用本地函数一样,获取的返回值自带类型。对于 t3code 这样一个"数据库在前端页面里直接呈现"的应用,tRPC 几乎是零成本的类型安全方案。
最终的技术栈明细是这样的:Next.js 作为应用框架、tRPC 处理 API 调用、Prisma 作为数据库 ORM、MySQL 作为存储方案、Tailwind CSS 做样式、NextAuth 处理登录(后面为了简化先不启用)。这套组合基本就是目前 T3 Stack 的标准配置。
2. 核心设计解析:类型安全如何贯穿 t3code 的每一步
2.1 数据模型设计:先画清楚实体关系,避免后期返工
t3code 的数据模型其实并不复杂,核心实体有四个:用户(暂不强求)、代码片段、标签、分享记录。但为了让标签既能灵活伸缩又不至于冗余,我用了多对多的关系设计。
具体来说,代码片段(CodeSnippet)和标签(Tag)之间是多对多关系。一个片段可以有多个标签,一个标签可以关联多个片段。在 Prisma 里这个关系用隐式多对多表表达最简洁,不需要显式定义一个中间表 model。如果说 之后要加标签分类,再拆成显式关系也不迟。
片段表的关键字段我设计如下:
- id:自增主键,优先用 cuid 或者 uuid,因为分享链接里暴露自增 ID 会被人遍历抓取
- title:片段标题,限制 100 字符
- description:一段简短的描述,方便快速回忆这个片段的用途
- content:真正的代码内容,用 TEXT 类型存
- language:语言字段,存字符串而不是枚举,这样后续添加新语言支持不需要做数据库迁移
- tags:和 Tag 表的多对多关系
- createdAt、updatedAt:时间戳
语言字段用字符串而不是枚举这个细节,我强烈建议你采纳。虽然枚举在数据库层面更严谨,但在实际使用中你永远不知道用户明天会不会需要贴一段 Rust 或者 Kotlin,枚举类型每加一个语言都要走一次 migration,而字符串字段只需要在前端下拉菜单里加一个选项,后端代码基本不用动。
分享记录表在 t3code 里承担一个很重要的职责:它不是简单存一个链接,而是要记录这个分享链接的生成时间、失效时间、访问次数。这样我就能在后台看到哪个片段被访问得最多,顺便为以后做"热门片段"功能留下伏笔。
2.2 数据库访问层:Prisma 为何是 T3 生态的标准答案
t3code 的数据库访问层选择了 Prisma,理由是在 T3 Stack 体系里,Prisma 的类型生成能力和 tRPC 类型推导能形成完美的配合。Prisma 通过 schema.prisma 文件定义数据模型,然后运行 prisma generate 自动生成的客户端,每个查询返回值都自带 TypeScript 类型,这些类型可以直接被 tRPC 的 router 引用,再通过 tRPC 的类型推导传到前端。整个链条上每一个环节类型都是通的,没有任何一个地方需要手动维护 interface。
拿 t3code 的片段查询来举例。我写一个 getSnippetById 的查询:
// server/api/routers/snippet.ts import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; export const snippetRouter = createTRPCRouter({ getById: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx }) => { const snippet = await ctx.db.snippet.findUnique({ where: { id: input.id }, include: { tags: true }, }); return snippet; }), });仔细观察这段代码,你会发现这里没有写任何返回类型声明。数据库查出来什么结构,tRPC 返回的就是什么结构,前端 useQuery 拿到的数据就是什么结构。这种体验在传统 REST API 里是感受不到的,前端同事经常为了一个"status 字段到底有没有"打电话确认,但在 t3code 里这种问题从编译期就被杜绝了。
关于 Prisma 的使用,我有三个实际经验想特别提一下。第一,findUnique 的性能比 findFirst 好很多,能走主键查询就坚决用 findUnique。第二,查询片段列表的时候不要无条件返回 content 字段,代码内容可能很大,列表页只需要标题、标签和更新时间,这时候用 select 指定字段可以有效减少数据传输量。第三,create 和 update 的 include 参数容易忽略,如果不带 include,创建后的返回值里不会包含关联对象,前端还得再发一次请求拿完整数据,直接在写操作里把关联关系带上更省事。
2.3 路由组织:把 API 拆成"工具"而不是"资源"
tRPC 的 router 和常规 REST 风格不太一样。REST 强调的是资源:GET /snippets、POST /snippets、DELETE /snippets/:id。tRPC 更倾向于按业务操作来组织路由,因为它本质上是函数调用。我在规划 t3code 的路由时,经历了从"资源思维"到"操作思维"的转变,刚开始总觉得按 REST 方式组织更熟悉,但写了两天后发现按操作组织才是 tRPC 的正确使用方式。
t3code 最终的路由结构是这样的:
// server/api/root.ts import { createTRPCRouter } from "~/server/api/trpc"; import { snippetRouter } from "~/server/api/routers/snippet"; import { tagRouter } from "~/server/api/routers/tag"; import { shareRouter } from "~/server/api/routers/share"; export const appRouter = createTRPCRouter({ snippet: snippetRouter, tag: tagRouter, share: shareRouter, });snippet router 内部的操作包括 create、update、delete、getById、getAll、searchByKeyword。share router 则负责创建分享、获取分享内容、记录访问次数。这样的设计好处在于,前端调用时的路径和语义非常清晰:
api.snippet.getAll.useQuery({ keyword: "prisma" }); api.share.create.useMutation({ snippetId: "xxx" });读取操作对应 useQuery,写操作对应 useMutation,tRPC 的 react hooks 封装做得非常顺手。另一个实际使用中的建议是,mutation 完成后要更新前端缓存。tRPC 提供了 utils 对象,在创建或删除片段之后调用utils.snippet.getAll.invalidate(),它会自动重新拉取列表数据,这样就不用手动维护一份前端状态了。
2.4 编辑器与语法高亮:shiki 还是 prisma 全家桶没那么简单
t3code 里的代码展示是这个项目的门面,直接决定用户愿不愿意长期使用。我在编辑器部分最先想到的是 CodeMirror 和 Monaco Editor,这两个都是功能强大的编辑器,但 t3code 第一版并不需要完整的编辑体验,更多是"能写进去、能正确显示语法高亮"就够了。
最终我采用了 textarea 输入 + code 展示的方案。编辑的时候用普通的多行 textarea,保存之后在展示页面用语法高亮组件渲染。这个方案有两个明显优势:textarea 不需要引入额外的编辑器包,首屏加载速度非常快;展示逻辑和编辑逻辑彻底分离,不容易出现光标跳转、自动格式化之类的复杂 bug。
语法高亮库的选择上,我在 react-syntax-highlighter 和 shiki 之间犹豫了很久。react-syntax-highlighter 使用简单,组件化程度高,但它的高亮底层是 hljs,语法覆盖率和现代语言支持度都一般。shiki 用的是 TextMate 语法解析,vscode 的高亮配色就是这套体系,效果和编辑器里看到的完全一致,但它的打包体积偏大,而且使用 async 加载机制。
我给 t3code 做高亮组件时最后选了 shiki,原因是它处理现代前端框架代码的准确率明显更高,比如 TSX 语法里的泛型、装饰器,hljs 经常会认错。这里有个细节,如果直接引入 shiki 并按需加载语言,页面首次请求会被拆成很多小 chunk,刷新时会有短暂的加载等待,我的解决方式是先保证最常见的前几门语言本地打包,其余语言再按需异步加载,体验就会顺滑很多。
在实际使用的过程中,我还发现代码高亮组件的内边距和字体配置直接影响截图在社交平台上的效果。我最后统一设置了 16px 的 padding、JetBrains Mono 字体、行高 1.7,分享出去的代码截图整体观感干净利落,这个细节建议做类似功能的朋友直接抄。
3. 实操过程:从空目录到 t3code 上线,每一步都做了什么
3.1 初始化项目:用 create-t3-app 一把梭还是手动搭建
对于第一次接触 T3 Stack 的开发者,我的建议是直接用 create-t3-app 脚手架初始化,因为它已经把 Next.js、TypeScript、Tailwind、tRPC、Prisma 这些集成配好了,你只要选择需要的模块就能进入业务开发。但如果你打算长期维护这个项目,我还是推荐理解一遍脚手架到底生了哪些文件,这才是真正的收获。
我用 create-t3-app 初始化 t3code 时选择了这些模块:
- Next.js 使用 App Router 模式
- TypeScript 启用严格模式
- Tailwind CSS 使用最新版本
- tRPC 选择 v10 版本
- Prisma 选择 MySQL 数据库
- NextAuth 先不集成,第一版用纯公开访问的方式
实际执行命令就是一行:
npx create-t3-app@latest t3code回车之后它会问你选哪些模块,选好之后自动安装依赖。整个过程大概三分钟,不需要手动去配置 tsconfig 里的路径别名,也不用自己装 tRPC 的依赖包,脚手架会把这些整合好。初始化之后目录结构是这样的:
t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ │ │ ├── api/ │ │ ├── layout.tsx │ │ └── page.tsx │ ├── components/ │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ ├── root.ts │ │ │ └── trpc.ts │ │ └── db.ts │ ├── styles/ │ │ └── globals.css │ └── trpc/ │ ├── server.ts │ └── react.tsx └── package.json这个结构里 prisma/schema.prisma 放数据库模型,server/api/trpc.ts 放初始化好的 tRPC 上下文,server/db.ts 返回数据库客户端实例,routers 目录用来放各业务模块的路由。整体非常清晰。
需要提醒的是,create-t3-app 生成的项目模板自带一个基础示例页面,里面包含了一些演示用的组件和路由,正式开发 t3code 业务之前,先把这些演示代码清理干净,避免后期干扰。
3.2 数据库模型落地:Prisma schema 的一次成型
t3code 的数据库模型在 Prisma 里是这样定义的:
// prisma/schema.prisma generator client { provider = "prisma-client-js" } datasource db { provider = "mysql" url = env("DATABASE_URL") } model Snippet { id String @id @default(cuid()) title String @db.VarChar(100) description String? @db.Text content String @db.Text language String @default("javascript") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt tags Tag[] shares Share[] } model Tag { id String @id @default(cuid()) name String @unique @db.VarChar(50) snippets Snippet[] } model Share { id String @id @default(cuid()) snippetId String snippet Snippet @relation(fields: [snippetId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) expiresAt DateTime? views Int @default(0) }这里有几个设计细节值得展开说。Snippet 的 description 字段我设计成可选,很多代码片段根本不需要注释说明,留空很自然,不要用 default("") 占用无效数据。Tag 的 name 加了 @unique 约束,保证同名标签只有一个,这种约束在数据库层面比在应用层判断可靠得多。Share 表里 snippetId 和 Snippet 关联时配置了 onDelete: Cascade,片段被删除时,分享链接自动清理,避免留下僵尸链接。
定义完成后执行两条命令:
npx prisma db push npx prisma generatedb push 会把模型直接推给数据库,适合开发阶段快速迭代;generate 会生成类型安全的 Prisma Client。如果你用的是 MySQL,确保本地已经有一个可连接的数据库实例,DATABASE_URL 的格式是 mysql://user:password@localhost:3306/t3code。
在生产环境我建议改用 prisma migrate 而不是 db push,因为 migrate 会生成可追溯的 SQL 迁移文件,可以回滚,db push 则是对着 schema 硬同步,不适合用在线上库。
3.3 核心 CRUD 接口实现:tRPC 的业务代码怎么写最顺手
接下来是 t3code 的核心业务逻辑,我先从 snippet router 中最常用的几个接口说起。
创建代码片段是最基础的,前端提交一个表单,包含标题、描述、代码内容、语言和标签列表。这里的关键处理是标签的"查找或创建"逻辑。用户写标签时,可能是已经存在的"prisma",也可能是全新的"docker",后端不能直接 create,得先判断是否存在再决定新增还是关联。我封装了一个 upsertTags 辅助函数:
async function upsertTags(ctx: { db: PrismaClient }, tagNames: string[]) { const tags = []; for (const name of tagNames) { const tag = await ctx.db.tag.upsert({ where: { name }, update: {}, create: { name }, }); tags.push(tag); } return tags; }然后在 create procedure 里把这个函数的结果和片段关联起来:
create: publicProcedure .input( z.object({ title: z.string().min(1).max(100), description: z.string().optional(), content: z.string().min(1), language: z.string().default("javascript"), tags: z.array(z.string()).default([]), }) ) .mutation(async ({ ctx, input }) => { const tags = await upsertTags(ctx, input.tags); return ctx.db.snippet.create({ data: { title: input.title, description: input.description, content: input.content, language: input.language, tags: { connect: tags.map((tag) => ({ id: tag.id })) }, }, include: { tags: true }, }); }),这里 input 校验用的是 zod,tRPC 原生支持 zod 作为输入校验器。如果你之前没用过 zod,可以把它理解成"带着类型信息的运行时校验工具",它不但能验证字段格式对不对,还能把验证后的类型自动推导出来,不需要再写 interface。
更新片段的逻辑类似,唯一需要注意的是 content 字段比较敏感,如果前端传了空字符串是错误的,但 z.string() 默认允许空,所以我加了 min(1) 来禁止空内容。这个校验习惯建议养成,数据库层虽然能靠约束兜底,但错误信息绕一圈再从数据库抛出来,前端的展示体验远不如在 API 入口直接阻断来得清楚。
删除片段走了 mutation,因为 tRPC 的 query 默认是幂等的,不应该有副作用的写操作。删除之后需要同步 invalidate 列表缓存,这个我在前面提过,用 utils.snippet.getAll.invalidate() 一行搞定。
3.4 前端页面:列表页、详情页、分享页三个视角的落地
t3code 的前端页面主要由三个部分组成:列表页、片段详情页、分享页。列表页负责展示所有片段和标签过滤,详情页负责展示代码高亮和编辑按钮,分享页是一个无登录状态的只读页面,给外部访客看。
列表页的查询我用 tRPC 的 useQuery,数据回来之后直接用 map 渲染卡片,每张卡片显示标题、语言标签、最近更新时间,点击进入详情。为了让列表页保持轻量,后端查询时用了 select 只返回必要字段,我在前面说过的 content 字段不包含在内,这个优化效果在列表数据超过 200 条时非常明显。
详情页是代码片段展示的主战场。页面布局分两块,上面是标题和标签信息,下面是 shiki 渲染的代码高亮区域。编辑功能我用了另一条路由,点击"编辑"之后会把代码放回 textarea。这里有一个交互上的优化,详情页进入时默认展示高亮效果,不加载 textarea,避免用户误触修改;只有明确点击编辑按钮才切换成可编辑状态。
分享页的实现是 t3code 比较有特色的地方。它接收一个 shareId,调用 share router 里的 getPublicShare 查询,不需要用户态,也不需要任何 token。为了保证分享页不会暴露太多信息,这个接口只返回片段内容、语言、生成时间,不返回创建者的其他信息。关键代码:
getPublicShare: publicProcedure .input(z.object({ shareId: z.string() })) .query(async ({ ctx, input }) => { const share = await ctx.db.share.findUnique({ where: { id: input.shareId }, include: { snippet: { select: { content: true, language: true } } }, }); if (!share) throw new TRPCError({ code: "NOT_FOUND" }); await ctx.db.share.update({ where: { id: input.shareId }, data: { views: { increment: 1 } }, }); return share; });注意后面的 views 字段,read 操作里竟然带了写操作,这在 REST 里看起来有点不规范,但 tRPC 的 query 理论上应该是纯函数,这里我是为了给分享热度加个统计。虽然实际运行没问题,但如果你想严格遵守 tRPC 的约定,可以改成 mutation。不过说实话,这种小工具的统计放在 query 里更简单,性能影响可以忽略。
3.5 部署实践:Vercel 上跑 Prisma 的两个隐藏问题
t3code 的部署选择了 Vercel,因为 Next.js 项目和 Vercel 的契合度最高,数据库我仍然用本地的 MySQL 实例,通过公网连接。部署过程本身非常顺利,代码推到 GitHub、导入 Vercel、配置环境变量,一两分钟就能看到页面。但坑都在细节里,这里分享两个我实际踩过的问题。
第一个坑是 serverless 环境和 Prisma Client 的兼容性问题。Vercel 的 Serverless 函数是冷启动模式,每次请求可能会跑在一个全新的 Node 进程里,Prisma Client 如果每次都重新初始化,冷启动时间会明显拉长。解决方案是在 Prisma Client 初始化时做全局缓存,只初始化一次,后续请求复用同一个实例。create-t3-app 生成的 prisma.ts 里已经处理了这个逻辑,如果你是自己搭建的,一定要注意:
// server/db.ts import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined; }; export const db = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;第二个坑很隐蔽,Vercel 的 serverless 环境中,Prisma 引擎默认会尝试下载 OpenSSL 相关依赖,但 Vercel 的运行时环境不是标准的 Linux 环境,版本不匹配会导致部署后 API 全部 500。解决办法是在 package.json 里指定 Prisma 的引擎协议为 DataProxy 或者二进制版本,更省事的方法是在项目设置里把 Vercel 的函数 region 指定到离数据库较近的机房东,但这只解决延迟,不解决兼容。我实际解决方式是安装了 prisma 的对应引擎依赖包,让 Postinstall 阶段完成引擎构建。
语义上注意,如果你部署在 Vercel 上且数据库在其他云厂商的 MySQL 实例上,务必检查数据库的防火墙规则是否放行 Vercel 的出口 IP。Vercel 的 IP 范围官方有文档,但第一次配置的人很容易踩到连不上的坑,我就是费了半天时间,最后发现是数据库白名单没加。
4. 常见问题与排查技巧实录
4.1 TypeScript 类型报错的三个典型场景
类型安全是 t3code 的优势,但代价是类型报错也会比普通项目更多一些。我自己开发过程中遇到最多的三类问题:
第一类是 zod input 类型和数据库字段不匹配。比如前端传 language 字段时是 string,但后端数据库里这个字段默认值可能是空字符。遇到这种情况,两个原则:input 侧的 zod 加上 optional/empty 处理,数据库侧取 data 时做 ?? "javascript" 兜底,两边都留好退路,类型和运行时就都能稳住。
第二类是 mutation 返回值里没有 include 关联对象。开发时最容易出错的操作是 create 之后马上 read.tags,但如果 create 时没有 include tags,返回的 tags 就是 undefined。这类问题一旦出现,编译期不一定能发现,因为返回类型里的 tags 在 Prisma 生成的类型中是可选的。我的排查习惯是,统一在写操作里把 include 写全,宁肯多传输一点数据也不在业务代码里做空值判断。
第三类问题是 tRPC 中 ctx.db 的命名冲突。你如果在 prisma schema 里定义了 db 这个 model,又创建了 db 这个 PrismaClient 变量,两个类型在自动推导时会互相干扰。建议 PrismaClient 常量命名用 db,模块导入用 PrismaClient 类型,避免一个作用域里同时出现两个 db 语义。
4.2 tRPC 的 404 和 401 表现不明显,到底怎么排查
传统的 REST API 里,404 和 401 由 HTTP 状态码直接反馈,但在 tRPC 中,状态码的区分没有 REST 那么直观。tRPC 在整个请求层统一返回 200,然后内部通过 error.code 区分错误类型,这也是很多人第一次接触 tRPC 会摸不着头脑的地方。
排查技巧其实很简单。打开浏览器的 Network 面板,找到请求载荷,里面会有一个 error 对象,它的 code 字段就是真正的错误类型。比如 NOT_FOUND、FORBIDDEN、INTERNAL_SERVER_ERROR 等等。所以 t3code 里分享链接失效我抛 NOT_FOUND,前端拿到之后显示"链接不存在或已过期",体验上比 REST 的裸 404 页面好很多。
另外提一句,开发环境调试 tRPC 的请求参数,我建议直接启用 tRPC 的 logger 中间件,它会把你每个请求的路径、耗时、输入输出都打印在终端里。这个对排查开发阶段的 bug 帮助特别大。
4.3 Prisma 在 serverless 环境下的连接池问题
前面聊过 Vercel 部署时的 Prisma 兼容问题,这里再补充一个实际高并发下的坑。Serverless 每个函数实例都维护了一个数据库连接池,如果冷启动频繁,连接池会反复创建销毁,MySQL 的连接数上限很容易被打满,导致后续请求全部拒绝连接。
解决方式有两个方向。一种是使用 Prisma Accelerate 这种连接池代理服务,让数据库的连接管理从 serverless 隔离出来,适合正式商业项目。另一种是 t3code 这种工具类项目,可以把数据库放在有固定进程的主机上,比如一台小型的轻量应用服务器,让 Next.js 以 Node 模式而不是 serverless 模式运行,这样连接池常驻内存,成本更低也更稳定。
我个人当时为了省服务器费用走的 Vercel,后来遇到一次连接数满的问题之后就无所谓了,直接把 t3code 的部署切到了一个低配 Node 服务器上,效果反而更稳定。如果你只是为了个人使用,这个方向值得参考。
4.4 常见问题速查表:有症状可以直接对号入座
这阵子开发和维护 t3code 的过程中,我顺手整理了一份问题速查表,读者做类似项目时可能直接对号入座。
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 列表页数据加载很慢 | 查询返回了 content 大文本字段 | 改为 select 指定字段 |
| 页面部署后发请求全部 500 | Prisma 引擎和 serverless 环境不兼容 | 切换引擎协议或在 Node 模式下部署 |
| 标签重复出现多条 | upsert 的 where 没有走唯一键名 | 检查 Tag 表 name 字段是否加了 @unique |
| 创建片段后 tags 列表为空 | create 时忘了 include tags | 在返回参数里加 include: { tags: true } |
| 分享链接一访问就报错 | Share 关联的 snippet 已被删除 | 确认 onDelete: Cascade,或者前端做好 404 展示 |
| 刷新页面后高亮代码闪白 | shiki 异步加载语言 chunk | 对常用语言做本地预打包 |
这张表是我自己的踩坑合集,看起来简单,但每一条都对应了一段真实的排错时间。新手阶段最怕的是不知道"问题出在哪里",有了表至少能快速锁定方向。
5. 收尾:一些补充与后续想法
如果让我总结 t3code 这个项目最值得复用的经验,我的体会是:类型安全不是选型上的时尚,而是真正能减少维护成本的工程决策。t3code 的核心逻辑大致七千行代码,但因为它从数据库到前端全部共用一套类型推导,我几乎没有因为字段改名或接口变更做过多余的同步修改,整体维护负担比之前做的 REST 项目低了非常多。
我实际使用过程中最顺手的一个小特性是全局搜索。因为片段表的 content 存在 MySQL 里,我原本打算引入 Elasticsearch 做搜索,但后来发现个人几百条片段的体量根本不需要。直接 WHERE content LIKE '%keyword%' 就够用了,甚至加了前缀索引之后响应时间不到 50 毫秒。所以做个人工具时,先想想自己的数据量级,再决定要不要上重型组件,这是 t3code 教给我的另一个重要经验。
后续如果继续迭代,我有几个想法放在 backlog 里。给片段增加代码压缩和复制按钮,目前复制功能需要自己选中内容,体验还能优化;再接一个浏览器书签小工具,在浏览技术文档时把看到的代码一键存进 t3code,减少复制粘贴的损耗;最后是做一个公开片段展示页,让用户可以把常用的代码段发布出去形成一个小型代码广场。这些功能按优先级慢慢排着来,但不管怎么发展,类型安全这个地基已经打牢了,上面想盖什么楼都不会塌。