☰
T3 Stack 实战:构建类型安全的全栈代码片段管理工具
2026/10/27 23:08:24 网站建设 项目流程

这几年做全栈项目,我越来越认同一句话:技术选型直接决定开发幸福感。t3code 是我用 T3 Stack 完整搭建的一套全栈应用,从名字也能看出来,这是个跟代码强相关的项目——简单说,它是一个给开发者自己用的代码片段与学习笔记管理工具。做它的初衷很朴素:平时写过的零散代码片段、教程笔记、解决方案草稿,经常分散在本地文件、备忘录和好几个仓库里,等到要用的时候翻半天都找不到。T3 Stack 不是一个新框架,而是一套以类型安全为核心的全栈组合拳,主角是 Next.js、TypeScript、tRPC、Prisma、Tailwind CSS,再配合可选的 NextAuth 认证。如果你正准备做一个带数据库、带用户体系的 Web 应用,又不想在前后端接口联调上反复折腾,这篇文章就是一条可以直接照做的完整路线。

1. 项目定位与技术选型:为什么兜兜转转还是选了 T3 Stack

1.1 t3code 到底想解决什么问题

先说业务场景。我自己的日常开发里有三个老毛病:第一,某个组件或函数写得特别好,三个月后想复用,但完全忘了放在哪个项目里;第二,看教程时候记的笔记散落各处,没有统一的标签体系,检索基本靠回忆;第三,想给某个旧项目补功能时,经常要翻好几个仓库才能找到当时的实现思路。

t3code 就是冲着这三个问题去的。它有几个核心模块:代码片段管理,支持标题、语言标签、代码内容;学习笔记管理,记录过程心得和踩坑经验;统一的标签系统,把代码片段和笔记用标签串联起来;全文检索,一分钟内找到想要的内容。当然还有最基础的用户体系,登录后每个人只看到自己的数据。

这个项目形态很适合做 T3 Stack 的练手和落地。它不像电商系统那样有复杂的订单状态机,也不像后台管理系统那样重表单重权限,但它把全栈应用的典型要素都覆盖了:数据库建模、用户认证、增删改查、一对多和多对多关系、搜索筛选、前端状态管理、部署运维。换句话说,把 t3code 完整跑通一遍,你对这套技术栈的理解基本就到位了。

1.2 组合拳的取舍:T3 Stack 为什么省心

先说结论:T3 Stack 最大的价值不是某个单一框架多强,而是整条链路的类型安全。数据库表结构由 Prisma Schema 定义,类型直接生成;服务端接口由 tRPC Router 定义,输入输出自动推导;前端组件拿到的是类型完整的响应数据。从数据库字段到页面渲染,TypeScript 类型像一条线贯穿始终,改一个字段名,从 schema 到前端组件一路报错,根本轮不到运行时才发现问题。

这听起来很理想,实际体验也确实如此。举个具体例子:以前用 REST API 做前后端分离时,后端改了接口字段,前端要自己改 interface、改请求逻辑、改页面渲染,稍有遗漏就出现线上 bug。而在 t3code 里,我改了 Prisma 模型新增一个字段,前端调api.note.getAll.query()拿到的数据自动就有这个字段,编辑器里直接提示补全,还需要手动维护一份 API 类型定义吗?完全不需要。

对比一下更直观:

方案类型链路额外工作适合场景
Next.js API Routes + REST前端手写 interface,容易漂移需要维护 OpenAPI 或手写类型简单项目、第三方 API 对接多
GraphQL + Codegen类型由 Codegen 生成,比较完整需要维护 schema 和 resolver多端复用、查询复杂
tRPC路由定义即类型定义几乎为零全栈自用、快速迭代

tRPC 的核心思路是“共享类型定义”。前端直接引用后端 Router 的类型,编译器替你把接口契约的维护工作做了。有人会担心 tRPC 能不能应对复杂查询,我的经验是:在项目内部用足够,遇到报表类复杂聚合也可以直接用 SQL 视图或额外接口,不必把所有负担放在一个篮子里。

另外,Next.js 的 App Router 和 Server Component 在这个组合里也起了大作用。服务端组件里直接调用 tRPC 查询,页面首屏数据一次性拿全,不需要客户端再发一轮请求,也不会有瀑布流请求的等待问题。Tailwind CSS 则是把样式开发速度拉满的工具——写 t3code 的时候我几乎没有写过自定义 CSS 文件,全部是原子类组合。

2. 环境准备与项目初始化:从零把 t3code 跑起来

2.1 本地依赖与数据库准备

T3 Stack 对本地环境的要求不算苛刻,但有些小坑值得提前说。我建议的版本组合如下:

工具建议版本说明
Node.js20.x LTS避免 18 以下,App Router 和 tRPC 都需要较新的运行时
包管理器pnpm 9.xT3 Stack 官方脚手架的默认选项就是 pnpm,npm 也可以但锁文件不同
PostgreSQL15+本地用 Docker 装最省事,一条命令搞定
Docker任意较新版本本地数据库容器化,项目删了数据库也跟着干净

我一般在本地用一个 Docker Compose 文件专门跑 PostgreSQL,开发完一键停止,不污染系统环境。如果你不想用 Docker,直接装 Postgres 也行,但要注意数据库版本最好 14 以上,Prisma 有些新特性依赖高版本。

创建好数据库后,需要准备环境变量。create-t3-app 初始化完成后会自动生成一个.env文件,里面最关键的是DATABASE_URL。在开发环境我用的是postgresql://postgres:postgres@localhost:5432/t3code?schema=public这种格式。注意 Prisma 的连接串格式和普通连接串略有差异,schema=public这个参数要保留,否则 Prisma 会默认找publicschema,严格来说会自动匹配,但显式写出来更保险。

2.2 create-t3-app 初始化全流程

T3 Stack 官方提供了完整的脚手架命令,这是我在这类项目里最推荐的方式,因为它把所有约定都帮你建好了,不需要自己拼装。打开终端,切到你想放项目的目录,执行:

pnpm create t3-app@latest t3code

接下来会有一系列交互选项,我逐个说下我当时的选择:

  • TypeScript 或 JavaScript:选 TypeScript,这是 T3 Stack 的灵魂。
  • 是否使用 ESLint:选是,后面写代码自动检查会省很多事。
  • 是否使用 Tailwind CSS:选是,前面已经说了它的价值。
  • App Router 还是 Pages Router:选 App Router,这是当前 Next.js 的主流方向。
  • 是否使用 tRPC:选是,这是 T3 Stack 的核心。
  • 是否使用 NextAuth.js:选是,后面需要用户体系。
  • 是否使用 Prisma:选是,数据库 ORM 就靠它。
  • 是否包含 Docker Compose 文件:如果你是本地开发,选是也无妨,可以直接用它跑数据库。

初始化完成后进入项目目录,先跑一个pnpm install装上依赖,然后改.env里的DATABASE_URL为本地数据库连接串。这里有个容易踩的坑:初始化生成的.env文件里DATABASE_URL通常是一个默认示例值,比如file:./db.sqlite或者一个带假密码的 Postgres 地址,千万别直接拿去跑下一步,必须改成你的实际连接串。

改完执行:

pnpm db:push

它会用 Prisma 把你的 schema 同步到数据库里。create-t3-app自带的 schema 里已经有一个示例Post模型,我们第 3 步会把它替换成 t3code 自己的模型。然后启动开发服务器:

pnpm dev

浏览器打开http://localhost:3000,如果看到一个默认的欢迎页面,脚手架工作正常。此时整个目录结构已经包含了 Next.js 14 的 App Router、src/server/api下的 tRPC 路由、src/server/db.ts的 Prisma 实例、src/trpc的客户端和服务端封装,结构比裸手搭要干净许多。

3. 数据模型与后端接口落地

3.1 Prisma 模型:把笔记、标签和代码片段串起来

业务模型是 t3code 的地基。我先梳理一下实体关系:每个用户有多个代码片段、多篇笔记;代码片段和笔记都可以被打多个标签,标签也可以挂在多个内容下,所以笔记和标签是多对多关系。Prisma 里定义多对多关系,推荐用隐式多对多表(implicit many-to-many),Prema 会自动创建关联表,不需要手动管理。

我最终的 Schema 核心部分长这样:

model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? notes Note[] snippets CodeSnippet[] } model Note { id String @id @default(cuid()) title String content String @db.Text language String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) tags Tag[] @relation("NoteTags") } model CodeSnippet { id String @id @default(cuid()) title String code String @db.Text language String note String? @db.Text createdAt DateTime @default(now()) updatedAt DateTime @updatedAt userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) tags Tag[] @relation("SnippetTags") } model Tag { id String @id @default(cuid()) name String @unique notes Note[] @relation("NoteTags") snippets CodeSnippet[] @relation("SnippetTags") }

几个关键设计点,想多说一下:

id我用的是cuid()而不是自增 ID。自增 ID 会暴露业务增长量,比如让别人知道你注册了第几个用户,在某些场景下有信息泄露风险;cuid 在所有分布式环境下不需要协调就能生成,对本地开发和未来扩展都很友好。

content和code这两块承载大量文本的字段,我特意加上了@db.Text,这是因为 Postgres 默认text类型可以存储字符串,但如果不显式声明,Prisma 在某些版本里生成的是varchar(191),会有长度上限。笔记稍微长一点就直接报错,这个坑我踩过一次。

Note和CodeSnippet都加了onDelete: Cascade,用户删除账号时关联数据自动清理。注意@relation的写法要把外键关系写清楚,否则 Prisma 的关联迁移生成会报错。

写完 Schema 后执行:

npx prisma migrate dev --name init

它会在数据库里生成迁移文件,并生成 Prisma Client 类型。从这一刻起,后面每处代码能拿到什么类型,都已经被 Prisma 约束住了。

3.2 tRPC Router:类型安全接口的核心实现

T3 Stack 的 tRPC 代码组织在src/server/api目录下。create-t3-app默认给了trpc.ts作为创建调用过程的工厂函数,里面有公开过程和受保护过程的定义。我来定义一个笔记相关的 Router,用受保护过程确保只有登录用户能写笔记:

// src/server/api/routers/note.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "../trpc"; export const noteRouter = createTRouter({ create: protectedProcedure .input( z.object({ title: z.string().min(1, "标题不能为空"), content: z.string().min(1, "内容不能为空"), language: z.string().optional(), tags: z.array(z.string()).default([]), }) ) .mutation(async ({ ctx, input }) => { const note = await ctx.db.note.create({ data: { title: input.title, content: input.content, language: input.language, userId: ctx.session.user.id, tags: { connectOrCreate: input.tags.map((name) => ({ where: { name }, create: { name }, })), }, }, include: { tags: true }, }); return note; }), getAll: protectedProcedure.query(async ({ ctx }) => { return ctx.db.note.findMany({ where: { userId: ctx.session.user.id }, orderBy: { updatedAt: "desc" }, include: { tags: true }, select: { id: true, title: true, language: true, updatedAt: true, tags: true, }, }); }), getById: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { return ctx.db.note.findFirst({ where: { id: input.id, userId: ctx.session.user.id, }, include: { tags: true }, }); }), search: protectedProcedure .input(z.object({ keyword: z.string().min(1) })) .query(async ({ ctx, input }) => { return ctx.db.note.findMany({ where: { userId: ctx.session.user.id, OR: [ { title: { contains: input.keyword, mode: "insensitive" } }, { content: { contains: input.keyword, mode: "insensitive" } }, ], }, orderBy: { updatedAt: "desc" }, select: { id: true, title: true, language: true, updatedAt: true, tags: true, }, }); }), });

这中间有几个细节值得展开。

第一,每个操作都用了protectedProcedure,这是因为在trpc.ts里,受保护过程会自动把 session 塞进ctx,没有登录直接返回 401。前端调用这些接口时,未登录用户会统一收到未授权错误,不需要在每个路由里重复判断。

// src/server/api/trpc.ts 里的关键逻辑(脚手架已生成) protectedProcedure = procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: ctx.session } }); });

第二,connectOrCreate是处理多对多标签的关键。一个标签名如果不存在则创建,存在则直接关联,这个操作是原子的,省去了“先查再插”的两步逻辑。配合name字段上的@unique约束,并发创建同名标签时也不会出现重复数据。

第三,getById的where里同时带上userId,这是防止越权的标准写法。用户 A 拿用户 B 的笔记 ID 来查询,findFirst会因为条件不匹配直接返回 null,而不是把别人的数据暴露给他。这个习惯我用在 t3code 里的每一个查询和删除操作上。

Router 定义好后,别忘了在src/server/api/root.ts(脚手架里的聚合文件)里注册:

// src/server/api/root.ts import { noteRouter } from "./routers/note"; import { snippetRouter } from "./routers/snippet"; export const appRouter = createTRPCRouter({ note: noteRouter, snippet: snippetRouter, });

注册完以后,前端就能通过api.note.getAll.query()调用这些接口了,类型自动加载,根本不需要写第二个版本的参数定义。

4. 前端页面与交互实现

4.1 数据流设计:Server Component + tRPC 的配合方式

t3code 的页面结构分两块:第一块是首页/列表页,展示最近更新的片段和笔记;第二块是详情/编辑页,用户在里面增删改。这里我刻意用 Server Component 和 Client Component 做了一套分工,具体是:

  • Server Component 负责首次数据获取和页面骨架渲染。
  • Client Component 负责交互区域,比如表单、搜索框、标签点击。
  • 跨组件共享的状态用 React Query 的缓存来管理。

在 Server Component 里获取 tRPC 数据,create-t3-app 已经封装了一个api对象:

// src/app/page.tsx import { api } from "~/trpc/server"; export default async function HomePage() { const notes = await api.note.getAll.query(); const snippets = await api.snippet.getAll.query(); return ( <main className="mx-auto max-w-4xl px-4 py-8"> <h1 className="text-2xl font-bold">我的代码仓库</h1> <SnippetList initialData={snippets} /> <NoteList initialData={notes} /> </main> ); }

注意这里的~/trpc/server是脚手架生成的服务端调用封装,它内部会把请求直接路由到当前进程中的 tRPC 服务,不走 HTTP,非常高效。页面跳转时,Server Component 直接拿数据渲染,首屏没有 loading 闪烁。

对于需要交互的区域,比如启动搜索框,我用的是 Client Component + tRPC 客户端调用:

"use client"; import { api } from "~/trpc/react"; export function SearchInput() { const utils = api.useUtils(); const searchMutation = api.note.search.useMutation({ onSuccess: (data) => { utils.note.getAll.setData(undefined, (old) => data); }, }); const handleSearch = (keyword: string) => { searchMutation.mutate({ keyword }); }; return <input onChange={(e) => handleSearch(e.target.value)} />; }

这里api.useUtils()是 tRPC v11 的新写法,相当于拿到了一个可以操作缓存的对象。搜索成功时直接把结果写进getAll的缓存,列表组件会自动重新渲染,不用再做跨组件事件通知。React Query 的缓存让数据流变得非常线性:查到新数据,更新缓存,视图跟着变。

4.2 表单、代码编辑器与标签交互的落地细节

t3code 的表单没有用很重的 form 库,我选择的是react-hook-form配合zod做校验。因为 tRPC 的 input 本身就是 Zod schema,前端完全可以复用同一套类型约束,根本不会出现后端校验通过、前端校验不通过这种两套标准不一致的笑话。

"use client"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { api } from "~/trpc/react"; const noteSchema = z.object({ title: z.string().min(1, "标题不能为空"), content: z.string().min(1, "内容不能为空"), language: z.string().optional(), tags: z.array(z.string()).default([]), }); export function NoteForm() { const createNote = api.note.create.useMutation(); const { register, handleSubmit, setValue, watch } = useForm({ resolver: zodResolver(noteSchema), defaultValues: { title: "", content: "", tags: [] }, }); const onSubmit = (data: z.infer<typeof noteSchema>) => { createNote.mutate(data); }; return ( <form onSubmit={handleSubmit(onSubmit)}> <input {...register("title")} placeholder="标题" /> <textarea {...register("content")} placeholder="输入笔记内容" /> {/* 标签输入组件,点击后 setValue("tags", newTags) */} <button type="submit">保存</button> </form> ); }

代码编辑器这块我一开始用的是<textarea>,但代码片段的展示效果太差。后来换成了@uiw/react-codemirror,它轻量、支持多语言高亮,不到 20KB 的打包体积。关键配置是:

import CodeMirror from "@uiw/react-codemirror"; import { javascript } from "@codemirror/lang-javascript"; import { python } from "@codemirror/lang-python"; export function CodeEditor({ onChange, value }: { onChange: (v: string) => void; value: string }) { return ( <CodeMirror value={value} height="300px" extensions={[javascript(), python()]} onChange={onChange} theme="dark" /> ); }

编辑器的onChange直接回填到表单的字段里,整个过程非常顺。播放代码高亮时还能顺手把语言标签自动填上,这个小细节对体验提升明显。

标签交互我做了两个入口:新建/编辑时的标签输入框,以及列表页顶部的标签筛选区。标签输入框本身是一个简单的自定义组件,用户按回车添加标签,点击标签上的 × 删除。标签用unique约束保证重名标签只存在一份,自动去重。筛选区就是一组Tag按钮,点击后通过查询参数或客户端状态过滤列表数据。

样式方面,我全部用 Tailwind 完成,自定义了一个比较克制的色板:背景用 slate-950,主内容用 slate-800 卡片,文字用 slate-200,强调色用 indigo-500。暗色模式是开发工具的刚需,Tailwind 只需要在tailwind.config.ts里配一个darkMode: "class",然后给根组件挂darkclass,所有颜色类自动切换。

5. 部署、性能与安全经验

5.1 从本地到线上:部署全流程记录

t3code 的部署方案,我选择了托管平台加云数据库的组合:前端和 API 服务部署在 Vercel,PostgreSQL 数据库用托管云服务。这个组合对全栈 Next.js 应用几乎是零成本的上线路径,不需要自己维护服务器,也省去了配置 Nginx、PM2 这一套东西。

部署前要准备的环境变量如下:

变量名说明
DATABASE_URL云数据库连接串,格式与本地一致
NEXTAUTH_SECRETNextAuth 加密密钥,用openssl rand -base64 32生成
NEXTAUTH_URL生产环境域名,如https://t3code.vercel.app
GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRET如果使用 GitHub OAuth 登录则必填

在 Vercel 项目设置里把这些变量配好以后,我有两个经验想分享一下。

第一个是数据库迁移的时序。Vercel 的构建流程是install -> build -> deploy,Prisma Client 需要在构建阶段就生成,所以构建命令里必须包含prisma generate。而建表操作不应该由 Vercel 自动执行,尤其是生产数据库不能动不动就跑prisma migrate dev。我的做法是:在本地迁移完以后,把迁移文件提交到仓库,然后在数据库迁移环节手动执行prisma migrate deploy。如果是小团队没有专职运维,可以直接在项目根目录加一个vercel.json的构建脚本,把prisma migrate deploy && prisma generate && next build串进去。但要注意,这样每次部署都会执行迁移,迁移文件必须保持幂等和安全,绝不包含破坏性操作。

第二个经验是环境变量的作用域。DATABASE_URL这种带凭据的信息坚决不能带NEXT_PUBLIC_前缀,否则会打进浏览器 bundle 里,等于把数据库密码交到每个用户手里。只有真正需要给前端用的公开变量才加前缀,比如某个第三方服务的公钥。

5.2 性能优化与安全实践

t3code 的列表页做得比较克制,没有一次性把几十条笔记的完整正文全拉回来,而是用select只返回列表需要的字段。这样首屏传输的数据量小,渲染也快。真正需要看详情的时候才通过getById查询完整内容,配合 React Query 的缓存,重复访问同一篇笔记时不用再次请求。

搜索这块,最开始的实现直接用 Prisma 的contains来做模糊查询。数据量小的时候没问题,笔记超过几千条以后就开始慢了。我在这个基础上加了两个优化:一是给搜索框加useDeferredValue,让输入过程不阻塞渲染;二是把搜索请求做成 debounce,用户停止输入 300ms 后才发请求。这套组合在 t3code 的数据规模下体验已经很流畅,如果以后数据量再上台阶,可以考虑引入 Postgres 的全文搜索或者独立搜索服务,但目前没有必要提前引入复杂度。

安全方面除了前面说的protectedProcedure和userId条件约束,还有几点值得强调。

Zod 的校验不是摆设。tRPC 的 input schema 是服务端校验的第一道防线,但一定要把tags限制成字符串数组,字段也要限制长度,防止恶意用户提交超大 payload。我在创建笔记时对title和content都有长度上限:title.max(100),content.max(20000),代码片段更是限制在 50KB 以内。

NextAuth 的配置也不是开箱即安全。我用 GitHub OAuth 做登录,回调地址是api/auth/callback/github,Vercel 部署时必须把环境变量里的域名填对,否则会一直回调失败。同时,生产环境要确保NEXTAUTH_SECRET是一个高熵随机字符串,不能用默认值或者短密码,否则 session 可以被伪造。

另外还有个容易被忽视的细节:Prisma Client 在 Next.js 开发模式下会重复实例化,导致数据库连接数飙升。create-t3-app 默认已经用globalThis做了单例缓存,原理是开发模式下模块热更新会反复加载db.ts,如果不做缓存,每次刷新都新建一个 PrismaClient,连接池很快就会被耗尽。这个单例模式在生产环境没有影响,但在本地开发非常关键。

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

6.1 开发期高频踩坑记录

我把这次开发过程中真正卡过壳、或者你大概率会遇到的问题整理了一下。

现象原因解决办法
页面一直报UNAUTHORIZED,但明明已经登录protectedProcedure里ctx.session为空,NextAuth 的 session 没有正确注入 tRPC context检查src/server/api/trpc.ts中createTRPCContext是否调用了getServerAuthSession
Prisma 执行migrate dev报“数据库连接失败”.env中DATABASE_URL没改,或者 Postgres 服务没启动先确认本地 Docker 容器状态,再在.env里写对连接串
更新了 Prisma Schema,但前端类型没有变化Prisma Client 没有重新生成执行npx prisma generate,并重启开发服务器
调 tRPC mutation 一直 400,但代码没问题Zod schema 字段名和前端传的字段名不一致检查 input 里的 key 是否严格匹配,tRPC 的 zod 校验是严格模式
NextAuth 提示Invalid OAuth callback URL官方回调地址配置错误在 OAuth 应用的设置里把回调地址填成https://域名/api/auth/callback/github
打包部署后页面数据是旧的React Query 默认gcTime缓存导致客户端拿旧数据在_app配置中修改staleTime,或部署后等待缓存过期

这里我想展开讲一下第二个问题。DATABASE_URL写错是最常见的新手报错,而且报错信息不一定明确。有一次我把schema=public写成了schema=prod,整个 Prisma 查询直接报错,但我第一反应是数据库连不上,排查了半小时才发现是 schema 写错。建议第一次跑项目时,先用一个可视化工具连一下数据库,确认连接串无误,再去研究 Prisma。

还有一个开发中会遇到的奇怪现象:改动 Prisma Schema 后,pnpm dev有时候不会自动重新生成 Prisma Client,于是代码里新增的字段在 TypeScript 层面就没有类型提示。这种情况不必着急重启电脑,直接执行npx prisma generate,然后 watch 模式会自动重新编译,前端类型提示马上就恢复了。

6.2 几个值得长期保留的避坑心得

做 t3code 的这些天,我最大的一个体会是:T3 Stack 的默认脚手架质量很高,但你不能完全依赖默认配置,关键地方还是要自己理解原理。

比如context的注入。脚手架生成的createTRPCContext会同时包含headers、session和db,但如果你的项目需要额外信息,比如请求里的自定义 header、权限标记,一定要在createTRPCContext里扩展,而不是在 Router 里临时处理。这样权限逻辑才能统一收敛。

再比如superjson的作用。t3code 里我前后端传输的Date对象,之所以没有被序列化成字符串,都是superjson的功劳。你可能会看到transformer: superjson这个配置,它让 tRPC 支持 Date、Map、Set 这些非 JSON 原生的类型。如果哪天自定义了一个BigInt字段,不要忘了确认 superjson 的配置,因为这个库不会自动处理所有类型,嵌套在对象里的某些特殊类型会踩坑。

还有一个关于状态管理的观察。t3code 前半程我还在用 Redux 和 Zustand 的思路去管理页面数据,但后来发现完全没必要。React Query 已经承担了服务端状态管理,本地组件之间的共享用 Context 或者简单的useState+ props 就足够。你再怎么在本地维护一份服务端数据副本,最后都要跟服务器同步,这本身就是重复劳动。T3 Stack 推荐的模式是:让 React Query 成为服务器状态的唯一所有者,本地 state 只管 UI 交互状态,比如弹窗开关、编辑器内容。

最后分享一个内联标签去重的小技巧。我创建标签时用connectOrCreate避免了重复插入,但在前端,用户可能快速点击同一个标签两次。我加了一层简单的前端去重:

const uniqueTags = [...new Set(input.tags.map((t) => t.trim().toLowerCase()))];

输入进状态之前就做一次过滤,这样既不会出现“React”“react”两个标签,也减少了不必要的数据库操作。小事一桩,但使用体验的差异很明显。

把 t3code 从零搭到上线,前后大概用了一个完整周末的时间。回头看,最值钱的不是那几千行代码,而是这套技术栈帮我省下的“前后端联调时间”。以前做类似的项目,最烦的就是定义 API、写文档、对接口、处理类型不匹配,全程下来至少多花两天。而在 T3 Stack 里,这些事被类型系统自动消化了,我能把精力花在真正重要的产品逻辑和交互细节上。如果你手头也正打算做一个全栈小项目,我真心建议试一次 create-t3-app 起手,跑通一个带数据库、带登录、带增删改查的完整闭环,你会对类型安全这个东西有全新的体感。之后想在这个项目上加功能,比如给代码片段加收藏、加分享链接、做标签云统计,顺着 tRPC 的 Router 继续扩展就行了,架构上基本不需要伤筋动骨。

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

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

立即咨询