1. 项目概述与整体思路
先说结论:t3code 是我最近一段时间在做的全栈项目代号,核心是基于 T3 Stack 搭建的一套可复用业务系统原型。T3 Stack 这个名字很多朋友可能已经听过,它是 Next.js、TypeScript、Tailwind CSS、tRPC、Prisma 这套技术组合的统称,由社区里的 Theodorus 推广开来。如果你还没接触过,我用一句大白话解释:这是一套让前端和后端共享同一套类型定义的技术栈,前端调用后端接口时就像在调用本地函数一样,类型还能自动推导。
我最初准备做这个项目时,其实在技术选型上纠结了很久。传统开发中,前端一个项目,后端一个项目,接口文档靠手写,联调时对着 Swagger 翻来翻去,字段名打错一个字就要排查半天。后来我用过 REST API 加 Postman 的模式,也试过 GraphQL 的 Schema 定义,但总感觉哪里不够顺滑。直到我把 Next.js 和 tRPC 串起来跑通第一个接口,才意识到这可能是全栈开发的另一种打开方式:后端直接用 TypeScript 写路由,前端直接导入类型并调用,接口的入参和出参全部由编译器校验,类型检查通过的那一刻,接口基本就是可用的。
这篇文章我想把 t3code 从选型、初始化、写接口到部署的完整过程记录下来。适合谁看?如果你已经了解 React 和 Next.js 的基本用法,但还没试过 tRPC,或者正在纠结全栈项目的前后端如何组织,那这篇文章可以帮你省掉不少试错的成本。如果你还不太熟悉 TypeScript,也能通过我的描述体会到类型安全带来的实感,而不是停留在“泛型约束”这种抽象概念上。
我在文章里不会只讲配置成功的部分,更多的是把我在实际过程中踩过的坑、绕过的路、以及最终沉淀下来的约定展开说清楚。t3code 这个项目虽然只是一个原型,但它覆盖了用户认证、数据建模、服务端渲染、API 缓存这一套完整链路,你把它当模板去改造,可以直接落到真实业务里。
1.1 项目定位与目标
我在规划 t3code 的时候,给它定了一个很具体的业务场景:一个团队内部的知识库系统。团队成员可以注册登录、创建文档、给文档打标签、按标签筛选,并且能看到每篇文档的最近编辑时间。这个场景不大,但正好能覆盖 T3 Stack 的典型能力边界——有用户体系、有数据库读写、有列表查询、有服务端渲染需求。
技术目标更直接:用最少的代码量,实现端到端的类型安全。这里“端到端”指的是从数据库表结构,到 API 层的输入输出,再到前端组件的 props,所有数据类型都由 TypeScript 在编译期统一管理。我不想在任何一个环节出现“前端定义了一个 interface,后端又定义了一个 interface,两边靠自觉保持同步”这种情况,那样的项目维护时间一长,类型就开始说谎。
另一个目标是保持项目结构足够简单,让后来者能快速接手。很多全栈项目写着写着就变成了前后端两个仓库,各自有构建工具、各自的 node_modules、各自的部署流程。t3code 选择单仓库、单进程的方式,是因为团队内部工具型项目的并发量不高,但迭代速度要求很高,单仓库能减少很多运维心智负担。这个决策在 t3code 这种规模下是合理的,但如果未来并发量大到必须拆分服务,tRPC 的 Router 结构也能方便地拆成独立的微服务,这是后话,后面我会细说。
1.2 为什么是 T3 Stack 而不是“传统三件套”
关于技术选型,我不太喜欢跟风,但更讨厌重复造轮子。所谓传统三件套,指的是 React + Node.js + 关系型数据库,然后自己用 Express 搭接口,用 JSON Web Token 做登录态,再单独处理跨域、错误码、参数校验、文档维护。这套东西我写过太多次,每次都在重复同样的劳动。
T3 Stack 给我的第一感觉是“把正确的事情默认化”。默认使用 Next.js 的 App Router,就同时拥有了服务端组件、客户端组件和 API 路由,不需要额外起一个 Node 服务。默认使用 tRPC,后端路由定义好之后,前端同事不需要再对着文档 Copy 参数名,所有类型直接引入。默认使用 Prisma 做数据层,改完 Schema 后执行一条命令,客户端类型就重新生成了。
而且 T3 Stack 有个核心指导思想:“渐进式规约”。用的时候不需要一下子上全所有功能,可以只选项目必需的模块。比如 t3code 就不需要 Rest API 那一套了,因为 tRPC 完全够用。它也允许你在某些边界场景里单独抽一个 REST 端点,比如要提供给第三方外部调用的时候,这种灵活性很重要。
我举个例子帮大家体会一下类型安全的实感。在传统 REST 架构里,前端代码通常长这样:
const res = await fetch('/api/notes'); const data = res.json(); // data 的类型是 any,你得自己记住返回结构而在 tRPC 里,同样的需求是这样:
const { data } = api.note.getAll.useQuery(); // data 的类型由后端 noteRouter 的返回值自动推导前者靠脑子记住接口返回字段,后者靠编译器兜底。这对个人项目来说可能差距不大,但当你的项目有十几个数据模型、几十个接口时,这条路能帮你省掉大量心智负担。这一点我在做 t3code 的标签筛选功能时感触最深,后面实操环节会详细讲。
2. 核心技术栈拆解与落地方案
t3code 这套技术栈里的每一个组件,单独拿出来都不是新东西,但组合在一起之后,整个数据流会变得非常清晰。我从数据流的角度拆一遍:浏览器请求页面,Next.js 服务端渲染时可以直接读取数据库,也可以通过 tRPC 调后端逻辑;页面交给浏览器后,客户端组件再通过 tRPC 调用增删改查接口;tRPC 后端调用 Prisma 访问数据库。整个链路是直线型的,所有类型定义都经由 TypeScript 串连起来。
2.1 tRPC 的端到端类型安全是怎么做到的
tRPC 的原理可以用一句话描述:把后端过程按 Router 组织,通过 TypeScript 的类型推导,把 API 的定义“投射”到前端。读起来有点绕,我用一个生活类比来解释。
想象你去餐厅点餐,传统方式是你看着纸质菜单,手写下菜名,递给服务员,后厨做完端上来。如果菜名写错了,或者后厨理解错了,你只能等菜上来才发现。tRPC 的方式是,菜单本身就是一部电子点餐簿,你点哪些菜、每道菜什么口味、是否加辣,点餐簿都给你做了选项限定,你根本不可能选出一个不存在的菜。如果某个菜今天停售了,点餐簿自动变灰,你连点都点不了。
落到代码里,tRPC 后端定义一个 Router:
// server/api/routers/note.ts import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; export const noteRouter = createTRPCRouter({ getById: publicProcedure .input(z.object({ id: z.string() })) .query(({ ctx, input }) => { return ctx.db.note.findUnique({ where: { id: input.id }, }); }), });前端使用时,直接从@/server/api/routers/note导出类型,或者在根 Router 里取类型:
// app/notes/[id]/page.tsx import { api } from "~/trpc/react"; export default function NotePage({ params }: { params: { id: string } }) { const { data } = api.note.getById.useQuery({ id: params.id }); return <div>{data?.title}</div>; }你注意看,api.note.getById.useQuery中的getById方法名、入参{ id: string }、返回的数据结构,全部是从后端自动推导出来的。一旦后端把id字段改成noteId,前端编译直接报错,而不是运行到那边半天才发现接口挂掉。这种体验在多人协作时的价值尤为明显,后端人员调完接口,前端人员不需要问“这个字段叫什么来着”,看一眼提示就知道。
2.2 Prisma 与数据库建模
数据库这层,t3code 用的是 Prisma + SQLite,开发环境零配置,跑起来非常快;生产环境我建议换成 PostgreSQL,连接串改一下就行,Schema 不需要动。Prisma 的核心是schema.prisma文件,它同时承担表结构定义、关系定义、类型生成三项任务。
t3code 的数据模型比较简洁,三张表:User、Note、Tag,外加一个多对多的关系表。用 Prisma 定义大概是这个样子:
model User { id String @id @default(cuid()) email String @unique name String? notes Note[] createdAt DateTime @default(now()) } model Note { id String @id @default(cuid()) title String content String authorId String author User @relation(fields: [authorId], references: [id]) tags NoteTag[] updatedAt DateTime @updatedAt createdAt DateTime @default(now()) } model Tag { id String @id @default(cuid()) name String @unique notes NoteTag[] } model NoteTag { noteId String tagId String note Note @relation(fields: [noteId], references: [id]) tag Tag @relation(fields: [tagId], references: [id]) @@id([noteId, tagId]) }第一次接触 Prisma 的人,最需要理解的一点是:这个文件不只是建表语句,它是你项目的“数据事实来源”。改字段、加关系、加索引,都集中在这里改,然后执行npx prisma migrate dev,数据库结构同步,客户端类型自动更新。不需要手写迁移脚本,更不需要在多个地方重复维护定义。
实测下来有个感受:Prisma 的类型推导与 tRPC 的结合是天然默契的。因为 tRPC 的返回值往往直接来自 Prisma 查询,前端拿到的对象字段与数据库严格一致,根本不存在“后端返回了但前端不知道”的情况。这种一致性让项目的开发效率高一个档次。
2.3 样式方案的选择:Tailwind CSS 的取舍
样式部分,t3code 选了 Tailwind CSS。Tailwind 这几年从“Utility-First 的争议方案”变成了大规模普及的默认选择,核心原因是它把 CSS 的维护成本从“全局类名管理”转移到了“局部原子类管理”。
在 t3code 里,我做了一个约定:业务组件全部用 Tailwind 原子类写,页面布局单独抽成布局组件,基础 UI 组件(按钮、输入框、卡片)用全项目自定义的一套语义类。这样既享受了 Tailwind 快速开发的便利,又不会出现一个 button 的样式散落在 30 个文件里的乱象。
有人会担心 Tailwind 的类名可读性差,一长串类名堆在 className 里,视觉上确实不够优雅。但实际开发中,它减少的是你频繁切换文件和命名的频率。一个组件改样式,直接在 JSX 里微调几个类名就完事,不需要去 CSS 文件里新建一个“card-wrapper”类,再回到 HTML 引用。对 t3code 这种重业务逻辑、轻视觉定制的项目来说,这个取舍是划算的。
2.4 目录结构与代码分层
项目结构合理性直接决定后续维护体验。t3code 的目录布局严格遵循 T3 社区推荐的模式:
src/ ├── app/ # Next.js App Router 页面 │ ├── layout.tsx │ ├── page.tsx │ └── notes/ │ ├── page.tsx # 笔记列表页 │ └── [id]/ │ └── page.tsx # 笔记详情页 ├── server/ │ ├── api/ │ │ ├── trpc.ts # tRPC 实例与上下文 │ │ └── routers/ │ │ ├── note.ts │ │ ├── tag.ts │ │ └── user.ts │ └── db.ts # Prisma Client 实例 ├── trpc/ │ ├── react.tsx # 客户端 tRPC hooks │ └── server.ts # 服务端 tRPC 调用助手 └── styles/ └── globals.css我在实际开发中体会到,这个结构最大的好处是“位置即职责”。数据查询在server/api/routers,页面入口在app,数据获取逻辑统一走api开头的方法,不会出现页面组件里直接写 SQL 或直接操作 Prisma 的“破窗时刻”。新同事接手项目时,不需要文档,看一眼目录就能知道什么代码应该放在哪里。
3. 从零搭建 t3code 的完整实操
这一章节我完整记录我的实操过程。由于 t3code 涉及的步骤比较多,我会按照时间线顺序展开:先初始化项目脚手架,再建数据库表结构,然后定义 tRPC Router,最后写前端页面接入数据。每一步我都会把命令、配置、以及我当时为什么这么操作的原因写清楚。
3.1 项目初始化与确认依赖
推荐直接使用create-t3-app脚手架,它会帮你把 Next.js、Tailwind、Prisma、tRPC、NextAuth 等模块一体化装好,并给你一份可以直接运行的基础代码。我在 t3code 初始化时执行了这条命令:
npx create-t3-app@latest t3code脚手架会让你选择要包含的模块。我的选择是这样的:
| 模块 | 是否选择 | 原因 |
|---|---|---|
| Next.js App Router | 是 | 默认推荐,支持服务端组件 |
| TypeScript | 是 | 不用选,默认强制 |
| Tailwind CSS | 是 | 统一样式方案 |
| tRPC | 是 | 这是 T3 的灵魂 |
| Prisma | 是 | 数据层需要 |
| NextAuth.js | 是 | 团队知识库需要登录体系 |
| 其他(ESLint、Prettier) | 是 | 代码规范一步到位 |
安装完成后,先跑一遍npm run dev,如果首页能正常打开,说明脚手架版本没问题。这里的坑点在于:不同版本的 create-t3-app 生成的目录结构略有差异,网络教程经常基于老版本或新版本,对照着看容易对不上,遇到这种情况不要慌,以你自己的生成结果为准。
初始化之后第一件事,改环境变量。.env文件里最核心的变量是DATABASE_URL,SQLite 模式下长得像这样:
DATABASE_URL="file:./db.sqlite"如果你一开始就打算用 Postgres,也可以把连接串写成postgresql://user:password@localhost:5432/t3code,但前期建议用 SQLite 跑通,开发体验更顺。还有 NEXTAUTH_SECRET 以及 NEXTAUTH_URL,NextAuth 阶段需要用到。初始化完成后先不要急着改代码,跑一遍基础流程,确认脚手架本身没问题,再开始动手。
3.2 定义数据模型与第一批 Router
数据库结构我沿用前面展示的三表模型。执行迁移:
npx prisma migrate dev --name init这条命令会创建数据库文件并生成 Prisma Client。执行完后src/server/db.ts里的db实例就可以用了。这里提醒一下:如果你在之后修改过schema.prisma,一定要重新跑迁移,否则 Prisma Client 的类型不会更新,前端调用 API 时拿到的字段还是旧结构。
第一批 Router 我建议集中覆盖“查询类”接口,先不做修改操作。t3code 的noteRouter我定义了 5 个 procedure:
// server/api/routers/note.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc"; export const noteRouter = createTRPCRouter({ getAll: protectedProcedure.query(({ ctx }) => { return ctx.db.note.findMany({ orderBy: { updatedAt: "desc" }, include: { tags: { include: { tag: true } } }, }); }), getById: protectedProcedure .input(z.object({ id: z.string() })) .query(({ ctx, input }) => { return ctx.db.note.findUnique({ where: { id: input.id }, include: { author: true, tags: { include: { tag: true } } }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1), content: z.string(), tagIds: z.array(z.string()).optional(), }), ) .mutation(({ ctx, input }) => { return ctx.db.note.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, tags: input.tagIds?.map((tagId) => ({ tagId })) ?? [], }, }); }), update: protectedProcedure .input( z.object({ id: z.string(), title: z.string().optional(), content: z.string().optional(), tagIds: z.array(z.string()).optional(), }), ) .mutation(async ({ ctx, input }) => { // 先删除旧标签关系再重建,避免残留 await ctx.db.noteTag.deleteMany({ where: { noteId: input.id } }); return ctx.db.note.update({ where: { id: input.id }, data: { title: input.title, content: input.content, tags: input.tagIds?.map((tagId) => ({ tagId })) ?? [], }, }); }), delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(({ ctx, input }) => { return ctx.db.note.delete({ where: { id: input.id } }); }), });这里我特别解释两个地方。
为什么用protectedProcedure而不是publicProcedure?因为笔记系统涉及用户数据,必须登录才能访问。protectedProcedure会在后台自动校验 NextAuth 的 session,如果没有登录直接返回 401,这个校验逻辑完全不需要你手写,这是 tRPC 与 NextAuth 集成的现成福利。
为什么update要手动先deleteMany再重建标签关系?这是多对多关系更新的经典坑。如果你直接在update里传入新的 tags 数组,Prisma 默认会尝试把旧的关联记录更新而不是替换,容易造成重复数据或者漏删。手动先删再插虽然多一条语句,但胜在逻辑清晰,不会出意想不到的结果。
3.3 前端页面接入 tRPC 数据流
Router 定义好之后,前端接入是最有意思的部分。在客户端的 React 组件里,代码长这个样子:
"use client"; import { api } from "~/trpc/react"; export function NoteList() { const { data: notes, isLoading, refetch } = api.note.getAll.useQuery(); if (isLoading) return <div>加载中...</div>; return ( <ul> {notes?.map((note) => ( <li key={note.id}> <h2>{note.title}</h2> <p>{note.updatedAt.toLocaleString()}</p> </li> ))} </ul> ); }有没有发现,调用useQuery时我没有传任何泛型参数,方法名和返回类型全部是自动推导的。一旦后端把note.getAll改名成note.list,前端这里会直接编译报错,你根本不需要回去翻接口文档。同理,如果你在 create 的 input 里漏了title字段,后端校验会直接拦下来,前端在编译期也会收到提示。这就是端到端类型安全的实感。
服务端组件接入 tRPC 的方式略有不同,因为不能直接用useQueryhooks。t3code 在笔记详情页使用服务端组件时,是通过server/api/trpc暴露的 caller 直接调用的:
// app/notes/[id]/page.tsx import { api } from "~/trpc/server"; export default async function NotePage({ params }: { params: { id: string } }) { const note = await api.note.getById({ id: params.id }); return <div>{note?.title}</div>; }这种方式适合首屏渲染时需要直接拿数据、且不需要交互的场景。它的好处是数据在服务端就已经准备好了,浏览器拿到的 HTML 就是完整内容,对 SEO 友好。坏处是这种页面不会自动处理 loading 状态,你需要在服务端组件外层自己处理“数据不存在”的情况。
实操中我的建议是:首屏核心数据用服务端组件直接取,后续用户的交互动作(比如点击标签筛选、打开编辑框)用客户端组件里的useQuery。二者结合,既保证首屏速度,又保证交互体验流畅。
3.4 认证与权限的最小实现
认证这层,t3code 选择 NextAuth.js。T3 脚手架里集成 NextAuth 是默认选项,但很多人集成完发现登录流程跑不通,原因是环境变量和 Provider 配置没对齐。
最基础的配置,在src/server/auth.ts里定义 Provider。t3code 为了开发方便,先用了 GitHub OAuth:
import GitHub from "next-auth/providers/github"; import { PrismaAdapter } from "@next-auth/prisma-adapter"; import { db } from "~/server/db"; export const authOptions: NextAuthOptions = { adapter: PrismaAdapter(db), providers: [ GitHub({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }), ], };使用 GitHub Provider 前需要先在 GitHub OAuth Apps 页面创建应用,设置的回调地址要填http://localhost:3000/api/auth/callback/github,否则登录跳转时会报 redirect_uri 不匹配错误。我在这里卡了最久,一度以为是 NextAuth 版本问题,最后发现就是回调地址没写对。
如果你不想折腾 OAuth,也可以先用 Credentials Provider 做邮箱密码登录。只是 Credentials Provider 与 Prisma Adapter 的配合没有 OAuth 顺滑,密码 hash 需要自己处理,不推荐新手一上来就搞。先跑通 OAuth,再回来扩展。
当 NextAuth 配置完成后,tRPC 上下文里的 session 就自动可用了。protectedProcedure内部的ctx.session.user就能拿到当前登录用户,而权限判断只需要在这一层处理一次,所有的页面和接口就都有了基础防线。
3.5 部署方案与构建细节
t3code 部署我选的是 Vercel,因为 Next.js 与 Vercel 的亲和度最高,推送代码后自动构建,无需操心服务器配置。部署前你需要在 Vercel 后台配置环境变量,包括DATABASE_URL、NEXTAUTH_SECRET、GitHub OAuth 的clientId和clientSecret。
如果你的数据库用的是 SQLite,那直接部署到 Vercel 会遇到一个麻烦:SQLite 是基于文件的数据库,Vercel 的 Serverless 环境是无状态的,文件系统在函数执行完成后会被回收,数据很可能丢。所以生产环境必须换成 Postgres 或 Turso 这种远程数据库。我部署时采用的是 Neon 提供免费 Postgres 实例,把连接串填到DATABASE_URL就搞定,跑迁移的时候需要本地连到远程库执行:
npx prisma migrate deploy注意是deploy而不是dev。dev会尝试创建迁移文件,deploy则只执行已有的迁移。生产环境用deploy更安全。踩过一次坑后我才明白,在 CI/CD 里把migrate dev写成部署命令是件危险的事,它会根据当前数据库状态自动生成迁移文件,可能导致不可预期的 Schema 变更。
4. 实战中绕不开的坑与排查经验
写到这里,t3code 的核心功能基本完成。但实际开发中真正消耗时间的从来不是那些“正常路径”,而是各种边界情况。这一章我把自己在开发过程中真实遇到的五类问题记录下来,包括现象、排查路径和最终解决办法,希望你不用再走一遍弯路。
4.1 服务端组件与 tRPC 的调用边界
t3code 早期版本里,我在一个客户端组件中不小心调用了服务端 tRPC 的 caller 方法,结果在浏览器控制台看到一堆关于“server-only”模块的报错。这个问题的根源是:服务端的apicaller 依赖 Node.js 运行时的环境,比如数据库连接、环境变量,这些在浏览器环境里根本不存在。
排查过程不算难,报错信息已经提示得很明显,但容易忽略的是写法细节。服务端组件里调用~/trpc/server导出的api是没问题的,但如果一个组件同时用了"use client"指令和api.note.getAll.query()这种调用方式,就一定会出问题。完全正确的做法是:客户端用api.note.getAll.useQuery(),服务端用await api.note.getAll(),两者导入来源不同,使用姿势也不同,不要混用。
如果你用 VSCode,建议给~/trpc/server配置一个 ESLint 规则或提示,至少在代码评审时多注意边界。我看到过很多人把api从 server 文件导入后,下意识在客户端组件里调用,直到部署后才暴露,这种问题越早发现成本越低。
4.2 类型不刷新导致的开发体验问题
t3code 开发阶段,我频繁修改 Prisma Schema,每次改完都要重新跑npx prisma migrate dev。但有一次我改了 Note 模型的字段后,前端使用api.note.getById的返回数据里却看不到新字段,重启 dev server 也没用。最后定位到问题是 Next.js 的 dev server 没有重新生成 tRPC 类型。
解决办法其实很简单:重启 dev server 一次,让 Next.js 重新扫描类型。如果你用的是 pnpm,还可以在postinstall脚本里加上prisma generate,确保每次安装依赖后都同步最新类型。后来我养成了习惯,改完 Schema 先跑npx prisma generate,再跑migrate dev,顺序不能反。generate负责更新客户端类型,migrate负责更新数据库结构,不要只跑其中一个。
4.3 Prisma 多对多关系的更新陷阱
前面提到update需要先删除旧的 NoteTag 关系,这个坑值得展开讲。Prisma 对多对多关系的处理默认比较“智能”,它允许你直接传一个嵌套的tags: { set: [...newTags] }。但实测下来,set方式在关系表有额外字段时很难用。如果某天你在 NoteTag 表里加了createdAt字段,set操作就无法覆盖这个字段的需求,你必须改用“先删后建”的模式。
另一个跟关系相关的坑是删除级联。当你删除一条 Note 时,Prisma 默认会拒绝操作,因为存在外键约束。你需要先手动删除相关的 NoteTag 记录,或者在你设计 Schema 时给关系加上onDelete: Cascade:
model NoteTag { note Note @relation(fields: [noteId], references: [id], onDelete: Cascade) tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) }这个配置可以让你直接删除 Note 或 Tag 时自动清理关系表,省去手动处理的步骤。
4.4 Tailwind 类名冲突与样式组织
由于 t3code 使用 Tailwind,类名冲突的问题相对少见。但开发过程中我发现一个痛点:当你使用@apply指令自定义组件类时,如果底层 Tailwind 版本升级,部分原子类可能会被改名,导致构建时报错。比如 Tailwind v3 的中bg-opacity-50在 v4 里改成了bg-black/50这种写法,旧代码直接失效。
t3code 的应对方式是把基础 UI 组件的样式集中在一个button.tsx或card.tsx里,用@apply声明语义类。但如果需要兼容多个版本,更安全的是直接写原子类组合,而不是依赖@apply。另外,tailwind.config 中content字段一定要包含所有用到的文件路径,类名才能被正确扫描。曾经有次我新建了一个utils/format.ts文件,里面用到了动态类名拼接,忘了加进 content 配置,导致样式一会儿有一会儿没有,排查了好一阵子才意识到是扫描路径的问题。
4.5 构建体积与包体控制的感悟
t3code 的next build产物在一开始比较大,主要原因是 Prisma 和 tRPC 相关的包被打进了客户端 bundle。T3 社区推荐使用server-only标记来阻止服务端代码被客户端引用。我在src/server/db.ts里加了import "server-only",同时确保src/trpc/server.ts中的代码不会在客户端被引入。
这里还有一个容易被忽视的点:zod的校验器同时被前后端使用,它本身并不大,但如果你引入了整个zod包,tree-shaking 不一定生效。建议在 tRPC router 里只引入你用到的方法,避免把整个校验库都带进客户端构建。
5. 性能调优与 t3code 的进阶思路
功能做到能跑是一回事,做好是另一回事。这一章记录我在 t3code 上做的一些性能优化和后续扩展的想法,大家遇到类似场景可以直接抄作业。
5.1 React Query 缓存策略
t3code 的数据请求底层是 React Query,它自带缓存、失效、重试机制。默认配置下,useQuery会根据 key 缓存数据,相同参数重复请求会直接命中缓存,不会重复打后端。但项目里多个组件可能用同一个 query 的不同参数,比如笔记列表按标签筛选,你需要给 query 设置合理的 key。
t3code 的做法是在api.note.getAll.useQuery(undefined, { staleTime: 30_000 }),表示 30 秒内数据不过期,避免每次切换标签都触发加载动画。同时,在创建、更新、删除成功后,我们会调用api.note.getAll.invalidate()让列表自动刷新,不需要手动刷新页面。这个机制一旦用顺手,几乎回不到手动管理数据 reload 的模式。
5.2 数据预取与加载态优化
Next.js 的服务端组件天然支持数据预取。t3code 在列表页同时使用服务端预取和客户端查询的方式:服务端先取一次数据填进 HTML,客户端组件挂载后,React Query 会发现已有缓存数据,不再需要显示 loading,直接从缓存渲染。
实现方式需要配合 tRPC 的 server caller 手动把数据注入 React Query 的缓存。这里我不展开到全部代码,只提醒一个重点:服务端预取的数据与客户端查询的 key 一定要一致,否则客户端会认为没有缓存。我在这个坑里折腾过版本升级后的 key 不一致问题。
5.3 团队协作时的代码生成与规范
t3code 目前只是原型,但如果要扩展到团队协作,我建议引入一些规范和工具约束。ESLint 与 Prettier 是不可或缺的,T3 脚手架默认已经包含了。除此之外,应该把 Prisma migrate 流程写进 CI,保证每次数据库变更都有审计记录。
在 Router 规范上,我们立了一条简单约定:所有“读取类”操作以get开头,所有“写入类”操作以create/update/delete开头,这样调用方只凭方法名就能判断操作类型。这条约定不依赖编译器,完全靠团队自觉,但效果立竿见影,代码 review 时不需要再逐个点开 procedure 看逻辑。
另外,t3code 的 tRPC procedure 有完善的错误信息吗?我强烈建议在每个 mutation 里加上自定义错误消息,比如throw new TRPCError({ code: "NOT_FOUND", message: "笔记不存在" })。否则前端拿到的只是一个笼统的 500 错误,排查问题会非常痛苦。
5.4 从一个原型到可维护项目:我的最终体会
做完 t3code,我最大的感受是:类型安全的收益不是在项目刚开始、所有代码都热乎的时候体现出来的,而是在项目维护到第三个月、你不得不改一个比较核心的数据结构时才真正显现。因为 t3code 的所有类型都是自动推导、相互引用的,当我给 Note 模型增加一个coverImage字段时,改完 Prisma Schema,重新生成类型,前端所有用到 Note 的地方立刻在编辑器里标出错误——哪个组件需要显示封面图,哪个接口需要接收新参数,一目了然。这在传统 REST 项目里是做不到的,你只能靠人肉搜索所有interface Note的定义去更新。
如果你还在犹豫要不要尝试 t3code 这套技术方式,我的建议是找一个小需求先跑一遍数据全链路。不一定要完整复刻我的知识库场景,哪怕只是一个“待办事项 + 标签”的小应用,只要你能跑通 Next.js 页面、Prisma 表、tRPC Router、前端 useQuery 这条链路,你就一定能感受到这种开发方式的酥脆感。
最后再分享一个我在 t3code 后续维护中沉淀下来的小技巧:在server/api/routers中新增一个模块时,不要忘了在根 router 里注册。这个步骤非常简单,但如果忘了, tRPC 并不会启动时报错,而是等你调用接口时才会发现“方法不存在”。把它写进你的检查清单,可以省去一次无谓的排查时间。