资讯动态

T3 Stack全栈开发实录:从TypeScript到tRPC的类型安全之路

发布时间:2026/10/27 7:01:59 来源:尧图企业网站定制
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.jsApp 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-applatest交互式命令行会询问项目名、包管理器以及要启用哪些模块。重要的一点是它把所有选项都列出来让你勾默认并不会帮你做决定。我当时的选择是全都要TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth、ESLint和Prettier路由模式选了App Router。有朋友会问是不是勾选的东西越少越好我的看法恰恰相反第一次跑T3就不要自作聪明地砍模块。它的脚手架是经过整体设计的每个选项之间都有配合关系。比如你不勾PrismaNextAuth的数据库方案就得另找你不勾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给前端组件用的hooksserver.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_PASSWORDt3code \ -e POSTGRES_DBt3code \ -p 5432:5432 \ -d postgres:16然后执行Prisma迁移生成第一张表的同时生成Prisma Clientnpx 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与proceduretRPC世界的核心组织单位如果说Prisma解决了数据库有类型的问题tRPC解决的就是接口有类型的问题。它的核心组织单位是两个router和procedure。一个router就是一组接口的集合可以嵌套。比如taskRouter里包含listByProject、create、updateStatus这些procedure。一个procedure就是一次具体调用分为query查询和mutation写操作两种。这个类比REST的话router近似于controllerprocedure近似于一个端点的处理方法但它比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.contextContext().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推到GitHubimport到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_ENVproduction COPY --frombuilder /app/.next/standalone ./ COPY --frombuilder /app/.next/static ./.next/static COPY --frombuilder /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-age31536000, immutable页面更新不会走到旧缓存。6.3 数据库迁移、备份与回滚的线上流程自托管之后发布流程变成了一条固定流水线。每次要发新版本先在本地或CI里跑测试构建镜像然后执行两个关键步骤npx prisma migrate deploy docker compose up -d --build appmigrate 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跑起来先让类型从数据库一路走到按钮上那种能靠编译器兜底的感觉会替你做决定。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑