☰
T3 Stack全栈实战:从t3code项目看类型安全与开发效率
2026/10/28 23:24:45 网站建设 项目流程

"t3code"这名字一看就不是普通项目——T3,带着终端、第三代、三要素的暗号感;code,又直白又野心。做全栈开发的朋友可能已经猜到了,这大概率是围绕T3 Stack展开的一个实战项目。如果你还没接触过,我简单说一句:T3 Stack 是目前前端圈里极讲究工程效率和类型安全的一套技术组合,核心是 TypeScript + Tailwind CSS + tRPC,再加一个 Next.js 当骨架,Turborepo 管仓库,Prisma 管数据库。我这次用 t3code 做了个完整的全栈应用,从初始化到上线踩了不少坑,也把整套流程跑通了。这篇文章就是把整个实战过程、选型逻辑、关键实现和排坑记录掰开揉碎,给你一条可以直接照着走的路线。准备动手搞全栈项目的、想升级技术栈的、或者单纯想看看 tRPC 到底怎么用的朋友,都可以拿这篇当参考地图。

1. 项目整体设计与技术选型思路

1.1 为什么选 T3 Stack,而不是传统的前后端分离

以前做全栈项目,最常见的组合是 React/Vue 前端 + Node/Java 后端,两边分开写接口文档、联调、维护类型定义。小项目还好,一旦业务复杂起来,改一个字段可能前端、后端、数据库三个地方都要动,还要在接口文档里同步。我近几年做过几个这种项目,最大的感受就是类型断层:前端不知道后端返回的到底是什么结构,后端不知道前端会传什么参数上来,运行时报错了才反应过来。

T3 Stack 的设计核心就是冲着这个痛点去的。它把所有环节都纳入 TypeScript 的类型系统里,从数据库表结构(Prisma 生成类型)到 API 路由(tRPC 自动推导输入输出类型)再到前端调用,整条链路类型是通的。也就是说,后端定义好一个查询,前端代码里直接就能拿到完整的类型提示,字段拼错、参数漏传这类低级问题在编译期就拦住了。

另一个选它的理由是开发效率。tRPC 不需要像 REST 那样为每个资源写 CRUD 路由,也不用像 GraphQL 那样写大量 schema 和 resolver。它就是直接把函数暴露给前端调用,后端写一个查询函数,前端就能像调用本地函数一样调用它。实际写下来,一个简单的增删改查接口,用 tRPC 比用 REST 大概能省一半的样板代码。

当然,T3 Stack 不是银弹。如果你的项目需要给第三方开放公共 API,或者有 iOS/Android 等非 Web 客户端,tRPC 的优势就体现不出来了,因为它的设计默认是"前后端共享同一个 TypeScript 项目"这种紧密耦合的模式。但如果你做的是 Web 应用、内部工具、或者 MVP 快速原型,T3 Stack 的收益非常明显。

1.2 t3code 项目的功能范围与模块划分

我这次用 t3code 做的是一个偏工具类的 Web 应用:用户可以注册登录,创建"代码片段"(类似一个极简的 pastebin),支持语法高亮、标签分类、公开/私密可见性切换,还带一个基于关键词的全文搜索。功能不算复杂,但覆盖了全栈项目的典型场景——认证、CRUD、复杂查询、权限控制、SEO 相关的页面渲染。

模块划分上,我按照 T3 Stack 的惯例把项目拆成几个清晰的层次:

  • 数据库层:Prisma + SQLite(开发环境),定义了用户、代码片段、标签三张表
  • API 层:tRPC router,按业务域拆分为 authRouter、snippetRouter、tagRouter
  • 前端页面:Next.js App Router(其实 create-t3-app 现在默认是 App Router),页面包括首页、片段详情页、创建/编辑页、个人中心
  • 服务端渲染:公开片段直接用 SSR 渲染,保证搜索引擎能抓到内容;私密片段走客户端取数

为什么这样拆?因为 T3 Stack 的哲学是"渐进式",你不需要一开始就把所有复杂度堆上去。先把骨架搭起来,跑通一个完整的"数据入库 -> API 调用 -> UI 展示"流程,再逐步叠加功能。这个思路对独立开发者或者小团队特别重要——减少前期设计成本,把时间花在验证业务逻辑上。

2. 项目初始化与关键配置解析

2.1 create-t3-app:一键拉到标准化骨架

T3 Stack 官方提供了一个脚手架工具create-t3-app,这也是 t3code 项目最顺滑的起点。命令很简单:

pnpm create t3-app@latest

交互式命令行会让你选择要包含的模块。我给 t3code 勾选的是:

  • Next.js(必选)
  • Tailwind CSS(必选,样式方案)
  • tRPC(必选,API 层)
  • Prisma(必选,ORM)
  • NextAuth.js(选上,认证)
  • 其他的如 Docker、ESLint、Prettier 默认即可

这里有个经验想多说一句:如果你还不确定项目后面会不会用 NextAuth,建议第一步还是勾上。因为 create-t3-app 的模板里,NextAuth 和 tRPC、Prisma 之间是做了集成编排的——比如getServerSession和 tRPC context 的合并、SessionProvider 的注入,都是现成的。后期手动往一个没有 NextAuth 的 t3 项目里加,绕的弯路比你想象的多。

脚手架跑完,目录结构是这样的(简化版):

t3code/ ├── prisma/ │ └── schema.prisma # 数据模型定义 ├── src/ │ ├── server/ │ │ ├── api/routers/ # tRPC 业务路由 │ │ ├── db.ts # Prisma 客户端单例 │ │ └── auth.ts # NextAuth 配置 │ ├── pages/ # 页面路由 │ │ ├── api/trpc/ # tRPC 的 HTTP 入口 │ │ └── ... │ ├── styles/globals.css │ └── env.js # 环境变量校验 ├── next.config.js ├── tailwind.config.ts ├── tsconfig.json └── package.json

run 起来之前需要先配置环境变量和数据库。开发环境我直接用 SQLite,零配置起跑;生产环境换成了 PostgreSQL,等会儿部署部分会细说。

2.2 环境变量与类型安全的秘密武器:env.js

create-t3-app 默认带了一个用了t3-env的场景化环境变量校验机制,它基于 zod 在启动时校验env文件里的配置值是否符合预期类型。很多人刚开始会忽略这个文件的威力,我最初也不太在意,直到有一次生产环境忘了配数据库连接串,应用启动后隔了五分钟才因为数据库连接超时暴露问题,排查浪费了很多时间。

有了t3-env的校验后,缺失配置在启动的瞬间就会被拦截并抛出清晰错误:Invalid environment variable: DATABASE_URL。不光如此,它还能把经过校验的 env 变量注入到全局类型里,在前后端代码里用的时候自动有类型提示。简单说,这个机制让"环境变量没配全"的坑在第一步就被填平了。

t3code 里我自定义了几个环境变量,比如NEXT_PUBLIC_BASE_URL(前端地址,用于生成分享链接)、SESSION_MAX_AGE(会话有效期)。写在.env.example里做好备注,方便团队成员复制成.env。

注意:凡是NEXT_PUBLIC_前缀的变量会暴露给浏览器端,永远别把密钥或私密 token 放进去。哪怕项目目前没有真正的密钥,养成这个命名习惯非常重要。

2.3 Tailwind CSS 配置:从默认到统一设计语言

Tailwind CSS 是 T3 Stack 的默认样式方案,因为它和组件化开发的配合度很高,不用写一堆 CSS 文件,直接在 JSX 里用类名完成大部分排版和样式工作。t3code 里我做了两件关键的 Tailwind 配置操作:

第一,扩展了tailwind.config.ts的 theme,定义了一套自定义颜色和字体。颜色方面,我用了一个偏深色背景的设计——背景色oxford-blue、主色electric-violet、强调色neon-green。不是光图好看,主要是代码片段展示类的产品,深色主题天然有"技术感",代码高亮的对比度也更舒适。

// tailwind.config.ts import { type Config } from "tailwindcss"; export default { content: ["./src/**/*.{js,ts,jsx,tsx}"], theme: { extend: { colors: { "oxford-blue": "#0f172a", "electric-violet": "#7c3aed", "neon-green": "#22c55e", }, fontFamily: { mono: ["JetBrains Mono", "Menlo", "monospace"], }, }, }, plugins: [], } satisfies Config;

第二,做了一个代码高亮组件。Tailwind 不直接提供语法高亮能力,我在前端选用了react-syntax-highlighter,在服务端渲染时配合 Prism 风格的暗色主题。这些类名由动态传入的语言类型决定——其实就是一个语言到 Prism 主题的映射,后面实现细节会展开。

Tailwind 最大的优点是把样式类写进 HTML 上下文里,不用来回切换文件找对应的 CSS 选择器。缺点是 JSX 会显得"堆满类名"。我的对策是:重复次数多的样式块抽象为小组件或@apply组合类,不要在组件里写 20 个 class 还到处复制粘贴。

3. 数据库模型设计与核心 API 实现

3.1 Prisma 数据模型:三张表如何关联

数据库是任何全栈应用的地基。我先说说 t3code 里 Prisma schema 的设计思路。

主要三张表:

  • User:用户信息,由 NextAuth 自动关联到Account和Session表
  • Snippet:代码片段,属于某个用户,有标题、描述、代码内容、语言、可见性、标签(多对多)
  • Tag:标签,可以挂在多个 snippet 上

核心的 schema 定义是这样的:

// 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 emailVerified DateTime? image String? snippets Snippet[] accounts Account[] sessions Session[] } model Snippet { id String @id @default(cuid()) title String description String? code String language String @default("plaintext") visibility Visibility @default(PRIVATE) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt authorId String author User @relation(fields: [authorId], references: [id], onDelete: Cascade) tags Tag[] @relation("SnippetTags") searchText String? // 用于全文检索的冗余字段 @@index([authorId]) @@index([visibility, createdAt]) } model Tag { id String @id @default(cuid()) name String @unique snippets Snippet[] @relation("SnippetTags") } enum Visibility { PUBLIC PRIVATE }

这里面有几个设计上的取舍值得解释一下:

  • id 用 cuid 而不是自增整数。原因:公开的 snippet 如果 id 是自增的,容易被遍历采集;cuid 是不可猜测的,适合这种带可见性控制的内容。
  • onDelete: Cascade。用户注销账号时,其所有片段会被连带删除,避免数据孤儿。
  • searchText 冗余字段。SQLite 本身不支持全文索引,简单的LIKE查询在数据量大的时候会慢。我加了一个冗余字段把标题、描述、代码拼接成一段,查询时只在这一个字段上做contains,逻辑直观且够用。之后换 PostgreSQL 可以用tsvector做真正的全文检索,当前阶段不引入额外复杂度。

数据库模型定好后,跑数据库迁移:

pnpm prisma migrate dev --name init pnpm prisma db seed

db seed这块我写了个简单的 seed 脚本,插入了几条演示 snippet 和标签,方便开发阶段页面有数据可看。

3.2 tRPC Router 拆分:authRouter 与 snippetRouter

t3code 的 API 层用 tRPC 实现。核心概念是router和procedure:router是一组函数的集合,procedure是具体的一个查询或变更操作。create-t3-app 生成的项目里默认把根 router 放在src/server/api/root.ts,业务子 router 放src/server/api/routers/目录下。

t3code 里有三个业务 router,我重点讲讲 snippetRouter——因为它是核心业务。

// src/server/api/routers/snippet.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc"; const snippetInputSchema = z.object({ title: z.string().min(1, "标题不能为空").max(100), description: z.string().optional(), code: z.string().min(1, "代码内容不能为空"), language: z.string().default("plaintext"), visibility: z.enum(["PUBLIC", "PRIVATE"]).default("PRIVATE"), tags: z.array(z.string()).default([]), }); export const snippetRouter = createTRPCRouter({ // 公开查询:最近的公开片段 getRecent: publicProcedure .input(z.object({ take: z.number().int().min(1).max(50).default(20) })) .query(async ({ ctx, input }) => { return ctx.db.snippet.findMany({ where: { visibility: "PUBLIC" }, orderBy: { createdAt: "desc" }, take: input.take, include: { author: { select: { name: true, image: true } }, tags: true }, }); }), // 当前用户的片段列表 getMySnippets: protectedProcedure .query(async ({ ctx }) => { return ctx.db.snippet.findMany({ where: { authorId: ctx.session.user.id }, orderBy: { updatedAt: "desc" }, include: { tags: true }, }); }), // 创建片段 create: protectedProcedure .input(snippetInputSchema) .mutation(async ({ ctx, input }) => { return ctx.db.snippet.create({ data: { ...input, searchText: [input.title, input.description, input.code].filter(Boolean).join("\n"), authorId: ctx.session.user.id, tags: { connectOrCreate: input.tags.map((tag) => ({ where: { name: tag }, create: { name: tag }, })), }, }, }); }), // 更新片段(仅作者本人) update: protectedProcedure .input(snippetInputSchema.extend({ id: z.string() })) .mutation(async ({ ctx, input }) => { const { id, tags, ...data } = input; // 先确认资源归属,避免越权 const existing = await ctx.db.snippet.findUnique({ where: { id } }); if (!existing || existing.authorId !== ctx.session.user.id) { throw new Error("无权操作该片段"); } return ctx.db.snippet.update({ where: { id }, data: { ...data, searchText: [data.title, data.description, data.code].filter(Boolean).join("\n"), tags: { set: [], connectOrCreate: tags.map((tag) => ({ where: { name: tag }, create: { name: tag }, })), }, }, }); }), // 删除片段(仅作者本人) 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 { success: true }; }), // 搜索公开片段 search: publicProcedure .input(z.object({ q: z.string().min(1), take: z.number().int().min(1).max(50).default(10) })) .query(async ({ ctx, input }) => { return ctx.db.snippet.findMany({ where: { visibility: "PUBLIC", searchText: { contains: input.q }, }, orderBy: { updatedAt: "desc" }, take: input.take, include: { author: { select: { name: true, image: true } }, tags: true }, }); }), });

这段代码无意中透露了 tRPC 的核心哲学:每一个 procedure 就是一个带类型约束的后端函数,前端调用时完全不需要写额外的请求层代码。

从设计角度,有几个细节可以给新手提示:

  • protectedProcedure不是简单的中间件,它内部会先从 session 里取用户身份,没有登录的用户直接返回错误。这个机制保证了业务代码里不需要到处写"if not logged in throw"。
  • 输入校验用 zod。zod 的 schema 同时被后端和前端共享,因此前端表单里可以用同一份校验规则做实时验证。
  • 权限控制写在业务内部而不是 router 外层,原因是可以根据不同操作做细粒度判断——比如查询公开数据任何人可看,但修改必须验证作者。

3.3 服务端调用 vs 客户端调用:SSR 与 hydration

tRPC 最妙的地方在于,同一个 procedure 既可以在服务端直接调用,也可以在浏览器端通过 HTTP 调用。这个特性在 T3 Stack 里被利用得淋漓尽致。

对于需要 SEO 的公开页面,比如 snippet 详情页,正确的做法是使用serverSideHelpers:

// src/pages/snippet/[id].tsx import { createServerSideHelpers } from "@trpc/react-query/server"; import { appRouter } from "~/server/api/root"; import { db } from "~/server/db"; export async function getServerSideProps(ctx) { const helpers = createServerSideHelpers({ router: appRouter, ctx: { db, session: null }, }); try { await helpers.snippet.getById.prefetch({ id: ctx.params.id }); return { props: { trpcState: helpers.dehydrate(), }, }; } catch { return { notFound: true }; } }

然后在页面组件里直接用useQuery读取数据,React Query 会用 SSR 阶段已经脱水(dehydrate)的数据作为初始缓存,前端不会重复发请求,也不会出现"先用 loading 再变内容"的闪烁。

为什么要做这一层?因为如果你把公开的 snippet 详情做成纯客户端加载(比如 useEffect 里fetch),搜索引擎的爬虫很可能拿到的是一整个空骨架页面,索引不到你的内容。对于内容型应用,SSR 是必须的,不是锦上添花。

t3code 里更极致的做法是给getByXXX这类公开读操作加入queryClient.setQueryData的缓存预热。这样用户在列表页点进详情页时,数据秒开,几乎没有网络请求的等待。

4. 认证与权限控制的完整落地

4.1 NextAuth 配置:Credentials 还是 OAuth?

认证方案上,t3code 用了 NextAuth.js。它支持几十种第三方 OAuth Provider,比如 GitHub、Google,也支持基于用户名密码的 Credentials Provider。

我最终选择了 GitHub OAuth + Credentials 双方案。为什么?

  • GitHub OAuth 可以极大降低新用户的注册成本,点了授权就能进。
  • Credentials Provider 让不想绑定 GitHub 的用户也能用邮箱注册。

NextAuth 的配置在src/server/auth.ts里,create-t3-app 脚手架已经给出了基础结构。我要补充的是 Prisma adapter 的连接——把用户数据和 Session 数据落到自己的数据库里,而不是用内存存储(默认方式在生产环境会丢 Session)。

// src/server/auth.ts import { type GetServerSidePropsContext } from "next"; import { getServerSession } from "next-auth"; import GitHub from "next-auth/providers/github"; import Credentials from "next-auth/providers/credentials"; import { PrismaAdapter } from "@next-auth/prisma-adapter"; import { db } from "~/server/db"; export const authOptions = { adapter: PrismaAdapter(db), providers: [ GitHub({ clientId: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET, }), Credentials({ name: "Email and Password", credentials: { email: { label: "Email", type: "email" }, password: { label: "Password", type: "password" }, }, async authorize(credentials) { // 这里需要自己实现注册时的密码哈希存储 // 以及登录时比对密码的逻辑 const user = await db.user.findUnique({ where: { email: credentials?.email }, }); if (!user || !user.passwordHash) return null; const isValid = await verifyPassword(credentials!.password, user.passwordHash); if (!isValid) return null; return { id: user.id, email: user.email, name: user.name }; }, }), ], session: { strategy: "jwt" }, callbacks: { jwt({ token, user }) { if (user) token.uid = user.id; return token; }, session({ session, token }) { if (session.user) session.user.id = token.uid; return session; }, }, } satisfies NextAuthOptions;

注意几个关键的细节:

  • 用 Credentials Provider 时,session 策略只能是jwt,不能是默认的database。因为 Credentials 的校验发生在 authorize 阶段,数据库里没有对应的 OAuth token 记录。
  • 用 JWT 的话,Session 不需要服务端存储,但 JWT 默认只存用户标识和过期时间,需要扩展 session 类型,把 userId 放进去。
  • NextAuth v4 的类型补丁要写在src/types/next-auth.d.ts里,否则 TS 报Property 'id' does not exist on type 'Session'。

4.2 注册登录流程:密码哈希与表单校验

如果你用 GitHub OAuth,登录流程基本是 NextAuth 一键完成的。但 Credentials Provider 的注册环节要自己动手。t3code 里我加了一个auth/register页面,处理流程如下:

  1. 前端用 zod 校验邮箱格式、密码长度(至少 8 位)
  2. 后端POST /api/register接口处理注册逻辑,密码用bcryptjs哈希后存入数据库
  3. 注册成功直接调signIn("credentials", { email, password, redirect: false })

密码哈希这件事我要多啰嗦一句:永远不要明文保存密码。bcrypt 是一种加盐哈希算法,即使数据库泄露,攻击者想从哈希反推出密码的代价极高。t3code 里我把哈希函数封装在src/server/password.ts里,这样认证逻辑和密码算法分离,方便以后升级算法。

// src/server/password.ts import bcrypt from "bcryptjs"; const SALT_ROUNDS = 12; export async function hashPassword(plain: string): Promise<string> { return bcrypt.hash(plain, SALT_ROUNDS); } export async function verifyPassword(plain: string, hash: string): Promise<boolean> { return bcrypt.compare(plain, hash); }

SALT_ROUNDS 设 12,不是随便选的。轮数越高哈希计算越慢,暴力破解越难;但轮数太高,用户登录时服务端 CPU 开销也大。12 是当前安全性和性能比较平衡的档位,实测单次 hash 在普通服务器上大约 100ms 级别,可以接受。

4.3 tRPC context 中的 session 注入

前端调用 tRPC 时,每个 procedure 都能从ctx里读取到 session 信息。这个ctx是 tRPC 在每次请求时构建的上下文,通过createContext函数把 NextAuth 的 session 和 Prisma 的 db 实例合并进去。

// src/server/api/trpc.ts import { initTRPC, TRPCError } from "@trpc/server"; import { getServerSession } from "next-auth"; import { authOptions } from "~/server/auth"; export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerSession(authOptions); return { session, db, ...opts, }; }; const t = initTRPC.context<typeof createTRPCContext>().create(); export const publicProcedure = t.procedure; export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { ...ctx, session: { ...ctx.session, user: ctx.session.user }, }, }); });

protectedProcedure就是一个带校验的中间件。任何受保护的操作只要用它,没登录就直接抛 401 错误给前端。前端 tRPC 客户端会统一处理这种错误并跳转到登录页,不用每个页面单独判断。

这套设计最让我满意的是:权限逻辑只在服务端存在,前端根本不存在"隐藏按钮"这种不可靠的权限做法。哪怕有人恶意构造请求,后端也会拒绝他。

5. 前端页面与交互实现

5.1 页面路由与布局设计

页面层我用 Next.js App Router,路由结构非常直觉:

  • /:首页,展示公开 snippet 列表 + 搜索框
  • /snippet/[id]:片段详情页,支持语法高亮
  • /create:创建新片段
  • /edit/[id]:编辑已有片段
  • /dashboard:个人中心,展示自己的片段列表
  • /auth/login、/auth/register:登录、注册

布局上,公共 Layout 包含顶部导航栏、用户头像/登录按钮、底部页脚。在 App Router 里,src/app/layout.tsx就是全局布局,每个页面的内容会自动塞进{children}。

导航栏的认证状态我用了一个useSession()hook 结合 SessionProvider 来实现。SessionProvider 放在src/app/providers.tsx里,create-t3-app 已经帮我们接线好了。

5.2 代码高亮与编辑器:一个被低估的复杂度来源

代码片段类产品,最影响使用体验的除了加载速度,就是高亮和编辑体验。这个模块的技术含量比想象的高,我展开聊聊。

高亮:我选了react-syntax-highlighter的 Prism 版本。选它的理由有两个:

  • 支持语言多,常见的 JavaScript、TypeScript、Python、Go、Rust 等都有对应语言包
  • SSR 渲染友好,因为它是纯组件渲染,没有浏览器 API 依赖

但直接引入它有一个体积坑——它会打包所有语言和主题,首屏体积暴增几十 KB。解决办法是用PrismLight手动注册语言:

// src/components/CodeHighlighter.tsx import { PrismLight as SyntaxHighlighter } from "react-syntax-highlighter"; import tsx from "react-syntax-highlighter/dist/esm/languages/prism/tsx"; import typescript from "react-syntax-highlighter/dist/esm/languages/prism/typescript"; import python from "react-syntax-highlighter/dist/esm/languages/prism/python"; import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; SyntaxHighlighter.registerLanguage("tsx", tsx); SyntaxHighlighter.registerLanguage("typescript", typescript); SyntaxHighlighter.registerLanguage("python", python); export function CodeHighlighter({ code, language }: { code: string; language: string }) { return ( <SyntaxHighlighter language={language} style={oneDark} customStyle={{ borderRadius: "0.75rem", fontSize: "14px" }} showLineNumbers > {code} </SyntaxHighlighter> ); }

编辑器:创建/编辑页需要写代码。我没有上非常重的 CodeMirror 或者 Monaco,而是用一个 textarea + Tab 键处理 + 行号伪元素的方案。对大多数短小的代码片段来说,完全够了。

编辑器里我最喜欢的一个细节是 Tab 键自动缩进:

function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) { if (e.key === "Tab") { e.preventDefault(); const start = e.currentTarget.selectionStart; const end = e.currentTarget.selectionEnd; const value = e.currentTarget.value; e.currentTarget.value = value.substring(0, start) + " " + value.substring(end); e.currentTarget.selectionStart = e.currentTarget.selectionEnd = start + 2; setCode(e.currentTarget.value); } }

这段代码虽然很短,但解决了写代码的人最自然的输入习惯问题。不加这个 Tab 处理的话,按下 Tab 光标就会跳去下一个表单元素,编辑器体验直接不及格。

5.3 表单验证与错误反馈

表单这部分,t3code 用的是 React Hook Form + zod。zod schema 和 tRPC 的输入校验共用同一份定义,这点很关键——前端和后端验证逻辑不会产生"前端能过、后端拒绝"或相反的割裂。

具体做法是把 schema 单独抽到src/schemas/snippet.ts,前端 form 和后端 procedure import 同一个文件。这样改一处,两端同时生效。

// src/components/SnippetForm.tsx const form = useForm<SnippetInput>({ resolver: zodResolver(snippetInputSchema), defaultValues: { title: "", description: "", code: "", language: "plaintext", visibility: "PRIVATE", tags: [] }, }); function onSubmit(data: SnippetInput) { createSnippet.mutate(data, { onSuccess: (snippet) => { router.push(`/snippet/${snippet.id}`); }, onError: (error) => { toast.error(error.message); }, }); }

关于错误反馈,我实际用的不是浏览器的原生校验,而是自己写了一个FieldError组件,在 zod 校验失败时显示具体的中文错误信息。这里的重点是,错误信息要从服务端和前端同时共享——因为有些错误只会在服务端出现,比如"无权操作该片段"。

6. 部署上线与性能优化实践

6.1 从 SQLite 迁移到 PostgreSQL

开发阶段用 SQLite 很方便,一个文件搞定。但上线跑真实用户时,SQLite 的并发写能力和部署弹性是不足的,所以我提前做了迁移方案:生产环境用 PostgreSQL,本地保持 SQLite。这不仅和 Prisma 的数据库抽象能力有关,也和 T3 项目的部署方式有关——Vercel 的 Serverless 环境不适合持久化文件的 SQLite。

迁移步骤很简单:

pnpm prisma migrate reset --force # 这只是重置开发库,如果你没有重要数据也可以

正式的迁移流程是:

  1. 在.env里把DATABASE_URL改为 PostgreSQL 连接串
  2. pnpm prisma migrate deploy会在生产库上执行迁移
  3. 确认迁移状态没问题后pnpm prisma generate

我在本地没删 SQLite 数据库,但是按项目环境变量按需切换。用 Prisma 的好处就体现出来了——schema 不变,底层数据库换了业务代码一行不用改。

6.2 构建优化:减少 tRPC 客户端体积

T3 Stack 默认生成的 tRPC 客户端在打包时是"整个根 router 都会被引入"的,不管前端用到了哪几个 procedure。这就导致首屏 bundle 偏大。我做了几个优化:

  1. 按需引入:@trpc/react-query允许我们在客户端单独创建某个子 router 的代理,而不是整个根 router。这个在最新的 SDK 支持里叫trpc.routerToClient。用这个方式,首页只 import 到 snippetRouter 的客户端代理。
// src/utils/trpc.ts (简化) import { createTRPCReact } from "@trpc/react-query"; import { type AppRouter } from "~/server/api/root"; export const trpc = createTRPCReact<AppRouter>({});

这是最通用的方式,如果按需引入做起来麻烦、收益有限,通常不做过度优化。我的实测结果是——脚手架默认配置下,gzip 后 bundle 大小变化也就十几 KB,除非你很在意首屏指标,否则 1 的收效没有想象中大。

  1. 动态路由懒加载:详情页的CodeHighlighter通过next/dynamic动态加载,只在片段详情页进入的时候加载。
import dynamic from "next/dynamic"; const CodeHighlighter = dynamic(() => import("~/components/CodeHighlighter"), { ssr: false, loading: () => <div className="animate-pulse h-64 bg-slate-800 rounded-xl" />, });

注意这里故意用了ssr: false。因为react-syntax-highlighter的 Prism 在 SSR 渲染时虽然可行,但会显著拖慢首屏 TTFB。详情页本身已经有 SSR 的数据了,高亮这层互动效果放在客户端渲染完全能接受。

  1. 图片优化:用户头像(如果接 OAuth 会有 image 字段)统一由 next/image 处理,配置域名白名单:
// next.config.js(写 next.config.ts 也可以) images: { remotePatterns: [ { protocol: "https", hostname: "avatars.githubusercontent.com" }, { protocol: "https", hostname: "lh3.googleusercontent.com" }, ], },

不配置的话 Next.js 会拒绝加载远程图片并报错。

6.3 部署到 Vercel:环境变量与构建命令

部署平台我选的 Vercel,原因很直接:Next.js 是它的原生运行环境,T3 Stack 和它配合几乎零摩擦。

部署的核心操作总结三条:

  • 仓库推送到 GitHub,Vercel 新项目关联仓库
  • 环境变量在 Project Settings 里逐个配置,包括DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL、GITHUB_CLIENT_ID、GITHUB_CLIENT_SECRET
  • 框架预设默认就该是 Next.js,构建命令如果没写在 package.json 里会走默认的next build

有一个坑我要特别提醒——Serverless 环境中的数据库连接数。Vercel 的 Lambda 函数是瞬时的,每次冷启动都会新建 PrismaClient 实例。我在src/server/db.ts里做单例保护:

// src/server/db.ts import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const db = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;

这个模式在看别人代码时很容易忽略,但它在 Serverless 环境里尤其重要——防止每个热加载都新建数据库连接,吃满 PostgreSQL 的连接上限。生产环境不需要挂到 global,因为 Lambda 实例复用相对稳定,但加上无害。

另一个隐患:如果用 Serverless + PostgreSQL,连接池设置很关键。Prisma 默认按连接池方式工作,你的连接串里要带?pgbouncer=true之类参数(如果前面有 PgBouncer),并根据需求调connection_limit。这些配置如果没处理好,上线后遇到高并发就可能报Too many connections错误。

7. 常见问题与排坑实录

7.1 类型报错:Session 没有 id 属性

这是 NextAuth v4 和 TypeScript 磨合期的经典问题。虽然session.user.id在 token 回调里已经塞进去了,但类型定义不知道,TS 直接报错。

解决方式是在src/types/next-auth.d.ts里扩展类型:

import "next-auth"; declare module "next-auth" { interface Session { user: { id: string; name?: string | null; email?: string | null; image?: string | null; }; } }

这个文件不需要手动 import,TS 会自动类型补丁。搞完重启 dev 服务器让类型重新加载。

7.2 tRPC 客户端不能只 import 一个子 router

可能是我写法的问题,但我发现createTRPCReact在类型层面用的是根 router 的完整类型,而不是运行时实际挂载的 router。所以按需引入的做法容易让 TS 类型和实际行为出现不一致,反而把简单的事搞复杂了。

后来我还是回归了"全部引入 + 按需动态加载页面"的思路,放弃对根 router 的细粒度裁剪。这可能对纯 tRPC 架构来说是最稳、也最符合心智模型的做法。

7.3 Tailwind 类名没生效

项目初始化后,有几次我新增了自定义颜色,但页面样式没有变化。排查后发现是 Tailwind 的content配置没有覆盖到新组件的路径。如果你把组件放在src/components之外的地方(比如lib/或临时目录),记得在tailwind.config.ts的content里加上对应的 glob 路径。

7.4 NextAuth 登录后回调不跳转

用signIn("credentials", { redirect: false })这种方式时,NextAuth 不会自动处理"登录成功后从哪来回哪去"的逻辑。我在注册/登录组件里手动记录callbackUrl,登录成功后用router.push(callbackUrl)。

7.5 Vercel 部署后 Prisma 报错找不到引擎

在 Vercel 上跑 Prisma 需要@prisma/client正确生成到项目里。解决办法是构建命令改成:

"build": "prisma generate && next build"

因为 Vercel 的构建环境不会自动执行prisma generate。

8. 性能数据与后续迭代空间

8.1 t3code 的核心性能指标

整体应用上线后,我观察了几天核心指标(数据为个人项目实测参考):

指标数据说明
首屏 TTFB(首页)约 280ms依赖 SSR 直出公开列表
详情页切页平均耗时约 150msReact Query 缓存预热
Lighthouse Performance95+主要得益于纯静态化和动态加载
Build 时间约 80s项目体量较小,后续增大有优化空间

这套数据比大多数同类型应用要好看,核心归功于三点:SSR + 缓存预热、动态加载高亮组件、Tailwind 的零运行时样式。

8.2 还可以扩展什么:从 MVP 走向完整产品

t3code 作为 MVP 已经完成闭环,但如果要继续演进,我个人首推以下几个方向:

  1. 全文检索升级:PostgreSQL 上用tsvector/tsquery替换LIKE查询,支持中文分词后搜索质量会质变。

  2. 评论与协作:code snippet 兼有"可讨论性",加上评论、版本历史,就能变成一个微型知识库。

  3. 埋点与数据统计:记录 snippet 的浏览量、复制量,可以发现用户真正关注的语言和内容,反哺运营。

  4. ESLint + 自动化测试:t3 template 自带 ESLint,但缺测试。给 snippetRouter 写集成测试会极大增加后续迭代的信心。

这个项目做下来,我最大的体会是:T3 Stack 不是让你一上来就写得很爽,而是让你在"改需求、查类型、做迭代"时少吃苦。类型安全带来的效率提升是隐性的,它不会让你第一天就变快,但会在你连续改三天代码之后兑现回报。如果这篇 t3code 的实战拆解能让你少踩几个我踩过的坑,那这功夫就没白花。

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

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

立即咨询