ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

t3code:基于T3技术栈的端到端类型安全全栈开发实战

t3code:基于T3技术栈的端到端类型安全全栈开发实战

做前后端分离项目久了,你会发现最消耗团队耐心的往往不是业务逻辑本身,而是接口两侧的"对齐"问题。字段名改一下,文档没同步,前端报错一片,联调群里互相@;后端说"我改了你看一下",前端回"你哪个接口改了也不说一声"。次数多了,我开始认真思考一个问题:前后端之间的类型契约,能不能不再靠人肉维护。后来我发起了 t3code 这个项目,用 T3 技术栈(TypeScript、Tailwind CSS、tRPC)从零搭了一套全栈应用基线,核心目标就是让"前端调接口像调本地函数一样有类型提示"这件事从设想变成可以每天用的现实。这篇文章会把项目的完整思路、落地步骤和实战中踩过的坑都摊开讲,适合正在了解端到端类型安全的全栈开发者,也适合团队里想引入 tRPC 但还在犹豫的同学做参考。

1. t3code的来龙去脉:这不是又一个脚手架模板

1.1 项目要解决的问题:API契约漂移

先说说我为什么非要折腾这么一套东西。过去几年我经手过好几个典型的前后端分离项目,流程都差不多:后端写接口,在 Swagger 里维护文档,前端照着文档手写 interface,联调时再对着 Postman 抓字段。这个流程最大的问题不是谁偷懒,而是任何一环靠人肉同步,迟早都会出错。

举一个我印象很深的例子。某个管理后台的订单接口,后端把status字段从枚举改成了字符串描述,自认为"反正改了调用方也能处理"。但前端至少有三个页面用了这个字段做条件渲染,其中一个是status === 'PAID',改完之后页面直接崩了。排查了三小时,最后发现就是接口文档没更新,前端根本不知道这个约定变了。这种问题严格来说不怪某个同事,而是流程里缺少一个强制性的类型约束。

t3code 就是在这个背景下做的。我不打算再做一个通用脚手架模板,市面上create-next-app之类的工具已经够多了。我想验证的是:能不能把后端的类型定义作为唯一事实来源,让前端在编译期就发现"你这里类型对不上",而不是等到联调甚至上线后才发现。

1.2 T3 这三个字母到底指什么

t3code 里的 T3,对应的是 TypeScript、Tailwind CSS、tRPC 三样东西,另外把 Next.js 当作宿主框架来跑整个应用。这个组合不是随便拼的:

  • TypeScript是地基,没有它后面都不成立;
  • Tailwind CSS负责样式,原子化类名让前端写 UI 快很多;
  • tRPC是核心,它把前后端的类型安全贯穿到整个调用链上。

如果你第一次听到 tRPC,可以把它理解成一个"函数调用级别的 API 方案":后端定义好一个 router,前端直接调用里面的 procedure,参数和返回值的类型完全自动推断。对我这种长期写 REST 接口的人来说,第一次跑通这个流程时的感受就是——以前是隔着围墙扔纸条,现在是直接坐下来开会。

1.3 整体目录结构与模块边界

t3code 的目录结构我按"能力分层"来组织,而不是按页面来组织。核心区分是src/server和src/trpc两条线,前者是后端逻辑,后者是前端调用入口,二者通过共享类型连接。

t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ │ │ ├── api/trpc/[trpc]/route.ts │ │ └── (main)/tasks/page.tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── trpc.ts │ │ │ └── root.ts │ │ ├── db.ts │ │ └── modules/ │ │ ├── task/ │ │ │ ├── task.router.ts │ │ │ └── task.service.ts │ │ └── user/ │ │ └── user.router.ts │ ├── trpc/ │ │ ├── client.ts │ │ └── provider.tsx │ └── styles/globals.css └── package.json

这个结构有几个好处:模块边界清晰,task相关的 router 和 service 放一起,后续做单元测试也容易定位;前后端共享的类型都从src/server/api这一层流出去,不会出现"同一个类型在多个地方各写一份"的尴尬。

1.4 项目范围:聚焦四个典型场景

为了避免项目失控,我把 t3code 的范围压缩得很小:登录鉴权与会话、任务 CRUD、按状态做分组统计、以及一套服务端渲染场景的调用示例。这几个场景覆盖了全栈开发里最常见的痛点,包括权限控制、数据变更后的缓存刷新、服务端组件的数据获取方式等等。

所以这篇文章不是"从零教你写一个完整业务系统"的教程,而是把 t3code 做为一套"带类型保障的全栈开发基线"来分享。你看完可以直接把核心思路搬到自己的项目里,不一定非要照搬代码。

2. 为什么放弃手写接口对接:端到端类型安全的思考过程

2.1 传统前后端对接方式的日常差异

很多团队对"类型不同步"的容忍度非常高,原因是"反正能跑"。但"能跑"和"跑得对"是两回事。我用一张表对比一下传统 REST 对接和 tRPC 对接的日常差异:

环节传统 REST + 手写类型tRPC 方式
新增一个字段后端改 DTO、改文档;前端改 interface、改 mock后端改 zod schema 和数据库,前端类型自动跟随
类型错误发现时机联调期、运行期、甚至线上编译期、编辑器即时提示
接口调用方式手工拼接 URL、处理 query 参数直接调用函数式 API,参数自动提示
维护文档需要额外维护,容易过期类型定义本身就是活文档

我见过太多团队把大量精力花在"让文档不过期"这件事上,但文档的本质是事后记录,类型推断才是事前约束。两者解决的问题层次完全不同。

2.2 tRPC 的工作原理:Router、Procedure、Context

tRPC 的核心概念不多,理解这三个就够用:

  • procedure:一个可以被前端调用的后端函数,分为query(查)和mutation(写);
  • router:一组 procedure 的集合,类似于 REST 里的"资源控制器";
  • context:每次请求进来时构建的上下文对象,通常放鉴权信息、数据库实例等。

整个流程说起来很简单:后端用initTRPC创建实例,定义router和procedure;前端用createTRPCReact创建客户端,然后通过 hooks 调用。关键在于 tRPC 的 TypeScript 类型推断能力,它会把后端 router 的类型直接传给前端客户端,前端拿到的不再是"某个接口地址",而是一个完整的、带参数和返回值类型的函数表。

生活化类比:以前 REST 接口像两个国家之间通过海关报关单交流,两边各自维护一套申报格式,格式不一致就得来回改单;tRPC 更像是同一个公司内部直接打电话,两边用的是同一本通讯录、同一个语言系统,不存在"单子对不上"的问题。

2.3 对比 GraphQL 和 OpenAPI:为什么选 tRPC

不少同学会问:GraphQL 不也能解决类型同步问题吗?OpenAPI 配合代码生成也可以啊?我的答案是:都能,但要比较的是综合成本。

对比项REST + OpenAPIGraphQLtRPC
类型同步方式依赖代码生成工具,需要额外构建步骤依赖 Schema + codegen,需要维护 resolver 与 schema 的一致天然同步,零生成步骤
学习成本低高(GraphQL Schema 语言、resolver、缓存策略、N+1 处理)中低,会写 TypeScript 基本就能上手
多客户端支持好好差,只适合 TypeScript/JavaScript 生态
调试体验通用工具多需要专门工具浏览器 Network 面板直接看请求,调试轻量

GraphQL 的方案本身很强,但放在 t3code 这种"全栈 TypeScript、前后端同仓"的项目里,引入它就等于多了一套 Schema 语言和解析层,而 tRPC 直接把这些省掉了。OpenAPI 适合对外发布 API 的场景,但内部全栈项目里,你往往需要的是"快速建一个带类型保证的后端接口",而不是"维护一套公开契约文档"。

2.4 这套取舍什么时候不成立

务必清醒一点:tRPC 不是万能的。如果你是以下情况,别用 tRPC,真的别用:

  • 需要给 iOS、Android、小程序等非 TypeScript 客户端提供 API;
  • 需要将 API 开放给第三方开发者,形成公开契约;
  • 前后端团队严格分离、仓库也分离,后端不能向前端暴露内部函数结构;
  • 需要长期稳定的 API 版本管理,REST + OpenAPI 反而更合适。

tRPC 的定位是"同构全栈项目里的高效通道",它是给你省事的,不是给你制造契约负担的。理解这一点,后面选型才不会跑偏。

3. 从空目录到跑通全流程:t3code 项目的核心落地步骤

3.1 初始化与依赖安装

我建议在空目录里从 Next.js 起步,免得被模板里预置的东西干扰。命令如下:

pnpm create next-app@latest t3code --typescript --tailwind --app --src-dir cd t3code pnpm add @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query zod superjson pnpm add -D prisma @types/node tsx

说明几点:superjson用来序列化复杂类型(比如 Date、Map),tRPC 默认的 transformer 就是它,不装的话Date类型传输会丢;zod是 tRPC 官方推荐的 input 校验工具,后面会一起提。数据库我这里先用 SQLite 起步,方便本地跑,正式部署再换 PostgreSQL 也不影响结构。

3.2 数据层先行:先写好 Prisma Schema

t3code 里我先定义User和Task两张表,关系很简单:一个用户有多条任务。这个先行的好处是,后端的 router 代码能直接引用 Prisma 生成的类型,不会出现"接口写完了才发现字段名对不上"的情况。

// prisma/schema.prisma generator client { provider = "prisma-client-js" } datasource db { provider = "sqlite" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) email String @unique passwordHash String name String? tasks Task[] } model Task { id String @id @default(cuid()) title String done Boolean @default(false) priority Int @default(1) userId String user User @relation(fields: [userId], references: [id]) createdAt DateTime @default(now()) }

然后执行:

pnpm prisma migrate dev --name init

这一步会生成数据库文件和 Prisma Client。注意.env里必须配好DATABASE_URL="file:./dev.db",这是新手最容易漏的。

3.3 搭出 tRPC 服务端:trpc.ts 与 router

接下来是 t3code 的核心:src/server/api/trpc.ts。这里面定义了三样东西——tRPC 实例、上下文类型、以及后续鉴权会用到的protectedProcedure。

import { initTRPC, TRPCError } from '@trpc/server'; import { ZodError } from 'zod'; import { prisma } from '../db'; import superjson from 'superjson'; export const createTRPCContext = async (opts: { req: Request }) => { // 这里可以从请求头解析 session,先留空 return { req: opts.req, db: prisma, userId: null as string | null, }; }; const t = initTRPC.context<Awaited<ReturnType<typeof createTRPCContext>>>().create({ transformer: superjson, errorFormatter({ shape, error }) { // 把 zod 的校验错误统一整理成友好信息 return { ...shape, data: { ...shape.data, validationError: error.cause instanceof ZodError ? error.cause.flatten() : null, }, }; }, }); export const router = t.router; export const publicProcedure = t.procedure; // 后续鉴权中间件加进来后,protectedProcedure 就代表"必须登录" export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => { if (!ctx.userId) { throw new TRPCError({ code: 'UNAUTHORIZED' }); } return next({ ctx: { ...ctx, userId: ctx.userId } }); });

再写src/server/api/root.ts把业务模块聚合起来。tRPC 支持多个 router 合并,这个设计在业务变大之后非常管用:

import { router } from './trpc'; import { taskRouter } from '../modules/task/task.router'; import { userRouter } from '../modules/user/user.router'; export const appRouter = router({ task: taskRouter, user: userRouter, }); export type AppRouter = typeof appRouter;

Task模块的 router 是这样:

import { z } from 'zod'; import { router, protectedProcedure } from '../../api/trpc'; const taskRouter = router({ list: protectedProcedure .input(z.object({ done: z.boolean().optional() })) .query(async ({ ctx }) => { const where = { userId: ctx.userId, ...(done === undefined ? {} : { done }) }; return ctx.db.task.findMany({ where, orderBy: { createdAt: 'desc' } }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1), priority: z.number().int().min(1).max(5).default(1) })) .mutation(async ({ ctx, input }) => { return ctx.db.task.create({ data: { ...input, userId: ctx.userId } }); }), toggle: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const task = await ctx.db.task.findFirst({ where: { id: input.id, userId: ctx.userId } }); if (!task) throw new TRPCError({ code: 'NOT_FOUND' }); return ctx.db.task.update({ where: { id: input.id }, data: { done: !task.done } }); }), });

这里的要点是input 的 zod 校验同时承担了类型推断职责。比如create传进来的参数,前端拿到手之后就知道必须要有title,priority会有默认值1,乱传根本编译不过去。

3.4 把 tRPC 挂到 Next.js 上

Next.js App Router 下,只需要一个 route handler 转发请求:

// src/app/api/trpc/[trpc]/route.ts import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; import { appRouter } from '@/server/api/root'; import { createTRPCContext } from '@/server/api/trpc'; const handler = (req: Request) => fetchRequestHandler({ endpoint: '/api/trpc', req, router: appRouter, createContext: () => createTRPCContext({ req }), }); export { handler as GET, handler as POST };

看上去只是薄薄一层,但它是整个前端调用链的入口。所有请求都走/api/trpc,具体调用哪个 procedure 由请求体里的路径决定。

3.5 前端接入:client、Provider 与 hooks

先建两个文件。src/trpc/client.ts创建 React hooks 客户端,src/trpc/provider.tsx注入 QueryClient 和 tRPC Provider:

import { createTRPCReact } from '@trpc/react-query'; import type { AppRouter } from '@/server/api/root'; export const api = createTRPCReact<AppRouter>();
'use client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { httpBatchLink } from '@trpc/client'; import { api } from './client'; import superjson from 'superjson'; const queryClient = new QueryClient(); export function TRPCProvider({ children }: { children: React.ReactNode }) { const trpcClient = api.createClient({ links: [ httpBatchLink({ url: '/api/trpc', transformer: superjson, }), ], }); return ( <api.Provider client={trpcClient} queryClient={queryClient}> <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> </api.Provider> ); }

页面里的调用非常直接:

'use client'; import { api } from '@/trpc/client'; export default function TaskList() { const { data: tasks, isLoading } = api.task.list.useQuery({ done: false }); const toggleTask = api.task.toggle.useMutation({ onSuccess: () => api.useUtils().task.list.invalidate(), }); if (isLoading) return <p>加载中...</p>; return ( <ul> {tasks?.map((task) => ( <li key={task.id}> <input type="checkbox" checked={task.done} onChange={() => toggleTask.mutate({ id: task.id })} /> {task.title} </li> ))} </ul> ); }

注意看,api.useUtils().task.list.invalidate()会在 mutation 成功后自动刷新列表,不需要手动 refetch。这就是 tRPC + react-query 组合最常见的正确姿势,后面在坑里我还会强调一次。

3.6 用 Tailwind 把页面撑起来

Tailwind 在这套体系里承担的是"快速出样式"的任务,不需要单独说太多,但有一个经验值得提:把 tRPC 的类型推断和 Tailwind 的类名结合,页面代码量能压到很低。比如任务卡片我只需要一个映射:

<div className="flex items-center justify-between rounded-lg border border-gray-200 p-4"> <span className={task.done ? 'line-through text-gray-400' : ''}>{task.title}</span> <span className="text-sm text-gray-500">优先级 {task.priority}</span> </div>

这样一来,整个 t3code 的核心链路就跑通了:数据库 -> Prisma -> tRPC router -> 前端 hooks -> Tailwind UI,每个环节都有类型兜底。

4. 类型安全之外的暗礁:服务端渲染、缓存与错误处理的那些细节

4.1 服务端组件里怎么调 tRPC

如果你只在客户端组件里用useQuery,t3code 还没真正触到全栈的难点。Next.js 的 App Router 里,很多页面是服务端组件(RSC),在 RSC 里不能直接用 hooks,那怎么拿数据?答案是用createCallerFactory在后端直接创建调用器:

import { createCallerFactory } from '@trpc/server'; import { appRouter } from '@/server/api/root'; import { createTRPCContext } from '@/server/api/trpc'; const createCaller = createCallerFactory(appRouter); export async function getServerSideTasks() { const ctx = await createTRPCContext({ req: new Request('http://internal') }); const caller = createCaller(ctx); return caller.task.list({ done: false }); }

这里有个非常关键的细节:服务端调用时 context 必须手动构建,不能从"当前浏览器请求"里拿。因为在 RSC 阶段你拿到的不是某个具体用户的请求头,session 解析逻辑得单独处理好,否则会出现"服务端渲染时拿不到登录态"这种诡异问题。我建议的做法是:把 userId 解析函数抽成独立模块,客户端和服务端 context 都复用它。

4.2 数据缓存:staleTime 与 invalidate 的正确姿势

react-query 的缓存机制是双刃剑。刚开始用的时候,很多人会抱怨"改了数据页面不刷新",其实问题不在缓存,而在你没有正确使用失效机制。

  • 查询用staleTime控制数据多久之后算过期,适合高频只读列表;
  • 变更用onSuccess里调invalidate,让相关查询重新拉取。

拿任务列表举例,如果你给list.useQuery设置了staleTime: 0,那就等于放弃了缓存;正确做法是保留默认缓存,等 mutation 成功后再把对应 key 失效掉。mutation 的目标不是"每次都拉新数据",而是"数据变了之后才拉新数据"。这个心智模型一定要转过来。

4.3 错误处理模型:不要只 catch 一个笼统的 error

tRPC 的错误是结构化的,TRPCError会暴露code,zod 校验失败还会带validationError字段。我在 t3code 里统一做了一个前端错误呈现组件,把错误分成三类:

错误场景TRPCError code前端处理建议
未登录UNAUTHORIZED跳转登录页
参数错误BAD_REQUEST表单局部标红
数据不存在NOT_FOUND提示"内容已删除"

在 errorFormatter 里把 zod 错误扁平化输出之后,前端能直接拿到字段级别的错误信息,不需要在页面里再写一堆"看 status code 猜原因"的逻辑。

4.4 鉴权中间件的实现细节

前面留了个protectedProcedure的位子,这里补上完整的鉴权流程。实际项目中,登录成功后把 session 存在 cookie 里,每次请求时从 cookie 解析出 userId,注入 context:

export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => { // ctx 里已经有从 cookie 解析出来的 userId if (!ctx.userId) { throw new TRPCError({ code: 'UNAUTHORIZED' }); } return next({ ctx: { ...ctx, userId: ctx.userId } }); });

业务 router 里,凡是需要登录的接口一律用protectedProcedure而不是publicProcedure。这样"谁可以调这个接口"的约束就直接写在路由定义层,前端通过类型也能感知到接口是否需要鉴权,不用靠猜。

4.5 性能边界:关注 N+1 与分页方式

类型安全解决的是正确性问题,但性能问题照样会来。t3code 里我有几条硬性规则:

  • 尽量避免在 query 里串行调用多次数据库查询,用 Prisma 的include或select一次性取数;
  • 列表接口预留分页参数,即使初期数据量小,也不要用裸的findMany;
  • 游标分页优先于 offset 分页,因为 offset 在深翻页时性能会明显下滑。

实现一个简单的 cursor 分页不复杂:

list: protectedProcedure .input(z.object({ cursor: z.string().optional(), take: z.number().default(10) })) .query(async ({ ctx, input }) => { const tasks = await ctx.db.task.findMany({ where: { userId: ctx.userId }, take: input.take + 1, ...(input.cursor ? { cursor: { id: input.cursor } } : {}), orderBy: { id: 'asc' }, }); const hasMore = tasks.length > input.take; const items = hasMore ? tasks.slice(0, -1) : tasks; return { items, nextCursor: hasMore ? items[items.length - 1]?.id : null }; });

这些看起来跟 tRPC 没关系,但在 t3code 这种"全栈以类型为轴心"的项目里,性能方案的选型同样要写在代码里,作为团队的默认约定。

5. 实测中的四个坑:从现象到根因的完整排查

5.1 坑一:Edge Runtime 下 Prisma 调用直接报错

现象:把 t3code 部署到测试环境后,任务列表接口一直返回 500,服务端日志显示 Prisma 报错,关键词是Connection is not established之类。

排查过程:我先在本地复现,本地完全正常,说明不是代码逻辑问题。随后看部署日志,发现 route handler 被运行在 Edge Runtime 里。Edge 环境不支持传统的数据库 TCP 长连接,Prisma Client 在这里根本没法正常工作。

根因:Next.js 部分平台默认开启 Edge Runtime,而我的 route handler 没有显式指定 runtime。

修复:在src/app/api/trpc/[trpc]/route.ts顶部加一行:

export const runtime = 'nodejs';

加完之后重新部署,接口恢复正常。这个坑属于"平台默认值和数据库生态不匹配"的典型问题,排查思路就一条:从本地到部署环境逐层对比差异,先确认自己的代码没问题,再去怀疑环境配置。

5.2 坑二:mutation 成功后列表不刷新

现象:创建任务接口调用成功,数据库里能看到新记录,但页面上列表没有任何变化。

排查过程:一开始我以为是要重新挂载组件,刷新页面后发现数据其实已经进去了,说明问题出在客户端缓存。再看代码,发现我的 mutation 里只写了接口调用,没有做任何缓存失效操作。

根因:react-query 默认会缓存已请求的数据,mutation 不会自动触发相关查询刷新。这不是 bug,是设计机制。

修复:

const utils = api.useUtils(); const createTask = api.task.create.useMutation({ onSuccess: () => { utils.task.list.invalidate(); // 让列表重新请求 }, });

这个坑几乎每个人都会踩一次。记住一句话:任何修改数据的 mutation 成功之后,都要显式 invalidate 相关查询,养成这个条件反射,能省很多时间。

5.3 坑三:zod 可选字段在更新接口里的"幽灵报错"

现象:写任务更新接口时,前端只传一个id和一个title,其他字段全部不传,结果后端返回校验错误。

排查过程:把请求体打出来看,priority字段是undefined。zod 的默认行为是"字段不存在或不满足类型就报错",我 schema 里写的是z.number(),但前端在编辑场景下不一定会传这个字段,于是触发校验失败。

根因:可选字段的定义不严谨。z.number()不接受undefined,必须明确写成.optional(),同时在 Prisma update 里处理好"没传就不更新"的逻辑。

修复:

input: z.object({ id: z.string(), title: z.string().min(1).optional(), priority: z.number().int().min(1).max(5).optional(), })

然后在 service 层把传入的字段做一层过滤,不要让undefined直接进 Prisma update 语句。这个坑提醒我:zod schema 要站在调用方的视角来设计,而不是站在数据库字段的视角。

5.4 坑四:照着 tRPC v10 老教程写,API 形态对不上

现象:网上很多 tRPC 教程还是 v10 的写法,比如initTRPC.create()之后直接t.createRouter(...)、t.procedure.query(...)。照着写,控制台报createRouter不存在。

排查过程:查 package.json 发现装的是 tRPC v11。v11 把初始化 API 改成了initTRPC.context<Context>().create(),router 的创建方式也从createRouter变成了t.router。

根因:版本升级带来的 breaking change。这个不算 bug,但确实是资料滞后造成的常见问题。

修复:统一以官方文档为准,确认版本号之后再决定怎么写。我建议比较稳的做法是:项目初始化时把 tRPC 相关依赖固定版本,避免哪天pnpm add装到新版本后旧代码大面积报错。

6. 适用边界与横向对比:这套方案在不该用的地方不要硬用

6.1 哪些场景可以放心用

从 t3code 的实践经验来看,下面几类场景用 tRPC 收益最大:

  • 内部管理系统:运营后台、管理面板、工具型站点,用户量不大但迭代极快,类型安全能直接减少联调成本;
  • 个人全栈项目:一个开发者同时写前后端时,tRPC 能让你彻底忘掉"接口文档"这件事,专注业务;
  • 创业公司早期 MVP:需要快速验证业务逻辑,又不想在接口文档和代码生成上投入太多维护精力。

如果你正在面对以上场景,我的建议是先挑一个小模块试用,比如把一个列表页从 REST 改成 tRPC,跑通一次全流程再决定是否铺开。不要一上来就全量重构,风险太大。

6.2 哪些场景建议绕开

反过来,下面这些场景用 tRPC 会踩得很痛:

  • 需要服务第三方开发者的开放平台,API 是产品的一部分;
  • 客户端是原生 App 或小程序,无法共享 TypeScript 类型;
  • 后端技术栈不是 Node.js 生态,比如 Java、Go 后端;
  • 团队内部前后端仓库分离且有明确的发布节奏,后端不希望暴露内部函数结构。

在这些场景里,REST + OpenAPI 或者 GraphQL 才是更稳妥的选择。技术选型没有"最正确",只有"最匹配现实约束"。

6.3 我自己的决策标准清单

最后分享一套我在做技术选型时实际会过一遍的问题,你可以直接抄走:

  • 前后端是不是都用 TypeScript?不是,直接排除 tRPC;
  • API 是否需要被公司外部调用?需要,走 REST + OpenAPI 更保险;
  • 前端是否需要读接口文档?团队小、沟通直接,tRPC 合适;团队大、人员流动快,一份稳定的 OpenAPI 文档可能更香;
  • 是否对极低延迟有强需求?tRPC 本身不是性能瓶颈,但如果你做的是开放的高并发 API,通用 REST 网关的生态会更成熟。

这套清单不一定适合所有人,但至少能帮你避免"上完 tRPC 之后再推倒重来"的尴尬。

6.4 后续扩展方向:t3code 还能往哪里走

目前 t3code 已经验证了"端到端类型安全"这个核心假设。如果继续做下去,我会优先从这几个方向扩展:

  • 用 Turborepo 做 monorepo 拆分,把server和web分开但共享类型包,方便团队多人协作;
  • 引入 Playwright 做端到端测试,因为类型安全保证了调用链正确,但 UI 交互仍然需要 E2E 兜底;
  • 增加日志与可观测性,在 tRPC middleware 里统一打点,记录每个 procedure 的耗时和错误,方便排查线上问题。

我最初做 t3code 的时候,最直观的收获是"少跑了很多次联调";但真正让我觉得值回票价的,是后期改字段时的从容——前端编译直接告诉我哪里受了影响,而不是等同事在群里喊一嗓子。类型安全不是银弹,它解决的是"前后端对齐"这个具体问题,但这个问题恰恰是很多项目里最日常、最隐蔽的时间黑洞。如果你也想把这套思路落到自己的项目里,不妨先找一个最小的场景跑通试试,感受一下"改完后端、前端立刻变红"这种即时反馈带来的安心感。

返回列表