☰
T3 Stack全栈开发实录:从TypeScript到tRPC的类型安全之路
2026/10/27 7:01:56 网站建设 项目流程

t3code这个名字,最初只是我顺手给一个练习项目起的代号:三个T加一个code,敲着顺口。等我把技术栈定下来回头看,才发现这三个T字头刚好撞上了T3技术栈的核心组成——TypeScript、Tailwind和tRPC,后面还要加上Next.js和Prisma,于是这个代号就顺势成了项目名。

这篇文章是我把t3code从空目录一路推到生产环境的全程记录:为什么放弃自由混搭技术栈而选择T3,数据层怎么建模才能少返工,tRPC如何把类型从数据库一路送到浏览器按钮上,以及真正上线之后遇到的那几个让人挠头的问题。如果你正准备用create-t3-app搭建自己的全栈项目,或者已经在用T3但不太清楚各部分的边界该怎么划,这篇应该能帮你省掉不少弯路。

1. t3code的起点:为什么选中T3技术栈而不是继续自由混搭

1.1 我原来那套"React加Node自由组合"为什么撑不住

在做t3code之前,我的全栈项目一贯是自由混搭:前端Next.js,后端Express或者Next.js自带API routes,数据库用MongoDB或者MySQL,接口之间全靠自己约定。项目刚起步时一切都很好,小团队、小数据量、需求也不复杂。可一旦功能开始多起来,问题就一个接一个冒出来。

最典型的一个场景:后端接口返回的字段从userName改成了displayName,前端调用处散落在七八个组件里。就算当时记得改,一两周之后又有新需求要加字段,这时候接口文档、TypeScript接口定义、前端实际使用的类型声明,三份东西经常处在不同的版本。改一处漏两处,运行时才发现类型不匹配。我当时经常自嘲,项目里的类型安全只存在于写代码那一刻的编辑器插件里,只要一刷新页面就原形毕露。

另一个问题是API层本身的成本。一个中等复杂度的页面,列表查询、创建操作、状态更新、删除确认,背后就是四五组接口。每个接口都要写路由定义、参数校验、错误处理,前端还要包一层带类型的请求函数。这些代码没有技术含量,却要占用大量注意力。到了项目后期,接口文件比业务代码还多,维护起来非常累。

T3技术栈正是冲着这些痛点来的。它不是一个全新的框架,而是把当时前端生态里最擅长做"类型安全"的几个工具绑在一起,形成一套默认组合,让数据从数据库查询结果到前端UI组件的props之间,全程有同一个类型系统在背后兜底。

1.2 T3的三个T到底解决了什么问题

T3 Stack的核心理念,简单说就是"类型安全优先、约定优于配置"。它默认绑定的是这样一组技术:

技术在t3code里扮演的角色解决的核心问题
Next.js(App Router)页面路由与渲染框架一套代码同时搞定服务端渲染和客户端交互
TypeScript全项目类型基础让类型检查在编译期就拦截错误
tRPC前后端接口层免去手写API契约,接口类型自动推断
Prisma数据库ORM数据库表结构映射为TypeScript类型
Tailwind CSS样式方案样式写在组件里,不需要维护全局CSS文件

这里面的关键,不是某个单独工具多强,而是它们串起来之后的效果。以前我在前端定义一个Task接口,在后端又要定义一遍,数据库里还有一份表结构,三者靠人肉同步。T3这套方案里,数据库schema由Prisma定义,Prisma Client基于schema生成类型,tRPC的router输入输出自动继承这些类型,前端React组件再从tRPC拿到类型。整个链路只有一个源头,改字段就是改一处。

用生活类比的话,过去前后端各揣一份地图走同一座城市,两个地图总有一个人过期;T3的做法是整个团队只盯同一张动态更新的路况图,路变了图跟着变,不存在信息差。

1.3 t3code要做一个什么样的东西

技术选型不能凭空做,得有项目撑着。t3code的定位是"一个轻量级任务看板",面向小团队内部使用。核心功能有三块:

  • 项目空间:创建项目,每个项目有独立的slug和描述;
  • 任务管理:在项目下创建任务,设置状态(待办、进行中、已完成、已归档)和优先级;
  • 人员视图:任务可以指派给某个用户,支持按负责人过滤。

这个规模刚好合适:它需要多表关联(用户、项目、任务)、需要鉴权(登录后才能操作)、需要大量增删改查,但又不会复杂到让一篇文章失去焦点。选这个方向还有一个私心——真实的小工具比空泛的Demo更容易坚持做完,做完之后也真的会在团队内部被用起来,从而逼着我把部署、迁移、缓存这些问题都处理好。

2. 从create-t3-app到t3code:脚手架落地的目录整顿与环境搭建

2.1 初始化命令与交互选项:我最终勾选了哪些

T3官方提供的脚手架是create-t3-app,初始化命令很简单:

npm create t3-app@latest

交互式命令行会询问项目名、包管理器,以及要启用哪些模块。重要的一点是,它把所有选项都列出来让你勾,默认并不会帮你做决定。我当时的选择是全都要:TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth、ESLint和Prettier,路由模式选了App Router。

有朋友会问,是不是勾选的东西越少越好?我的看法恰恰相反:第一次跑T3就不要自作聪明地砍模块。它的脚手架是经过整体设计的,每个选项之间都有配合关系。比如你不勾Prisma,NextAuth的数据库方案就得另找;你不勾tRPC虽然也能用API routes,但那就不是T3的完整形态了。先完整跑起来,再在理解的基础上做减法,比一开始就砍得七零八落要稳妥得多。

初始化完成之后,脚手架会自动创建git仓库并安装依赖。整个过程一般在三分钟内,到这里你已经有了一套完整的全栈骨架。接下来才是真正花时间的环节——理解骨架、修整骨架。

2.2 目录结构解读:哪些文件是生成的,哪些被我挪了位置

create-t3-app生成的目录结构非常规整,这也是我后来最欣赏它的一点。核心结构长这样:

t3code/ ├── prisma/ │ ├── schema.prisma │ └── seed.ts ├── src/ │ ├── app/ │ │ ├── layout.tsx │ │ ├── page.tsx │ │ └── api/ │ │ └── trpc/ │ │ └── [trpc]/ │ │ └── route.ts │ ├── server/ │ │ ├── api/ │ │ │ ├── root.ts # 聚合所有子router │ │ │ ├── routers/ │ │ │ │ └── post.ts # 示例router │ │ │ └── trpc.ts # tRPC实例与鉴权中间件 │ │ ├── auth.ts # NextAuth配置 │ │ └── db.ts # Prisma Client单例 │ ├── trpc/ │ │ ├── react.tsx # 前端tRPC hooks │ │ ├── server.ts # 服务端调用入口 │ │ └── shared.ts │ ├── env.mjs # 环境变量类型校验 │ └── styles/ │ └── globals.css

第一次看到这个结构,很多人会困惑src/server/api和src/trpc的区别。简单说,src/server下是纯后端逻辑:数据库访问、路由定义、鉴权上下文。src/trpc下是双端共用的客户端封装——react.tsx给前端组件用的hooks,server.ts给服务端组件直接调用用的client封装。

我做的调整主要有三处。第一,把示例的post.ts替换成了业务router,按照task / project / user三个模块拆分,放在routers目录下。第二,把公用的zod校验schema抽出来放到server/schema.ts,避免router文件里塞满一长串输入定义。第三,把环境变量文件从env.mjs补充了生产环境的额外校验规则。这些调整不是为了标新立异,而是为了后面加功能时,目录边界足够清晰。

2.3 环境变量、数据库启动与第一条本地记录

T3脚手架里内置了一个容易被忽略的好东西——@t3-oss/env-nextjs。它用zod对process.env做运行时校验,你忘记配置某个环境变量时,启动阶段就会直接报错,而不是等部署到线上才炸。

t3code的核心环境变量如下:

import { createEnv } from "@t3-oss/env-nextjs"; import { z } from "zod"; export const env = createEnv({ server: { DATABASE_URL: z.string().url(), NEXTAUTH_SECRET: z.string().min(1), NEXTAUTH_URL: z.string().url(), }, client: { NEXT_PUBLIC_APP_NAME: z.string().optional(), }, runtimeEnv: { DATABASE_URL: process.env.DATABASE_URL, NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, NEXTAUTH_URL: process.env.NEXTAUTH_URL, NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME, }, });

数据库我选了PostgreSQL,本地开发直接用Docker起一个,比本机安装干净得多,也方便后续迁移到生产环境。一条命令就够了:

docker run --name t3code-pg \ -e POSTGRES_PASSWORD=t3code \ -e POSTGRES_DB=t3code \ -p 5432:5432 \ -d postgres:16

然后执行Prisma迁移,生成第一张表的同时生成Prisma Client:

npx prisma migrate dev --name init

到这里,脚手架自带的示例post表已经在本地数据库里落地。第一次看到npx prisma studio里出现数据表,整个项目才真正算"活"了。接下来重头戏是数据建模——我花在这个环节的时间,比写接口和页面都要多。

3. Prisma数据建模:任务、项目与用户这三张表里的取舍

3.1 核心模型:把需求翻译成关系

t3code的数据模型不复杂,就三张核心表,外加一张用户和项目的中间表。但就是这三张表,我在建模过程中来回改了至少五次,每一次改动都涉及状态字段怎么表达、级联删除怎么处理、索引怎么加这些细节。

最终的schema核心部分如下:

enum TaskStatus { BACKLOG IN_PROGRESS DONE ARCHIVED } model User { id String @id @default(cuid()) name String? email String @unique image String? tasks Task[] @relation("Assignee") memberships ProjectMember[] } model Project { id String @id @default(cuid()) name String slug String @unique description String? tasks Task[] members ProjectMember[] createdAt DateTime @default(now()) } model ProjectMember { userId String projectId String role String @default("MEMBER") user User @relation(fields: [userId], references: [id], onDelete: Cascade) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) @@id([userId, projectId]) } model Task { id String @id @default(cuid()) title String description String? status TaskStatus @default(BACKLOG) priority Int @default(2) projectId String project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) assigneeId String? assignee User? @relation("Assignee", fields: [assigneeId], references: [id], onDelete: SetNull) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([projectId, status]) @@index([assigneeId]) }

几个值得展开说明的选择。

主键我用的是cuid()而不是自增整数。自增主键在单体小项目里没毛病,但一旦后续做数据同步、导入导出或者分布式部署,自增id很容易成为瓶颈。cuid是客户端生成的字符串id,天然无序、不易枚举,也方便前端在创建数据时先拿到id做乐观更新。t3code虽然现在规模不大,但这个习惯让我后面加功能时完全不用回头改主键类型。

ProjectMember是一张多对多中间表。为什么不用Prisma的隐式多对多关系?因为隐式关系没法存附加字段。实际场景里,项目成员是有角色的——管理员能删项目,普通成员只能操作任务。所以中间表必须有role字段,这就必须用显式关系表。

3.2 枚举与索引:状态字段的建模选择

TaskStatus我用了Prisma枚举而不是普通的字符串字段。原因很直接:任务状态应该是有限集合,用枚举可以从数据库层面就阻止非法值写入。如果后面要把某个状态改名,Prisma迁移虽然会多几步操作,但比代码里到处判断字符串要安全得多。

优先级字段我用的是Int而不是枚举,这一点可能会有争议。我的理由是优先级天然需要排序——ORDER BY priority按整数排就行,不需要在应用层做映射。1到5的数值配上@default(2),语义是"普通优先级",查询时按降序排就得到高优在前。

索引方面,最常用的一句话是@@index([projectId, status])。任务列表页几乎总是"先按项目过滤,再按状态分组看",这个复合索引直接对应查询模式。assigneeId单独建索引是为了支持"我负责的任务"这种个人视图。很多人建模时不建索引,数据量小感觉不到,等跑到线上几千条记录后一个全表扫描就让接口慢下来,那时候再补救就麻烦了。

3.3 用migrate和seed让数据先跑起来

Prisma的迁移命令有两个,很多人搞不清区别。migrate dev是开发环境专用,它会检测schema变更、生成新的迁移SQL,并自动应用到本地数据库。migrate deploy是生产环境专用,只执行已经生成的迁移文件,不会让你交互式确认。t3code的发布流程里,部署时跑的就是migrate deploy。

初始化表之后,我立刻写了seed脚本。这一步非常推荐,尤其是项目要持续开发的情况下,没有种子数据,每次测试一个页面都要手动造账号、造项目、造任务,纯属浪费时间。

seed脚本在prisma/seed.ts,用tsx直接运行,不需要额外编译。package.json里配好入口:

"prisma": { "seed": "tsx prisma/seed.ts" }

seed里做的事很简单:创建一个演示账号,创建两个项目(一个带若干任务,一个空项目专门用来测空状态),再给任务随机指派负责人。这样每次重置数据库后,运行npx prisma db seed,整套演示数据就回来了。

3.4 一对多与级联删除:我在schema里反复改过的地方

这里我想单独说说级联删除。刚开始我图省事,把任务到项目的onDelete设成了Cascade,意思是项目删了任务一起删。后来复盘觉得有点慌:如果用户误删项目,所有任务就真没了。但做成纯软删除,查询每个地方又要带where: { archivedAt: null },代码很啰嗦。

最终我的方案是保留物理删除的Cascade,但加上了ARCHIVED状态作为缓冲。也就是说,界面上的"删除项目"并不直接调用project.delete,而是先给项目下的任务批量标记为ARCHIVED,确认无误后才能真正删干净。这样既保留了大扫除的能力,也避免了手滑造成的数据灾难。

还有一处是assignee的onDelete: SetNull。用户被删了,任务不应该跟着消失,负责人字段应该变成空,任务回到"未指派"状态。这种关系在现实中几乎必然发生,建模的时候提前想好,比上线后报错再补迁移要舒服得多。

4. tRPC全链路:类型从数据库一直走到按钮的管线搭建

4.1 router与procedure:tRPC世界的核心组织单位

如果说Prisma解决了"数据库有类型"的问题,tRPC解决的就是"接口有类型"的问题。它的核心组织单位是两个:router和procedure。

一个router就是一组接口的集合,可以嵌套。比如taskRouter里包含listByProject、create、updateStatus这些procedure。一个procedure就是一次具体调用,分为query(查询)和mutation(写操作)两种。这个类比REST的话,router近似于controller,procedure近似于一个端点的处理方法,但它比REST多出来的关键能力是:输入和输出的TypeScript类型由框架自动推断,完全不需要手写接口声明。

以t3code的创建任务为例:

import { z } from "zod"; import { protectedProcedure, router } from "../trpc"; import { db } from "../../db"; export const taskRouter = router({ create: protectedProcedure .input( z.object({ projectId: z.string().cuid(), title: z.string().min(1).max(100), description: z.string().optional(), status: z.enum(["BACKLOG", "IN_PROGRESS", "DONE", "ARCHIVED"]), priority: z.number().int().min(1).max(5).default(2), }) ) .mutation(async ({ ctx, input }) => { return db.task.create({ data: { ...input, assigneeId: ctx.session.user.id, }, }); }), });

注意这里zod的schema不仅仅做输入校验,它同时也是类型的唯一来源——前端调用这个mutation时,传入的参数会被自动推断成完全相同的类型。如果后端改了字段名,前端在编译期就会报错。这个特性给我的开发体验带来本质变化:以前是"写完接口,再祈祷前端没写错",现在是"编译器替我盯着,写错根本编译不过"。

4.2 context与鉴权:把session塞进每一次调用

tRPC的鉴权思路和REST中间件非常像,但更优雅。在t3code里,所有需要登录的操作都走protectedProcedure,它是由一个中间件实现的:

import { initTRPC, TRPCError } from "@trpc/server"; import type { Context } from "./context"; const t = initTRPC.context<Context>().create(); const isAuthed = t.middleware(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { user: ctx.session.user, }, }); }); export const router = t.router; export const publicProcedure = t.procedure; export const protectedProcedure = t.procedure.use(isAuthed);

这个设计的妙处在于,中间件在拦截非法请求的同时,还能往ctx里注入user对象。后续handler里不需要再通过NextAuth去查一遍当前用户,直接拿ctx.session.user.id用就行了。既省了一次数据库查询,也让每个procedure的代码更干净。我有的同事习惯在每个接口里写"从header里解析token"的代码,看到tRPC这个写法后直呼之前白写了不少重复代码。

4.3 在Server Component里直接调用:createCaller的实战价值

T3 Stack配合Next.js App Router还有一个隐藏优势:在服务端组件里不需要通过HTTP调用自己的接口,直接调用tRPC的caller就行。

t3code的任务列表页就是这么写的:

import { auth } from "@/server/auth"; import { createCaller } from "@/server/api/root"; import { redirect } from "next/navigation"; export default async function ProjectPage({ params }: { params: { slug: string } }) { const session = await auth(); if (!session?.user) redirect("/login"); const caller = createCaller({ session, headers: await headers() } as any); const tasks = await caller.task.listByProject({ slug: params.slug }); return <TaskBoard initialTasks={tasks} projectSlug={params.slug} />; }

这相当于服务端组件直接调用了后端函数,没有走网络请求,也没有重复的HTTP序列化开销。但前端拿到的数据结构和客户端通过tRPC hook拿到的一模一样,类型也完全一致。这个把"服务端数据获取"和"前端状态缓存"统一起来的能力,是我认为T3这套组合拳里最被低估的一点。以前用Next.js API routes,要么写两套调用方式,要么绕一大圈做类型映射,现在一个caller全搞定。

4.4 客户端调用、缓存失效与乐观更新的组合拳

一旦数据到了客户端组件,交互逻辑走的是tRPC的React hooks。t3code里创建任务后的刷新逻辑是这样写的:

"use client"; import { api } from "@/trpc/react"; export function CreateTaskForm({ projectId }: { projectId: string }) { const utils = api.useUtils(); const createTask = api.task.create.useMutation({ onSuccess: () => { utils.task.listByProject.invalidate({ slug: projectSlug }); }, }); const handleSubmit = (title: string) => { createTask.mutate({ projectId, title, status: "BACKLOG", priority: 2, }); }; return <form onSubmit={/* ... */}>{/* ... */}</form>; }

这里的invalidate是tRPC基于TanStack Query实现的缓存失效机制。创建操作成功后,相关列表的缓存自动作废并重新拉取。相比手写REST客户端时"请求成功后手动调用刷新函数",这套机制更省心,因为它知道哪些数据依赖哪些接口,不需要程序员自己去梳理。

再进一步是乐观更新。在tRPC的useMutation里可以配置onMutate直接往缓存里插入一条临时数据,后端返回后再用onError回滚。这个能力把交互体验拉到了接近原生应用的水平。t3code里"拖动任务改状态"这个功能就用了乐观更新——拖过去那一瞬间UI已经变了,网络请求在背后跑,失败才弹提示回滚。用户基本感知不到延迟。

5. 页面层分工:Server Component和Client Component怎么排队

5.1 适合放在服务端的页面:列表页渲染

刚接触App Router的时候,我犯过一个典型错误:所有页面都写"use client"。后来发现,这等于放弃服务端渲染的全部好处,还让每个页面都打包一堆客户端JavaScript。

在t3code里,列表页和详情页我都放在服务端。原因不复杂:

  • 任务列表页需要从数据库查询数据,查询本来就该靠近数据库做,而不是先交给浏览器再发起一次fetch;
  • 这类页面没有复杂的即时交互,渲染结果可以直接输出HTML,首屏更快;
  • 服务端组件直接把数据作为props传给子组件,压根不需要在浏览器里再加载数据。

RSC的边界感很重要。我的判断标准是:这个组件渲染时需不需要事件处理器、需不需要useState、需不需要浏览器API?如果三个都不需要,就放在服务端。列表页就是典型——它只负责把数据库里的数据查出来展示,真正需要交互的是底下的任务卡片和创建表单,那些才是客户端组件。

5.2 适合放在客户端的交互:创建任务的表单

创建任务的表单必须放客户端,因为它有状态、有事件、有异步提交。t3code里的CreateTaskForm是一个标准的"use client"组件,它接收projectId作为props,内部维护标题输入、状态选择、优先级选择这些本地状态。

客户端组件需要注意的细节是,它不能直接调用服务端的caller,只能通过tRPC的hooks走HTTP请求。这正好形成了分工:服务端组件负责"读",客户端组件负责"写",而tRPC同时为两种场景提供了类型安全的通道。

表单提交的体验设计上,有一个小细节值得提:提交按钮在mutation进行时应该改用pending状态,禁用重复点击。tRPC的useMutation返回的isPending字段就是为了这个场景准备的。很多人忽略这一点,快速点两下提交按钮就会创建重复任务,这是非常常见的低级bug。

5.3 加载态、空状态和错误处理的落地

页面和数据都到位之后,真正影响体验的是边界状态。t3code里我做了三层次的兜底:

加载态用Tailwind的animate-pulse做骨架屏,避免打开页面时白屏或者闪现loading文字。tRPC查询返回的isPending状态配合骨架屏组件,在数据还没到达时展示几个灰色的占位块,视觉上比转圈舒服得多。

空状态往往是大家最后才补的东西。t3code里,一个还没有任务的"看板"如果只显示空白,用户会困惑是加载失败还是真的没数据。我在空状态里放了一条提示文案加一个"创建第一个任务"的按钮,把空状态也变成功能入口。

错误处理分两层。服务端组件那边,如果caller抛异常,Next.js的error.tsx会兜底,显示一个可重试的错误页。客户端交互那边,mutation失败时用onError拿到错误信息,弹出错误提示并且回滚乐观更新。这一套做下来,用户在正常流程里几乎感受不到异常,但异常发生时也不会一头雾水。

6. 部署环游记:从Vercel一键部署到Docker自托管的迁移实录

6.1 第一次部署:Vercel上的顺滑和局限

T3项目部署到Vercel几乎是一条坦途。把t3code推到GitHub,import到Vercel,填上环境变量,Push后自动构建,几分钟就上线了。Vercel对Next.js的原生支持确实无与伦比,Prisma的serverless适配也做得很成熟,冷启动虽然有,但个人小工具完全能接受。

但用了一段时间之后,我发现了几个天花板。

首先是冷启动延迟。免费版的Serverless函数闲置后会被冻结,用户第一次打开页面可能要等三四秒。对内部工具来说,这个延迟很伤体验。其次是连接数问题。Serverless每个实例各自维护数据库连接,PostgreSQL的默认连接池很容易被打满。我尝试过用Prisma的pgbouncer连接模式,也把connection_limit调小过,能缓解,但每次都像在做外科手术。

第三个问题更本质:Vercel的文件系统是临时的,没法持久化用户上传的东西。t3code后来加了任务附件功能,文件存哪成了新问题。加对象存储当然可行,但项目只是个小团队内部工具,为了一个附件功能引入云存储等于大炮打蚊子。综合考虑之后,我决定迁移到一台小服务器上自托管。

6.2 自托管:Docker Compose与Nginx反代的配置要点

自托管方案我选了Docker Compose,因为它能把数据库和应用一起编排,在一台机器上跑起来最省心。t3code的docker-compose.yml简化后长这样:

services: db: image: postgres:16 restart: always environment: POSTGRES_USER: t3code POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_DB: t3code volumes: - pgdata:/var/lib/postgresql/data app: build: . restart: always depends_on: - db environment: DATABASE_URL: postgresql://t3code:${DB_PASSWORD}@db:5432/t3code NEXTAUTH_SECRET: ${NEXTAUTH_SECRET} NEXTAUTH_URL: ${PUBLIC_URL} ports: - "127.0.0.1:3000:3000" volumes: pgdata:

应用镜像构建的关键,是Next.js的standalone模式。next.config里设置output: "standalone"后,构建产物里会生成一个精简的.next/standalone目录,里面包含了运行所需的所有依赖,用它作为运行目录,镜像体积能小很多,启动也快。

对应的Dockerfile大致是:

FROM node:20-alpine AS builder WORKDIR /app COPY . . RUN npm ci RUN npm run build FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/public ./public EXPOSE 3000 CMD ["node", "server.js"]

Nginx配置反代到本机3000端口,加上HTTPS证书后,整个服务就和外部世界接上了。这部分配置我建议直接抄Nginx官方文档的Next.js反向代理模板,然后注意一个细节:静态资源要缓存。因为Next.js构建后的静态文件都带哈希文件名,可以放心加长缓存头,Nginx里location /_next/static/设置Cache-Control: public, max-age=31536000, immutable,页面更新不会走到旧缓存。

6.3 数据库迁移、备份与回滚的线上流程

自托管之后,发布流程变成了一条固定流水线。每次要发新版本,先在本地或CI里跑测试,构建镜像,然后执行两个关键步骤:

npx prisma migrate deploy docker compose up -d --build app

migrate deploy必须在代码更新之前跑,因为新版本代码大概率依赖新表结构。顺序反了的话,新版代码一启动就查询不存在的字段,直接白屏。

备份我用一个简单的cron脚本,每天凌晨用pg_dump导出整个数据库,保留最近7份。别小看这个土办法,很多小团队出事时最大的遗憾就是"我们没做过备份"。恢复流程也验证过一次:起一个空的Postgres容器,把dump文件导进去,应用连上就恢复。整个回滚时间控制在十分钟内,对于t3code这个体量已经足够了。

6.4 上线后踩过的三个真实问题

迁移到自托管后,还是陆续遇到了几个线上问题。我把它们整理成一个表格,每个都附上了排查思路:

问题现象根因修复方案
高峰期页面偶发数据库连接报错Prisma连接池被跑满,Docker容器和宿主机之间没有限制并发缩短Prisma连接空闲回收时间,把应用容器常驻实例数固定为1并限制Node进程内存
新版本发布后,部分浏览器里样式错乱Nginx对_next/static的缓存头配置不当,旧文件被缓存静态资源缓存策略改为哈希文件名加immutable,只在发布时清理Nginx缓存
任务大列表接口查询间隔性超时接口一次全量查出所有任务,未做分页,数据量大后内存和耗时飙升列表接口加limit与cursor分页,前端滚动加载,同时重新校验索引是否覆盖查询

第一个问题最典型。它的排查链路是这样的:先看到日志里出现Prisma的P1008超时错误,然后意识到不是某一次查询慢,而是连接获取不到。接着查看容器连接数和Swap,发现Prisma默认连接数在Docker里启动多个worker时会被放大。最后把connection_limit显式设置成10,并给pgBouncer连接模式开启后,问题稳定消失。

第二个问题的根源在于我当时把静态资源缓存头配置得太激进,导致新版本发布的CSS文件被旧哈希的URL指向了过期版本。排查时在浏览器无痕模式里一切正常,普通标签页却样式错乱,这个对比是判断缓存问题的经典信号。

第三个问题给了我一个教训:索引和数据分页最好在建模阶段就规划好,不要等数据量大了再补。t3code后来把任务列表改成每页50条、滚动加载,接口响应时间从几百毫秒降到几十毫秒,体感明显。

7. 项目复盘:那些让我庆幸和头疼的选型

7.1 让我庆幸的五个决定

项目上线稳定运行之后,我给自己做了一次复盘。先说说那些让我庆幸的决定。

第一是坚持全链路使用tRPC,没有为"图省事"保留REST接口。中间有一段时间我很想加一个原生REST接口给外部脚本调用,但最终克制住了。这避免了项目里出现两套接口风格并存的状态,一切类型安全都被约束在同一条管道里。

第二是主键选用cuid()。虽然在本地开发时它显得比自增id繁琐,但后面做数据导入、做双写、做日历视图时,客户端先拿到id再做乐观更新变得非常自然。

第三是页面默认服务端渲染。刚开始写t3code时,我习惯性地给页面加"use client",后来逐个纠正。纠正之后构建产物体积明显缩小,首屏速度也上来了。

第四是给所有的tRPC输入都加了zod校验,一步都没有偷懒。后来团队里有人想加一个不受限的查询参数,被我在code review里拦了下来。输入校验不是给用户添堵,它是防止未来某天自己犯错。

第五是自托管选了Docker Compose而不是手工配置Node进程。Docker让整个部署可重复、可回滚、可迁移,这是手工操作做不到的。

7.2 让我后悔的四个选择

有庆幸就有后悔,这些后悔同样有价值。

第一是初期没有写seed脚本,导致前两周几乎每天手动造测试数据。每次页面刷新想看多项目多任务状态时,都要打开Prisma Studio手动插入几条记录。这个时间成本算下来够写几十个seed函数了。

第二是slug和id的混用。项目列表页用了slug作为URL参数,但任务查询早期用的是项目id,导致有些地方要传入{ slug },有些地方要传入{ projectId },类型上没问题,但心智负担不小。如果一开始就统一用slug,后面会简单很多。

第三是页面边界的划分在前期摇摆。我在一个列表页上花费了大量时间试图让它既是Server Component又是Client Component,折腾了很久之后才发现正确的做法是把不同职责拆成不同组件,而不是让单个组件同时承担两种角色。

第四是给所有可选项全部勾选之后,前期有段时间心智负担很重。脚手架生成的文件太多,每个文件都在问"这个是什么意思"。这不是脚手架的问题,是我还没建立完整认知就开始改代码。后来我花了一个下午把所有生成的文件从头到尾读了一遍,才彻底理顺。

7.3 如果重新开始,我会调整的三件事

如果让我重新做一遍t3code,有三个地方我会从一开始就调整。

第一,schema先行。我会先花半天时间在白板上画ER图,把用户、项目、任务、中间表的关系全部确定,再动笔写Prisma schema。而不是边写代码边改模型,导致迁移文件比业务代码还多。

第二,第一个功能一定要"完整打通"。从登录进入系统,到创建一个任务,到任务出现在列表里,最后保存到数据库,这个最小闭环应该在项目初期就做出来。它比做十个半成品页面有用得多,因为它能验证整条链路的类型、鉴权、调用、渲染都在按预期工作。

第三,把数据库迁移步骤嵌入部署流程的第一步。我在自托管初期每次部署都要手动跑npx prisma migrate deploy,有两次漏跑了导致线上报错。后来我在CI脚本里把迁移步骤固定为发布前置检查,再也没有犯过这个错。

t3code现在还在运行,功能不算多,但加功能和重构都很顺手。这几年我做过不少全栈项目,这次是维护成本最低的一次。如果你也正在纠结要不要走T3这套,我的建议是别纠结太久——找个周末把create-t3-app跑起来,先让类型从数据库一路走到按钮上,那种能靠编译器兜底的感觉,会替你做决定。

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

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

立即咨询