“t3code”不是一个新技术名词,但它在我手头的一堆全栈项目里,确实是最有代表性、最值得拿出来拆解的那一个。简单说,t3code 是我基于 T3 Stack(Next.js + TypeScript + Tailwind + tRPC + Prisma)从零搭建的一套典型全栈应用骨架,里面包含了用户认证、数据库建模、API 路由、数据校验、部署配置这些日常开发躲不掉的核心环节。如果你正在纠结“Next.js 项目到底怎么组织才不烂尾”“tRPC 和 REST 相比到底图什么”“Prisma 迁移和部署时踩了哪些暗坑”,那这篇文章基本就是照着我踩过的路给你画了一张避坑地图。
稍微交代一下背景:T3 Stack 这个概念最早由前端社区大佬 Theo 带火,核心思路是用一套“类型安全贯穿前后端”的组合拳,解决多语言、多端协作时的接口不一致问题。t3code 这个项目名里的 “code” 强调的是“可复制的代码范式”,不是一个开箱即用的后台管理模板,而是一份能够被团队按需裁剪的参考实现。它适合有一定 React 基础、想往全栈方向走的人,也适合那些已经用 Next.js API Routes 写了一堆接口、开始维护困难却不知道怎么重构的团队。我会从设计思路、核心实现、实操过程、线上隐患四个方面,把从零到上线这条路上的每一个决定性选择都摊开讲清楚。
1. 项目整体设计与技术栈拆解
1.1 为什么是 T3 Stack,而不是“全家桶”或“自由组合”
先说一个很多人问过我的问题:为什么不用 Nest.js 做后端、Vue 做前端,或者干脆用 Next.js 加一堆自己拼的库,而偏偏选择了 T3 Stack 这一套?答案核心在于“类型安全的链路闭环”。在传统前后端分离项目里,前端写fetch('/api/user/list'),后端在 Controller 里定义参数和返回结构,两者之间没有任何强制约束,靠的是接口文档和人的自觉。一旦后端把id从 number 改成 string,前端不会报错,只有在运行时接口挂了你才知道。而 t3code 采用的 tRPC 方案,让 API 调用从“URL 字符串拼接”变成“函数调用”,前端直接 import 后端定义的路由,参数类型和返回类型完全由 TypeScript 编译器接管,改一个字段类型,全项目立刻报错,这种开发体验一旦上手就很难回去。
有人会问:那 GraphQL 不也能做到类型安全吗?能,但 GraphQL 引入的 schema 定义、resolver 拆分、代码生成器配置,对一个快速迭代的团队来说,启动和维护成本都不低。tRPC 的思路更轻——你不需要额外学一套查询语言,它就是普通的 TypeScript 函数,只是通过 RPC 的方式暴露给前端。t3code 里没有使用 REST 也没有用 GraphQL,这不是因为那些不好,而是因为它们对“小团队快速全栈交付”这个诉求来说太重了。再加上 Prisma 负责数据库层的类型安全,Next.js 负责前后端同构,整条链路从浏览器到数据库都是 TypeScript,形成一个几乎没有缝隙的类型闭环,这是我在实际项目里最看重的收益。
再说 Tailwind,很多人觉得它只是“快速写样式”的工具,但 t3code 里我把它当作设计约束的一部分来用。没有散落各处的 CSS 文件,所有间距、颜色、字体大小都有统一的 token,视觉一致性靠配置保证而不是靠设计师手动盯。配合 Next.js 的 App Router 和 React Server Components,Tailwind 在服务端渲染场景下几乎不产生多余的客户端样式开销,这对首屏性能是实打实的帮助。
1.2 项目目录结构与职责边界
t3code 的目录结构第一眼看上去有点“密”,但每一条路径都有它存在的理由。完整结构拆开大概是这样的:
src/ app/ # Next.js App Router 路由层 (auth)/ # 分组路由,登录/注册相关页面 (dashboard)/ # 分组路由,需要鉴权的业务页面 api/ # 特殊场景下的代理接口 layout.tsx # 根布局 page.tsx # 首页 server/ api/ # tRPC 路由定义入口 root.ts trpc.ts db/ # Prisma client 实例 auth.ts # 认证逻辑封装 services/ # 业务逻辑层 trpc/ shared/ # 前后端共享的类型定义 components/ ui/ # 无业务状态的通用组件 business/ # 带业务数据的组合组件 lib/ utils.ts # 通用工具函数 prisma/ schema.prisma # 数据模型 migrations/ # 数据库迁移文件这里的核心原则是把“路由层”“服务层”“数据层”分开,但又不像传统后端那样物理隔离得特别远。t3code 的做法是:页面组件允许直接调用 tRPC 的服务端函数,但复杂业务逻辑 — 比如创建订单后要同时扣库存、发消息、记日志 — 必须下沉到services目录,避免在路由处理器里堆一堆临时逻辑。这种“适度分层”的好处是,团队里不同水平的开发者都能找到一个固定的放代码的位置,不会出现路由层越写越厚、服务层形同虚设的问题。
还有一个很重要的点是trpc/shared这个目录。它里面放的是前后端共享的类型字面量和推断类型,比如UserRole、OrderStatus这样的联合类型。因为这些类型是纯 TypeScript 层面的东西,不涉及运行时代码,所以可以被安全地同时引用到客户端和服务端。这就是 t3code 能在“一劳永逸地保持类型同步”这一点上做出示范的关键结构。
2. 核心功能实现与关键配置
2.1 数据库模型设计与 Prisma Schema 实战
数据库设计是每个项目绕不开的第一步,t3code 里用 Prisma 作为 ORM,但 Prisma 的核心不只是“简化增删改查”,而是它的schema 文件天然就是一份可版本化的数据库文档。我建议你打开 prisma/schema.prisma,里面会看到几个典型的模型,我来拆解其中一个带业务代表性的设计。
假设我们要实现“用户-文章”系统,一个用户有多篇文章,每篇文章有多个标签,还需要记录文章的创建和更新时间。用 Prisma 定义如下:
model User { id String @id @default(cuid()) email String @unique name String? password String posts Post[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Post { id String @id @default(cuid()) title String content String published Boolean @default(false) author User @relation(fields: [authorId], references: [id], onDelete: Cascade) authorId String tags Tag[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([authorId, published]) } model Tag { id String @id @default(cuid()) name String @unique posts Post[] }这里有几个刻意选择的细节值得展开说。
第一,主键用的不是自增Int而是cuid()字符串。为什么?因为自增 ID 在分布式场景和多端离线创建的场景下容易出现冲突,而且会暴露业务数据量(比如用户能猜到 ID 是顺序的)。cuid 虽然不是 UUID4 那么随机,但在业务系统中足够安全,索引性能也在可接受范围之内。如果你的是高并发写入场景,可以考虑改用 UUID、ULID,或者干脆用数据库自带的雪花算法方案。不过 t3code 选择 cuid 的核心理由只有一个:创建实体时不需要等数据库返回自增值,可以在应用层先产生 ID,这对批量创建和关联写入非常友好。
第二,author关系字段上配置了onDelete: Cascade。这意味着删掉一个用户,他名下的所有文章都会被自动删除。这个配置比重建外键方便得多,但实际生产中要小心 — 我曾经在一个项目里没加这个配置,删除用户的时候数据库直接报外键约束错误,这才意识到关联数据的清理策略必须在模型层就定好。反过来,如果文章是用户的经营凭证,你更可能想要onDelete: Restrict加上软删除字段,这个决策粒度很大,不能偷懒。
第三,@@index([authorId, published])这个联合索引非常关键。如果业务上经常要查“某个用户的已发布文章”,没有这个索引,数据库就得对全表做扫描。数据量几百条的时候感受不出来,等到了十万条就会开始出现慢查询。Prisma 里写这个索引只是在 schema 里加一行,但实际作用不亚于你在数据库控制台里手工执行的CREATE INDEX。
2.2 tRPC 路由设计与类型安全请求链路
tRPC 是 t3code 整条链路里技术含量最高的一环,也是大多数初学者最容易搞混的地方。我直接从一个实际场景讲起:前端页面上要展示“当前登录用户发布过的文章列表”,同时支持按标签过滤。
传统 REST 的做法是:设计一个GET /api/posts?authorId=xxx&tag=yyy&page=1,然后写对应的 Controller、Service、DTO,前端还要自己维护一套PostApi类来封装请求。tRPC 里,这一切被简化成了一个服务端函数定义:
// server/api/routers/post.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "../trpc"; export const postRouter = createTRPCRouter({ listByAuthor: protectedProcedure .input( z.object({ tag: z.string().optional(), page: z.number().min(1).default(1), pageSize: z.number().min(1).max(50).default(10), }) ) .query(async ({ input, ctx }) => { const where = { authorId: ctx.session.user.id, ...(input.tag ? { tags: { some: { name: input.tag } } } : {}), }; const [items, total] = await Promise.all([ ctx.db.post.findMany({ where, include: { tags: true }, skip: (input.page - 1) * input.pageSize, take: input.pageSize, orderBy: { createdAt: "desc" }, }), ctx.db.post.count({ where }), ]); return { items, total }; }), });这段代码里最值得学习的不是查询本身,而是几个环环相扣的类型安全设计:
zod负责输入校验。前端如果传了一个page: 0,在进入数据库查询之前就会被拦下来并返回前端校验失败信息。你完全不需要在业务代码里手写if (page < 1) throw new Error(...)。protectedProcedure表示这个接口必须在登录状态下才能访问,ctx.session的类型由认证中间件注入,前端调用方拿到的ctx类型是编译期确定的,不存在“运行时才知道 session 里有啥”的问题。- 返回的
{ items, total }会被 TypeScript 自动推断,前端拿到posts时,它的每一项都包含tags数组,不需要额外声明类型。
前端调用这段代码,直接写:
// components/business/PostList.tsx "use client"; import { api } from "~/trpc/react"; export function PostList({ currentTag }: { currentTag?: string }) { const { data, isLoading, refetch } = api.post.listByAuthor.useQuery({ tag: currentTag, page: 1, }); if (isLoading) return <p>加载中...</p>; return ( <ul> {data?.items.map((post) => ( <li key={post.id}> <span>{post.title}</span> <span>{post.tags.map((t) => t.name).join(", ")}</span> </li> ))} </ul> ); }这里的api.post.listByAuthor不是手写的网络请求函数,而是由 tRPC 基于服务端路由自动生成的调用器 — 它的参数类型、返回类型和服务端代码严格一致。也就是说,当服务端把Post模型的content字段重命名成body时,前端这段代码会在编译期直接标红。这种“改后端导致前端立刻报错”的特性,实际上把一整个类别的接口不一致 bug 从运行时提前消灭掉了。
我在 t3code 项目里没有把所有操作都堆在少数几个路由里,而是按业务模块拆成authRouter、userRouter、postRouter、tagRouter,然后在root.ts里合并成一个 appRouter。这个拆分逻辑和目录拆分一致:不是“路由文件越多越好”,而是“一个业务域一个入口,彼此不交叉”。
2.3 认证与会话处理细节
认证方案我选择了 NextAuth.js(现在叫 Auth.js),它和 Next.js App Router 集成得很好,t3code 里通过server/auth.ts封装了统一的 Session 获取逻辑。用 NextAuth 而不是自研 JWT 方案的理由是:它内置了 Google、GitHub 等 OAuth 提供商适配器,也支持 Credentials 邮箱密码登录,配合 Prisma Adapter 可以直接把 User / Session / Account 三张表同步到数据库,省掉大量重复实现。这部分你要是第一次接触,可能会被[...nextauth]这个写法绕晕。我解释一下:这是 Next.js 的 catch-all 路由,意味着它同时处理/api/auth/signin、/api/auth/signout、/api/auth/session这几个子路径的请求。
t3code 里的会话策略是 Session-based(数据库存 Session),而不是纯 JWT。原因是 tRPC 需要从数据库的session表拉取最新用户信息 — 比如用户被禁用后,我们希望他的现有会话立刻失效,这是纯 JWT 做不到的。JWT 的优点是无需查库、适合无状态服务,但缺点是你无法在服务端主动吊销,除非自己维护黑名单。对于中小型全栈应用,数据库 Session 是完全够用的方案,而且 Prisma Adapter 会自动处理 session 生命周期。
有一个非常容易踩的坑,我必须提醒一下:把NEXTAUTH_SECRET写进.env时,一定不要用同一个密钥部署多个环境。更不要把它提交到 git 仓库。我见过不止一个项目因为把.env文件不加进.gitignore,结果 GitHub 上全是数据库连接串和密钥。t3code 的做法是在.env.example里放占位符,真正的.env只在本地存在,服务器上的环境变量从 CI/CD 的 Secret 管理中注入。
3. 从零到一的实操过程记录
3.1 初始化项目与依赖安装
如果照着 t3code 的标准重建一个项目,最快捷的方式是用社区维护的 create-t3-app 脚手架。执行下面的命令:
npx create-t3-app@latest my-app交互式界面会让你选择要用哪些模块:Next.js、Tailwind、tRPC、Prisma、NextAuth。根据 t3code 的实验结论,我建议除了这些核心模块外,把 ESLint 和 Prettier 也带上,代码格式统一能从根源上少掉一半的 merge conflict。选完之后脚手架会自动生成一套带基础结构的代码,之后的改动都是针对业务本身了。
如果你希望完全手动搭一遍,核心依赖大约是这些:
npm install next react react-dom npm install trpc @trpc/server @trpc/client @trpc/react-query @tanstack/react-query npm install prisma @prisma/client zod superjson npm install next-auth @auth/prisma-adapter npm install -D typescript tailwindcss @tailwindcss/postcss eslint prettier手动搭建时有一个 t3code 里特别强调过的细节:tRPC 一定要配置 superjson 作为 transformer。因为 tRPC 默认的 JSON 序列化处理不了Date对象、Map、Set这些复杂类型,如果没有 superjson,你从数据库查出来的createdAt传到了前端就变成字符串而不是Date实例,很多依赖时间计算的方法会直接出 bug。在server/api/trpc.ts里你会看到:
import { initTRPC, TRPCError } from "@trpc/server"; import superjson from "superjson"; import type { createContext } from "./context"; const t = initTRPC.context().create({ transformer: superjson, });这一行不显眼,但少了它项目会以各种诡异方式出问题。
3.2 环境变量与数据库连接配置
数据库是 Prisma,默认支持 PostgreSQL、MySQL、SQLite、SQL Server 等。t3code 生产环境用 PostgreSQL,本地开发时为了省事,最初可以用 SQLite 起步。你要是用 create-t3-app 脚手架,它会问你数据库选哪个,这里我的真实建议是:如果你身边没有现成的 PostgreSQL 实例,果断先选 SQLite,代码写完了再切换到 PostgreSQL,代价不大。别在开局阶段就卡在环境配置上,那是消磨热情的元凶。
如果是 PostgreSQL,.env里这样配:
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/t3code?schema=public"然后执行:
npx prisma migrate dev --name init这条命令做了三件事:根据schema.prisma生成 SQL 迁移文件、执行迁移到数据库、生成新的 Prisma Client。它会同步把node_modules/.prisma/client更新掉,所以每次改完 schema 都要重新跑一遍,否则代码里新建的 model 会在你写代码时提示“类型不存在”,实际上只是客户端没有重新生成。
在 t3code 里我没有把 Prisma 实例到处new,而是做了一个单例导出:
// 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;这个模式不是炫技,而是为了避免开发环境热更新时创建大量数据库连接导致连接数耗尽。Next.js 在开发模式下每次代码保存都会热重载模块,只要PrismaClient被重新实例化,就会建立新的连接。单例缓存能确保整个开发期间只有一个 Prisma 实例活着。
3.3 实现第一个完整 CRUD 流程
从创建一个“新建文章”的功能说起。要实验 t3code 的链路,这个流程是黄金路径。
第一步,在服务端路由里加一个 create 方法:
// server/api/routers/post.ts create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().min(1), tags: z.array(z.string()).optional(), }) ) .mutation(async ({ input, ctx }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, tags: input.tags ? { connectOrCreate: input.tags.map((name) => ({ where: { name }, create: { name }, })), } : undefined, }, include: { tags: true }, }); }),connectOrCreate表示“如果能找到同名标签就关联,找不到就新建标签”,这比先查后插高效,而且是原子性的,不会出现并发下重复创建同一个标签的问题。这个 API 设计妥帖使用性极强,我之后写所有字典表类型的多对多关系都是用这个方法。
第二步,前端页面调用 mutation:
"use client"; import { useRouter } from "next/navigation"; import { api } from "~/trpc/react"; export function NewPostForm() { const router = useRouter(); const utils = api.useUtils(); const createPost = api.post.create.useMutation({ onSuccess: () => { // 清空表单并刷新列表 utils.post.listByAuthor.invalidate(); router.refresh(); }, }); // 省略表单状态管理 return ( <form onSubmit={(e) => { e.preventDefault(); const formData = new FormData(e.currentTarget); createPost.mutate({ title: String(formData.get("title")), content: String(formData.get("content")), tags: ["t3code", "prisma"], }); }} > <input name="title" required /> <textarea name="content" required /> <button type="submit">发布</button> </form> ); }invalidate()是 tRPC + React Query 里特别聪明的一个设计:它不强制你手动重新请求,而是把缓存标记为失效,数据会按照 React Query 的策略自动重新拉取。这避免了“发完请求后手动在 useEffect 里强制刷新”的笨重写法。
第三步,页面路由挂在鉴权保护下。在 Next.js App Router 里,可以用服务端布局或中间件来保护整组页面。t3code 用的方式是在(dashboard)分组布局里直接检测 Session:
// app/(dashboard)/layout.tsx import { redirect } from "next/navigation"; import { auth } from "~/server/auth"; export default async function DashboardLayout({ children, }: { children: React.ReactNode; }) { const session = await auth(); if (!session) redirect("/login"); return <div className="p-4">{children}</div>; }这个方式是服务端校验,比纯客户端useSession更可靠,不会出现“闪烁几秒后突然跳登录页”的体验问题。tRPC 层的protectedProcedure是第二层防线,就算是某个忘了包在这个布局里的路由,依然无法调用需要登录的 API。
4. 部署、性能优化与常见问题排查
4.1 生产环境部署方案对比
t3code 这个级别的全栈应用,部署选项很多,但我实际用过之后,给你一个清晰的对比:
| 方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| Vercel 一键部署 | 与 Next.js 集成最完美,支持 Serverless 函数,部署零配置 | 长连接任务和定时任务受限,Serverless 冷启动有一定延迟 | 中小规模生产、演示项目、快速上线 |
| Node.js VPS + PM2 | 环境可控,进程持久,没有冷启动 | 需要自己配置 SSL、日志、守护进程,维护成本高 | 对国内访问有要求、需要常驻进程的团队 |
| Docker + 云服务器 | 环境一致性最好,回滚容易 | 构建镜像和编排有一定学习门槛 | 容器化改造后的团队标准部署 |
拿 t3code 来说,如果是演示项目我直接推荐 Vercel;如果业务有地域合规要求或者要跑长时间后台任务,建议 VPS + PM2。有一个特别隐蔽的问题是 Next.js 的 standalone output。如果你用 Docker 部署,需要在next.config.js里设置output: "standalone",这样构建产物会极小可以独立跑,镜像体积能少掉一半以上。很多第一次接触的人没配这个,被打包进镜像的node_modules和.next搞得镜像巨大,部署速度惨不忍睹。
数据库如果是 PostgreSQL,建议生产环境用托管服务而不是自己装,原因很实际:自动备份、监控告警、磁盘扩容这些事,自己维护的成本远高于那点订阅费。t3code 项目里我主力使用了托管 PostgreSQL,并且开启 SSL 连接,.env的连接串要带上sslmode=require。
4.2 实测中遇到的典型问题与排查思路
从 t3code 开发到部署落地的过程中,我遇到了一些高频问题,有些问题你在文档上很难搜到确切答案,这里直接给你排查路径。
第一个问题是tRPC 请求报 404 或 500,现象是本地开发一切正常,部署到服务器后前端任何useQuery都失败。排查过程大概是:先看服务端日志里有没有完整堆栈,如果报的是Unable to determine the superclass之类的超类和继承相关错误,通常是 superjson 配置没有生效 — 检查transformer是不是在服务端、客户端都配置了同一种。另一个常见原因是 tRPC 的 endpoint 路径写错,Next.js App Router 下 tRPC 的默认挂载路径是/api/trpc,你在app/api/trpc/[trpc]/route.ts里注册路由,前面请求用到的api.post.listByAuthor最终会发到这个路径。如果你在 Nginx 反代层做过路径重写,务必确认没有把trpc前缀吞掉。
第二个是Prisma 迁移失败,通常出现在prisma migrate deploy阶段。这类问题五花八门,但有迹可循:迁移文件冲突(多人同时改 schema)、数据库字符集和索引长度冲突(MySQL 低版本下长字符串索引会超限)、外键字段类型不一致。我自己的处理原则是:开发环境随便试,生产环境迁移前一定先prisma migrate diff从本地生成 SQL 检查一遍,再放到生产执行。永远不要在服务器上直接prisma db push,那只适合原型阶段。
第三个是NextAuth Session 失效过快,表现为用户操作到一半被弹回登录页。排查方向:NEXTAUTH_SECRET在多环境不一致(该换密钥时没有换),或者令牌过期策略设置太短。NextAuth 的 Session 默认策略下,数据库 Session 记录会在过期后自动清理,如果用户访问频率不高,很容易“被登出”。你可以调大 Session 的maxAge,或者干脆使用滚动过期。这部分要根据业务敏感度权衡,别为了省事无脑调很大。
4.3 开发体验与性能优化建议
最后聊几个我在 t3code 里沉淀下来的体验优化点,每一个都是实打实提升了日常开发效率的东西。
建议一:ESLint 规则里开启@typescript-eslint/consistent-type-imports。它强制你使用import type而不是import去引用纯类型。效果是构建时可以减少一部分编译产物体积,代码里谁是运行时谁是编译期一目了然。这个规则对于 tRPC 项目特别重要,因为我们会大量 import 一些仅存在于类型层面的类型,它们其实在运行时不存在,写混了可能导致前端报undefined错误。
建议二:数据库查询返回结构与前端页面数据的映射,尽量在服务端完成,别让前端做二次加工。t3code 的原则是把格式化逻辑放在 tRPC 的 query 函数里,比如把tags数组转化成逗号分隔的字符串,前端只负责展示。这样前端代码变得很薄,也更接近声明式 UI 的哲学。
建议三:开启 React Query 的 Devtools,在开发环境你会多一个可视化面板。它可以直观看到每个查询的状态、缓存时间和失效时间。很多 tRPC 的结果“为什么不刷新”“为什么重复请求”这类问题,看一眼 Devtools 基本就明白了,比在代码里 console.log 追原因要快得多。
建议四:组件中只调用useQuery,把 mutation 的副作用统一收敛到表单提交处理器里。不要在onClick里连锁调用多个 mutation,那会让网络请求顺序难以追踪,也很难做乐观更新。t3code 的做法是每个“动作”对应一个 mutation,并把onSuccess里的刷新逻辑集中在同一处写,保证可以一行代码定位。
建议五:给关键查询加适当的staleTime。React Query 默认的staleTime是 0,意味着每次组件挂载都可能重新请求。对于像“标签列表”这种基本不变的数据,把staleTime设为 60 秒能减少很多没必要的后端压力。你不用全局统一设一个值,变与不变的数据应该有明显区别。
如果你照着 t3code 的路径一路走下来,会发现这套技术组合真正打动人的地方不是某一个库多强大,而是它们组合之后对“开发流”的重塑。你会发现前后端交接的废话变少了、为类型问题扯皮的会议变少了、联调环境里因为字段对不上打回的测试单也变少了。类型安全不是一种装饰,它是把工程里最容易被忽略的沟通成本,压缩到了编译器的检查范围之内。我个人的实际体会是,第一次跑通 t3code 的全链路的那个下午,带来的愉悦感远超任何一次调通 REST API 的瞬间 — 因为你知道,这一步一旦落地,整个团队后续的迭代速度都会是另一个量级的。如果你正准备开启自己的全栈项目,或正在为频繁的接口变更焦头烂额,不妨从 t3code 这套设计里借走几个思路,它们远比你想象得更能救命。