news 2026/10/14 6:23:09

T3栈实战:从零构建自托管代码片段工具t3code

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
T3栈实战:从零构建自托管代码片段工具t3code

1. 为什么做 t3code:被碎片化笔记逼出来的练手项目

t3code 是我最近花了两周业余时间做完的一个代码片段收集工具,整体基于 T3 栈(Next.js + TypeScript + tRPC + Tailwind CSS + Prisma)搭建。起因很简单:我的本地代码碎片已经多到检索成本失控,而我翻了半天那些散落在各个文件夹里的代码,最后还是在聊天记录里找到的。与其继续忍,不如自己动手。

1.1 需求来源:一个"临时文件夹"引发的自救

写代码这些年,我养成了一个坏习惯:看到一段有用的代码,第一反应就是复制到本地某个文件里。文件名叫"新建文本文档(3).txt",放在一个名为"临时"的文件夹里,这种场景估计不少同行都不陌生。时间一长,这些碎片就变成了死数据——想找的时候翻半天,翻到了又缺上下文,忘了当初为什么存它、它依赖什么环境、适用什么版本。

我试过用本地 Markdown 按目录分类,也试过各种在线 snippet 服务,但要么搜索太弱,要么标签体系太重,要么数据不在自己手里,哪天平台改版就全没了。真正让我下决心自己写工具的,是一次找七个月前写过的"带重试的 fetch 封装":我在五个地方翻了两小时才找到。那一刻我就明白了,我需要的是一个轻量、自托管、能快速存取代码碎片的工具。

所以 t3code 的定位从头到尾都很清楚:不做知识库,不做博客,不做社区,只做三件事——快速保存一段代码、打上标签方便检索、一键复制回剪贴板。评论、点赞、社交分享这些功能一概不做。需求收敛到这个程度,项目才不会做烂尾。

1.2 选型动机:为什么明知学习曲线陡,还是选 T3 栈

这次选型我是故意的。之前做过的几个项目都是"前后端分离 + 手写 REST API + 联调文档"的套路,对这种模式下的类型断裂已经忍了很久:前端改了接口字段,后端没跟上,编译期什么都不报,运行时才炸,炸了还得靠肉眼对接口文档。

T3 栈的核心卖点恰恰就是端到端类型安全。用它做的事,是把"前端调用"和"后端实现"绑在同一套 TypeScript 类型系统里。我改了一个 tRPC 路由的入参类型,前端所有调用点立刻飘红,编译阶段就能抓到,而不是等上线后由用户触发 500。对我来说,这个吸引力足以让我接受它的学习成本。

当然,T3 栈不是什么银弹,它适合的是"数据密集型、CRUD 为主、表单交互多"的 Web 应用,恰好 t3code 就是这种形态。如果你要做的是重客户端交互的编辑器、实时协作工具,那这套组合未必舒服。这个结论是我把项目做完之后才真正体会到的,后面我会专门展开说。

2. T3 栈的组合逻辑:五件套各自负责什么

先给不了解 T3 栈的读者画个轮廓:它通常包含 Next.js 做应用框架、TypeScript 做类型系统、tRPC 做 API 层、Tailwind CSS 做样式、Prisma 做数据库访问,可选地加上 NextAuth 做认证。这五件套不是随便拼在一起的,每一环都有明确的职责边界,理解了它们怎么咬合,你才能用好这套东西。

2.1 Next.js App Router 与 tRPC 的职责切分

很多人一开始会困惑:Next.js 自己就能写 API Route,为什么还要引入 tRPC?我的理解是,Next.js 的职责是"页面怎么渲染",tRPC 的职责是"数据怎么安全地流动"。两者有交集,但重点不同。

在 t3code 里,页面骨架和数据预取用了服务端组件,比如 snippet 列表页的初始数据,我在 Server Component 里调用了 tRPC 的 server caller 直接查库,页面首屏就是完整 HTML。而所有需要用户交互触发的数据变更——新增 snippet、改标签、删除记录——都走 tRPC mutation,由客户端组件发起。

实际操作中,你要在app/api/trpc/[trpc]/route.ts里创建一个 Next.js Route Handler,把 tRPC 的请求转发到后端处理函数。这个文件是两者衔接的枢纽:

import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; import { appRouter } from '@/server/api/root'; import { createTRPCContext } from '@/server/api/trpc'; const handler = (req: Request) => fetchRequestHandler({ endpoint: '/api/trpc', req, router: appRouter, createContext: createTRPCContext, }); export { handler as GET, handler as POST };

这段代码本身不难,难的是理解 Context 是怎么把数据库实例、当前会话信息注入到每个路由处理函数里的。我建议新手把createTRPCContext里的headers()提取和db实例初始化单独抽出来看,这是整个 tRPC 调用的起点。

2.2 Prisma 数据建模:从需求到 schema.prisma

t3code 的数据模型很朴素,但设计时我还是认真想过的。核心表就三张:用户表、片段表、标签表,外加一个多对多的关联表。

model User { id String @id @default(cuid()) name String? email String? @unique image String? snippets Snippet[] tags Tag[] } model Snippet { id String @id @default(cuid()) title String content String language String @default("plaintext") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt authorId String author User @relation(fields: [authorId], references: [id]) tags SnippetTag[] } model Tag { id String @id @default(cuid()) name String authorId String author User @relation(fields: [authorId], references: [id]) snippets SnippetTag[] @@unique([authorId, name]) } model SnippetTag { snippet Snippet @relation(fields: [snippetId], references: [id], onDelete: Cascade) tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) snippetId String tagId String @@id([snippetId, tagId]) }

几个关键决策想说明一下。语言字段language我没用枚举而是普通字符串,因为代码高亮会涉及的语言列表很长,用枚举反而要频繁迁移表结构。Tag上加了@@unique([authorId, name]),保证同一个用户的标签不会重复,但不同用户可以有同名标签——这是多租户设计里很容易忽略的点。关联表里两个外键都加了onDelete: Cascade,删 snippet 时自动清掉关联记录,不然会有孤儿数据。

2.3 Tailwind 与 shadcn/ui:写样式的节奏感

样式部分我用的 Tailwind CSS 加上 shadcn/ui 的组件。说实话,用 Tailwind 的头两天我是有点抵触的,类名一长串,HTML 看起来像被 JS 压缩过。但用顺手之后发现它的优势:不需要在 CSS 文件和各种 class 命名之间来回跳,改样式就在元素上改,效率确实高。

shadcn/ui 我主要拿来做了下拉菜单、弹窗、输入框这些基础组件。它的特点是组件代码直接复制进项目而不是装成 npm 包,所以你可以完全掌控样式,不会被库的内部实现卡住。我在 t3code 里给代码片段详情做了个弹出式预览,用的就是 shadcn 的 Dialog,改动起来非常顺手。

有个细节值得提:Tailwind 的暗色模式。默认情况下 Tailwind 的dark:变体是根据系统偏好判断的,但 t3code 需要用户手动切换,所以我在tailwind.config.ts里设置了darkMode: 'class',然后写了一个小的ThemeToggle组件,把主题状态存到localStorage,并在html根节点上切换dark类。这样写比默认方案多花十分钟,但用户体验完全不一样。

2.4 NextAuth 登录:第三方授权的接入记录

认证层面我选了 NextAuth,主要图它接入 GitHub 授权登录足够省事。配置里设置好 client ID 和 secret,在app/api/auth/[...nextauth]/route.ts里挂上相关配置即可。

踩过的坑有两个。第一个是回调地址:本地开发时回调地址填http://localhost:3000/api/auth/callback/github,部署到线上后必须改成线上域名,否则授权会一直报 redirect_uri 不匹配。第二个是会话策略:t3code 这种场景我用了 JWT session 而不是数据库 session,因为每个请求都要在读库之前确认用户身份,JWT 省了查询开销,但要注意 JWT 里不要塞太多数据,存个 id 和名字就够了。

3. 核心功能落地:t3code 的 CRUD、标签与检索

如果说前面是搭骨架,那这一章就是填肉。t3code 真正让我花时间思考的不是"能不能增删改查",而是怎么在类型安全的前提下,把查询、标签、搜索这些功能组织得干净。

3.1 tRPC 路由划分:按资源拆,还是按场景拆

tRPC 的路由设计有两种常见思路:按数据库资源拆(snippet router、tag router),或者按业务场景拆(listSnippets、createSnippet)。我最后用的是两者混合——基础 CRUD 按资源拆,跨资源的复杂查询单独拆一个 search 路由。

export const snippetRouter = createTRPCRouter({ list: publicProcedure .input( z.object({ page: z.number().int().min(1).default(1), pageSize: z.number().int().min(1).max(50).default(10), tagId: z.string().optional(), keyword: z.string().optional(), }) ) .query(async ({ ctx, input }) => { const where = buildWhereClause(input, ctx.session?.user?.id); const [items, total] = await Promise.all([ ctx.db.snippet.findMany({ where, include: { tags: { include: { tag: true } } }, orderBy: { createdAt: 'desc' }, skip: (input.page - 1) * input.pageSize, take: input.pageSize, }), ctx.db.snippet.count({ where }), ]); return { items, total, page: input.page }; }), });

这里有个小技巧:把 where 条件的构建抽成buildWhereClause函数,因为list和search两个 query 都用到相同的过滤逻辑,不抽的话代码会重复。类型上,input经过 zod 校验后是精确推断出来的,所以在buildWhereClause里拼 Prisma 查询条件时,字段名错了、类型不匹配,编译器都会报错。

3.2 多标签系统的存取逻辑

标签的存取是最能体现"多想一步"价值的地方。保存一个 snippet 时,用户可能输入好几个标签,有的标签已存在,有的得新建。我的做法是:先按当前用户查已存在的标签名,把新的批量创建,然后建立关联。

const saveTags = async (db: PrismaClient, authorId: string, tagNames: string[]) => { const uniqueNames = Array.from(new Set(tagNames.map((t) => t.trim()).filter(Boolean))); const existing = await db.tag.findMany({ where: { authorId, name: { in: uniqueNames } }, select: { name: true }, }); const existingNames = new Set(existing.map((t) => t.name)); const newNames = uniqueNames.filter((n) => !existingNames.has(n)); if (newNames.length > 0) { await db.tag.createMany({ data: newNames.map((name) => ({ name, authorId })), }); } const allTags = await db.tag.findMany({ where: { authorId, name: { in: uniqueNames } }, select: { id: true, name: true }, }); return allTags.filter((t) => uniqueNames.includes(t.name)); };

注意createMany是批量创建,设标签时如果一次性提交几十个,性能上比逐个 create 好很多。createMany在 SQLite 的 Prisma 版本支持可能受限,所以在本地开发时要注意数据库方言的差异,后面章节我会专门说这个。

3.3 全文搜索的朴素实现

t3code 这个体量,上 Elasticsearch 或者上 pg 的全文索引都属于杀鸡用牛刀。我的搜索策略就是:标题模糊匹配 + 内容模糊匹配 + 标签命中,三个条件用 OR 连接,关键词拆分成词后用contains逐个匹配。

const buildWhereClause = (input: { keyword?: string; tagId?: string; }, userId: string) => { const conditions = [{ authorId: userId }] as Prisma.SnippetWhereInput[]; if (input.keyword) { const keywordWords = input.keyword.trim().split(/\s+/); conditions.push({ OR: keywordWords.map((word) => ({ OR: [ { title: { contains: word } }, { content: { contains: word } }, { tags: { some: { tag: { name: { contains: word } } } } }, ], })), }); } return { AND: conditions }; };

这个方案的优点是简单直接,缺点是数据量到几千条之后会变慢。到那时候再切 PostgreSQL 的全文检索也不迟,Prisma 支持search查询模式,迁移成本不算高。我个人的原则是:不在项目早期为用不上的性能做复杂设计。

3.4 代码高亮与纯客户端组件的边界

代码高亮我用的 Shiki,而不是 Prism。原因很实在:Shiki 的 highlighter 是基于 TextMate 语法的,各种语言的语法覆盖率比 Prism 高不少,而且可以直接在服务端调用,生成纯 HTML 返回给浏览器,不用加载一堆 JS。

但实际上我把代码展示组件设成了'use client',因为要处理复制按钮的点击事件和暗色模式切换。这里有个 Next.js 开发中容易犯的错:整个组件加'use client'之后,Shiki 的高亮逻辑如果能放服务端就别放客户端,否则打包体积会暴涨。我的做法是:高亮在服务端完成,把 HTML 字符串传给客户端组件,客户端只负责展示和绑定事件。

// 服务端获取高亮后的 HTML const highlighted = await shiki.highlight(code, { lang: language, theme: 'github-dark' });

这个"在服务端做重活,在客户端做交互"的思路,是 App Router 下写组件的关键准则。凡是纯展示的、计算结果不依赖浏览器 API 的内容,都尽量在服务端算好。

4. 部署与线上排查:从"本地能跑"到"生产可用"

每次项目做到"本地能跑"的时候,我都会觉得胜利在望,然后被现实教育。t3code 上线过程中遇到的三个问题,我觉得值得单独写一节,因为它们非常典型。

4.1 种子数据与本地开发环境

t3code 的本地开发环境,我用的是 SQLite 数据库,也就是 Prisma schema 里的 provider 暂时设为sqlite。好处是零配置,一个文件就能跑起来。代价是很多只支持 PostgreSQL 的特性不能用,比如createMany在旧版 SQLite provider 下不支持,我在写第 3 章的标签逻辑时就踩到了。

种子数据我用了一个独立的 seed 脚本,配合tsx来跑:

npx prisma db push npx tsx prisma/seed.ts

db push和db migrate的区别值得说一句:db push适合快速同步 schema 到数据库,适合开发期;正式环境还是建议用migrate生成迁移文件,否则生产环境的表结构变更没有记录,出问题很难回滚。

4.2 构建期类型检查与打包错误

next build是每个 Next.js 项目的"照妖镜"。我的第一次构建失败,原因是 ESLint 规则把一段包含any的代码拦住了。具体是 tRPC 的ctx.session?.user?.id在某个分支里被推断成了any,我图省事没有显式标注类型,结果 eslint 的no-explicit-any直接报错。

修法也很简单:在 zod input 里把tagId、keyword这些字段都显式声明类型,确保buildWhereClause的参数是确定的。这里我学到的是:T3 栈把类型安全做得很严是好事,但你自己写的代码如果到处any,等于把这条防线亲手拆了。

4.3 一个 500 错误的完整排查链路

上线第二天,用户反馈:在某个标签页下点击 snippet 详情,页面直接 500。我的第一反应是查看部署平台的日志,但生产日志只显示Error: PrismaClientKnownRequestError ...,具体信息不全。排查过程是这样的:

第一步,我在本地用同样的数据路径复现。先看标签页能正常打开,说明list查询没问题;再点详情,本地居然没有复现。这就说明问题跟环境相关,很可能出在数据库方言差异上。

第二步,我怀疑是 Prisma 的分页查询里用了skip和take,SQLite 和 PostgreSQL 在 offset 行为上没有差异,所以排除。

第三步,我去看生产环境数据库的关联数据。发现标签页详情里,某个 snippet 的language字段存了一段很长的文本——我在某个版本里把"language 自动识别"的测试数据写进去了,而 Shiki 拿这个不存在的语言名去loadLanguage,抛了异常。

根因就是:脏数据 + 高亮库的容错缺失。修复是我在 Shiki 调用前加了一层白名单校验:语言名不在内置列表里就回退到plaintext。这个修复提醒我,任何依赖外部配置的库,都要对输入数据做校验,不能假设数据永远干净。

4.4 数据库从 SQLite 切到 PostgreSQL

本地开发用 SQLite,线上用 PostgreSQL,这是不少小项目的常见配置,但切换过程有个坑:Prisma 的provider从sqlite改成postgresql之后,之前生成的客户端是不认账的。你必须重新执行:

npx prisma generate npx prisma db push

generate是重新生成 Prisma Client,db push是同步数据库结构。如果你在 schema 里用了 SQLite 特有的类型,比如某些字段的@db.Text,切换后要检查兼容性。我实际遇到的问题是:String类型在 SQLite 下默认没有长度限制声明,在 PostgreSQL 下会映射成text,这没问题;但如果哪天某个字段声明了@db.VarChar(255),而你的数据长度超过了 255,写入就会报错。建议在切换之后,把关键路径的增删改查都跑一遍冒烟测试。

5. 项目做完之后,我对 T3 栈适用边界的重新判断

t3code 做完,我的收获不只是这个工具本身,更是对 T3 栈的适用边界有了切身体会。有些感受是看文档看不出来的。

5.1 类型安全带来的"重构安全感"

最直观的体验是重构效率。我中途把 snippet 列表的分页从"前端传 page/pageSize"改成了"用 cursor 游标分页",虽然字段变了,但所有调用方在编译期就全部暴露了问题。改完一个 tRPC route,所有用了旧字段的地方全部飘红,照着改一遍就完事,根本不需要在前后端之间反复确认。

这种"重构安全感"是 T3 栈最值钱的部分。如果你维护过一个半年以上的全栈项目,一定经历过"改接口字段、前端忘了适配、上线后数据不显示"这种事。T3 栈用类型系统把这个风险前置到了编译期,省掉的是整个团队在联调时消耗的时间。

5.2 它不适合的场景:重客户端交互与实时推送

但我也要说实话,T3 栈不是万能药。如果你要做的是一个类似在线代码编辑器的产品,前端需要大量本地状态、实时协作、WebSocket 推送,那 Next.js 加 tRPC 这套组合并不会帮你太多——这些场景的核心在客户端,服务端只是数据供给方,类型安全带来的收益会被复杂的客户端状态管理稀释。

另外,如果你的团队已经有一套成熟的 REST API 和对应的 SDK 工具链,那引入 tRPC 需要权衡:它和 OpenAPI 生态不互通,也没有现成的跨语言客户端。纯 TypeScript 全栈团队用 T3 栈很香,但要接第三方、要对外开放 API,还是建议单独维护一套接口层。

5.3 t3code 的后续方向

t3code 目前已经跑起来了,我的使用频率明显高于之前的任何一款 snippet 工具。接下来的规划有两个方向:一是给代码片段加 AI 摘要,在保存时自动生成一段"这段代码解决什么问题"的描述,解决我现在"存了但忘了为什么存"的问题;二是做一个浏览器插件,在网页上选中代码后右键直接存入 t3code,配合已有的标签体系,形成一个顺手的内容收集闭环。

这两个功能都不大,但能把 t3code 从"手动搬运工"变成"半自动收割机"。等这两个功能都上线之后,我再回来分享一次实战经验。

最后分享一条个人体会:做工具类项目,最怕的不是技术难,而是需求发散。t3code 之所以能在两周内做出来,恰恰是因为我一开始就把"不做社区、不做评论、不做知识管理"写在了项目 README 的顶部。每一次想加功能的时候,先问一句:这功能会不会让 t3code 偏离"代码碎片收集器"五个字的定位?问完之后,大部分需求自己就消失了。剩下的,才是真正值得写进代码里的东西。

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

生成式AI教育教学应用指引1.0版深度拆解:从场景边界到落地实操

1. 从一份教学指引说起:生成式AI进课堂到底要解决什么问题第一次看到这份《生成式人工智能教育教学应用指引1.0版》的时候,我的直觉是:终于有人把这件事从"讨论该不该用"推进到了"具体怎么用"。过去两年多,我…

作者头像 李华
网站建设 2026/10/14 6:22:46

RTSP转SIP流媒体网关:基于live555的协议桥接与RTP转发实战

1. 项目背景与核心需求拆解1.1 这个网关到底解决什么问题做过安防监控或者视频会议系统集成的朋友大概率都遇到过这种局面:手头一堆网络摄像机、编码器,它们清一色只认RTSP协议,拉流地址就是那种rtsp://ip:554/stream的形式;可对接…

作者头像 李华
网站建设 2026/10/14 6:22:45

程序员过劳猝死预警:身体出现这10个信号必须马上停下

你上一次睡醒之后觉得“满血复活”,是什么时候?如果这个简单的问题让你突然愣住,那我先建议你把这篇内容看完。程序员是特别擅长把身体异常合理化的群体。熬夜改完一个 bug,胸口闷两下,“没事,就是累了”&a…

作者头像 李华
网站建设 2026/10/14 6:22:45

AI IDE与命令行之争:上下文边界如何重塑编程工作流

最近我把用了半年的某款AI IDE收进了"偶尔打开"的列表,主力开发环境重新回到了终端。不是因为它不好用,恰恰相反——正是在它身上尝够了AI编程的甜头,我才开始认真审视那些被IDE包装得光鲜亮丽的交互,到底有多少是在替我…

作者头像 李华
网站建设 2026/10/14 6:22:36

AnyPS5串流实战:跨设备远程游玩PS5的完整配置与优化指南

1. 从“AnyPS5”这个名字说起:它到底想解决什么问题第一次看到“AnyPS5”这个项目名,我脑子里蹦出来的第一个念头是:这大概率是个跟 PlayStation 5 沾边的工具,而且“Any”这个前缀暗示了它的核心卖点——跨平台、跨设备、跨场景的…

作者头像 李华
网站建设 2026/10/14 6:22:17

U盘OpenClaw即插即用:便携自动化环境搭建与避坑指南

简介:这是一套面向需要跨设备便携使用 OpenClaw 的用户打造的 U 盘即插即用方案,解决在他人电脑或临时环境中快速调用工具、又不想安装软件或留下使用痕迹的问题。资源包共约 2000 个文件,以 1203 个 md 文档、721 个 js 脚本、69 个 json 配…

作者头像 李华