各位幸存者,早上好。
我是你们的老朋友,那个在凌晨三点盯着屏幕上 “500 Internal Server Error” 抽完第三罐红牛的资深工程师。
今天我们不聊框架的新屁眼,也不谈那些花里胡哨的 CSS 动画。今天我们来聊聊一个关乎全栈开发者尊严的话题:我们为什么要把前端的屁股和后端的脸拼凑在一起时,还要痛苦地像个孤儿一样重新定义一遍类型?
在座的各位,有多少人经历过这种名为“类型地狱”的剧情?我猜,手拿保温杯的举手,里面泡的不是枸杞,是发际线的绝望。
咱们先来聊聊这个“JSON 序列化心智模型”。这个词听着很高大上,其实翻译过来就是:“我觉得是 A,但他给我的是 B,但我又不死心,我再去查文档。”
第一章:JSON 是个撒谎的情人
在全栈开发早期,或者当你试图用 REST API 而不是 tRPC 时,你们的关系是这样的:
前端说:“嘿,我要个用户列表。”
后端想:“好嘞,给你个 JSON 数组。”
前端拿到手:“咦?为什么 user.name 有时候是字符串,有时候是 null?为什么那个 age 字段我没见过?”
这就是 JSON 的本质——它是序列化。它的作用就是把你漂亮的 TypeScript 接口像挤牙膏一样挤成一段纯文本丢过去。在这个过程中,最讽刺的是什么?是类型丢失。
你在 TypeScript 里写的 interface User { id: number; name: string },在 JSON 里变成了 { "id": 1, "name": "Taro" }。这时候,TypeScript 的魔法消失了。哪怕你的代码里写了 500 个断言,编译器也没办法告诉你“嘿,你在某个地方把 id 当成了字符串传进去了”,因为 JSON 不认识 TypeScript。
这就像你和你对象约定好“周末去爬山”,结果到了周六,他给你发了一条微信:“来公园吧”。你到了公园,发现他在楼下等。
你说:“你不是说爬山吗?”
他说:“我说的是公园。”
你崩溃了:“你说过‘爬山’的!”
这就是 JSON 序列化带来的心智负担。你的大脑需要在这个“TypeScript 概念层”和“JSON 传输层”之间反复横跳。你要在脑子里画两张图:一张是代码里的,一张是 API 文档里的。如果你忘了更新文档,或者后端改了字段但没通知你,恭喜你,恭喜你获得了一个充满惊喜的运行时错误。
这种“信任危机”是全栈开发的死敌。我们为什么要信任一个不知道自己是 number 还是 string 的文本块?我们为什么要为了传数据,把好好的类型变成那个丑陋的 JSON?
第二章:缝合怪式的 API 设计
为了解决这个问题,早期的架构师们发明了各种复杂的解决方案,最后都殊途同归地变成了“缝合怪”。
最常见的做法是 Code First(先写 API)。前端工程师先定义好 DTO(数据传输对象),写好注释,发给后端工程师。后端工程师看了一眼,心想:“哎呀,这跟我写的 User 接口有点像,要不我就直接复用吧?”
于是,前端写了一个 createUserRequest,后端写了一个 CreateUserRequest。它们长得一模一样,但在文件系统中,它们住在两个不同的星球上。
有一天,后端觉得 email 字段最好加个格式校验,于是他把类型改了,或者加了 @validate 装饰器。前端呢?前端还在用旧的数据结构,直到有一天,生产环境爆了,因为前端传了一个不带 email 的对象,而后端接口强制要求它存在。
或者反过来,后端传回来的数据里多了一个 isAdmin 字段,前端定义的接口里没有。前端开发者抓耳挠腮:“这该死的字段是从哪冒出来的?是不是我的 JSON 解析出错了?”
这就是重复造轮子的最高境界——重复造同一个轮子,还造得歪歪扭扭。
这种心智模型的核心是:“我们需要一种机制来确保前端和后端对数据结构达成一致。”
最糟糕的是,你根本没法在运行时捕获这些不一致。编译器只认识 TypeScript,不认识 HTTP 请求。你的 TypeScript 可能编译通过,但你发送的 JSON 依然是一场灾难。
第三章:tRPC —— 当类型不再是纸老虎
现在,让我们把舞台交给 tRPC。
tRPC 的哲学非常简单,甚至有点像那个让你少写作业的“魔法书”:
“既然我们要保证类型安全,为什么不直接把服务器的类型文件拷贝到客户端?”
是的,你没听错。tRPC 允许你共享代码。你不用定义两遍接口。你不用写 zod schema,不用写 Swagger 文档,不用在 JSON 和 TypeScript 之间翻译。
在 tRPC 的世界里,API 路由其实就是一个返回类型确定且参数类型确定的过程函数。它就像是一个隐形的桥梁,把你的 React 组件和后端逻辑紧紧绑在一起。
想象一下,你不再需要写 fetch('/api/users') 这种老掉牙的代码。你写的不再是 HTTP 请求,而是直接调用函数。
让我给你们展示一下,这种思维转变带来的震撼。
3.1 服务器端的“作弊”代码
假设我们有一个 Next.js 应用。在传统的 REST 风格里,你可能会这样写后端:
// app/api/users/route.ts
import { NextResponse } from 'next/server';
import { z } from 'zod';
// 这里是定义一次的类型,或者你用一个独立的 file
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
export async function GET() {
// 模拟数据库查询
const users = [
{ id: 1, name: 'Alice', email: '[email protected]' },
{ id: 2, name: 'Bob', email: '[email protected]' }
];
return NextResponse.json(users);
}
看这段代码,你为了给前端返回数据,你在服务端定义了 UserSchema。为了安全起见,你可能还要写验证逻辑。
但在 tRPC 里,你不需要定义 Schema,因为你的业务逻辑本身就是类型定义。你看:
// app/trpc/router.ts (这是真正的共享代码)
import { initTRPC } from '@trpc/server';
import { PrismaClient } from '@prisma/client';
const t = initTRPC.create();
const prisma = new PrismaClient();
// 这是我们的“共享类型”
const userRouter = t.router({
// query 是查询操作
all: t.procedure.query(async () => {
return prisma.user.findMany();
}),
// procedure 是一个带类型的管道
byId: t.procedure
.input(z.object({ id: z.number() })) // 输入验证(可选但推荐)
.query(async ({ input }) => {
return prisma.user.findUnique({
where: { id: input.id },
});
}),
});
export const appRouter = t.router({
user: userRouter,
});
注意到了吗?prisma.user.findMany() 返回的类型是什么?是 User[]。t.router 会自动推断出这个类型。
3.2 客户端的“克隆”代码
接下来,神奇的事情发生了。在客户端,我们只需要几行代码就能获得刚才那个 appRouter 的完整类型。
// app/_trpc/client.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../trpc/router'; // 导入服务器的路由类型
export const trpc = createTRPCReact<AppRouter>();
现在,看看你的 React 组件里发生了什么。你不再是发起一个 HTTP 请求,你是在调用一个函数。
'use client';
import { trpc } from '@/app/_trpc/client';
import { useQuery } from '@tanstack/react-query';
export const UserList = () => {
// 1. useQuery 帮我们管理请求状态(加载、错误、数据)
// 2. trpc.user.all 是一个 React Hook
// 3. 类型提示是自动的!你甚至不需要知道这个字段叫什么,IDE 会告诉你
const { data, isLoading, error } = trpc.user.all.useQuery();
if (isLoading) return <div>正在加载地精...</div>;
if (error) {
// 错误也是类型安全的!
// 假设是一个 Zod 错误
if ('zodError' in error) {
return <div>你输入了什么奇怪的东西: {error.zodError.issues[0].message}</div>;
}
return <div>未知错误: {error.message}</div>;
}
return (
<ul>
{data.map((user) => (
<li key={user.id}>
{user.name} ({user.email})
</li>
))}
</ul>
);
};
看到了吗?这就是消除心智模型。
你不再需要思考:“我需要传什么参数?”
你不需要思考:“这个接口返回什么?”
你不需要思考:“我怎么解析 JSON?”
你只需要思考:“我想要什么数据,然后把它画出来。”
第四章:深入灵魂的代码共享
很多人第一次接触 tRPC 时会问:“我能不能不共享代码?我就想写前端,不想让后端看我的代码。”
这就像是想光着身子游泳,但还要穿泳裤。tRPC 允许你共享类型,而不共享实现。
你可以这样写:
// server.ts
import { initTRPC } from '@trpc/server';
const t = initTRPC.create();
export const appRouter = t.router({
greeting: t.procedure.input(z.string()).query(({ input }) => {
return `Hello, ${input}!`;
}),
});
// client.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from './server'; // 只导入类型定义
import { z } from 'zod';
export const trpc = createTRPCReact<AppRouter>();
客户端引入的只是 AppRouter 这个 TypeScript 接口,并没有引入任何服务器逻辑、Prisma 客户端或者业务代码。
这就像你请了一个厨师做菜(TypeScript 类型),你只需要知道菜单上有什么菜(接口定义),你不需要知道他在厨房里是怎么洗菜、怎么切肉、怎么炒菜的。
这种“契约式编程”极大地提高了开发效率。前端开发者在写组件之前,甚至可以先在浏览器控制台里测试一下 API 的返回值,看看有没有类型提示。这种即时反馈是任何 Swagger 文档都无法比拟的。
第五章:中间件与验证 —— 那个穿着盔甲的骑士
如果说 tRPC 是你的武器,那么中间件就是你的盔甲。
在传统的 REST 开发中,验证通常是被遗忘的角落,或者是一个丑陋的 if (input.name === null) throw new Error()。而在 tRPC 中,验证是第一道防线。
我们通常使用 zod 来配合 tRPC 进行输入验证。这不仅仅是为了“防御”,更是为了生成类型。
// 更复杂的验证示例
const updateProfile = t.procedure
.input(
z.object({
id: z.number(),
bio: z.string().min(10).max(500), // 长度限制
tags: z.array(z.string()).max(3), // 最多3个标签
})
)
.mutation(async ({ input }) => {
// 在这里,TypeScript 知道 input.id 是 number,input.bio 是 string
// 并且它已经保证了这些条件满足
await prisma.profile.update({
where: { id: input.id },
data: {
bio: input.bio,
tags: input.tags,
},
});
});
在客户端调用时:
trpc.profile.update.useMutation({
onSuccess: () => {
alert('更新成功!');
},
onError: (err) => {
if (err.data?.code === 'BAD_REQUEST') {
// 这是一个非常高级的体验
// tRPC 会自动把 Zod 的错误格式化成人类可读的文本
alert('哎呀,参数不对:' + err.data.zodError.issues[0].message);
}
},
});
你看,那个 err.data.zodError 是什么?是 tRPC 自动捕获的!它不需要你手动去解析错误字符串。这种错误处理的体验,简直是对比了“地狱”与“天堂”。
第六章:乐观更新与 React 的完美融合
tRPC 最迷人的地方在于它与 React 的深度绑定。尤其是当你结合 React Query(或 TanStack Query)时,你得到了一种叫做“乐观更新”的能力。
乐观更新的核心思想是:先相信用户,再相信网络。
假设我们要实现一个“点赞”功能。
传统方式:
- 用户点击点赞。
- UI 延迟 200ms,变成蓝色。
- 发送请求。
- 服务器接受。
- UI 变成蓝色。
tRPC + React Query 方式:
- 用户点击点赞。
- 瞬间,UI 变成蓝色,数字 +1。
- 发送请求。
- 服务器拒绝(比如网络断了,或者权限不足)。
- 瞬间,UI 恢复原状,数字 -1。提示用户“操作失败”。
代码是这样的:
const LikeButton = ({ userId }: { userId: number }) => {
const utils = trpc.useUtils();
// 初始查询
const { data } = trpc.user.getLikes.useQuery({ userId });
// 变更查询(Mutation)
const likeMutation = trpc.user.like.useMutation({
onMutate: async (newLike) => {
// 1. 取消掉正在进行的旧查询,防止冲突
await utils.user.getLikes.cancel({ userId });
// 2. 保存之前的值,以便回滚
const previousLikes = utils.user.getLikes.getData({ userId });
// 3. 乐观更新!直接修改缓存数据
utils.user.getLikes.setData(
{ userId },
(old) => old ? { ...old, count: old.count + 1, likedByMe: true } : old
);
return { previousLikes };
},
onError: (err, newLike, context) => {
// 4. 出错了?回滚!
if (context?.previousLikes) {
utils.user.getLikes.setData({ userId }, context.previousLikes);
}
},
onSettled: () => {
// 5. 无论成功失败,最后都刷新一次,确保数据同步
utils.user.getLikes.invalidate();
},
});
return (
<button
onClick={() => likeMutation.mutate({ userId })}
disabled={likeMutation.isPending}
>
❤️ {data?.count} {data?.likedByMe ? '已赞' : '赞'}
</button>
);
};
看这段代码,没有任何关于 fetch、response.json()、setState 的心智负担。你只需要关注“数据变了,UI 应该怎么变”。tRPC 和 React Query 协同工作,像老夫老妻一样默契。
第七章:拒绝“上帝视角”,拥抱模块化
以前写 API,如果你是单体应用,你可能会写一个超级大的路由文件,里面塞进了几千行代码。一旦出错,那是真正的“牵一发而动全身”。
tRPC 的架构天然支持模块化。因为路由本质上就是一个函数集合。
// router/user.ts
const userRouter = t.router({
all: t.procedure.query(...),
byId: t.procedure.query(...),
});
// router/post.ts
const postRouter = t.router({
list: t.procedure.query(...),
create: t.procedure.mutation(...),
});
// appRouter.ts
export const appRouter = t.router({
user: userRouter,
post: postRouter,
comment: commentRouter,
});
你可以把每个 router 作为一个单独的文件。这意味着什么?意味着你可以把后端代码的维护工作分配给不同的团队成员,就像前端一样。前端可以拆成组件库、路由、状态管理,后端为什么不能拆?
第八章:我的机器上运行着两套代码?
这是新手最大的恐惧:“我用 tRPC,是不是意味着我前端和后端要跑两套代码?”
答案是:不一定。
tRPC 是基于调用的,它不在乎你是怎么部署的。
- Monorepo (推荐): 你用 Next.js 或者 T3 Stack,前端和后端在一个代码库里,都在
src下面。tRPC 直接调用本地的函数,编译通过,运行就通。这是最快的开发体验。 - 独立部署: 你把后端 API 部署到 Vercel Edge、AWS Lambda 或者任何服务器上。前端代码里只需要配置一下
httpUrl,tRPC 客户端就会自动切换从 HTTP 获取类型定义(通过一种特殊的类型推断机制),或者你把类型定义拷贝过来。这依然保持了类型安全,只是代码重复了一点点(类型定义部分)。
tRPC 帮你解决的,不是“前后端分离”这个问题,而是“前后端同步”这个问题。无论你怎么部署,你都不会丢失类型安全。
第九章:关于“生成类型”的黑魔法
如果你仔细观察 tRPC 的类型推断机制,你会发现这其实是编译时的魔术。
当我们调用 trpc.user.all.useQuery() 时,TypeScript 编译器实际上是在运行某种类型的“泛化”算法。
它看到:
userRouter定义了一个all路由。all路由返回了一个Promise,该 Promise 解析为User[]。useQuery需要一个泛型参数,用来推断返回的数据类型。
于是,TypeScript 告诉你:data 的类型就是 User[]。
这就像是你给了一个黑盒,你不需要看黑盒里的电路板(实现代码),你只需要看黑盒外面的标签(类型定义)。tRPC 保证了那个标签是 100% 准确的,因为它就是从那张标签本身生成的。
第十章:别再犹豫,开始重构吧
我们要承认,tRPC 不是银弹。如果你在做一个极其简单的静态页面,REST API 够用了。如果你在做一个极简的 MVP,手写 API 也能凑合。
但是,一旦你的项目涉及到复杂的业务逻辑、多人协作、或者你开始厌倦了那个总是报错的 axios 配置文件,tRPC 就是你的救赎。
它把全栈开发从“拼图游戏”变成了“搭积木”。你不需要在脑海中构建两个世界(一个是代码,一个是 API 文档),你只需要在一个世界里思考。
它消灭了 JSON 序列化带来的噪音,让开发者回归到“写代码”本身。
当你在凌晨三点修 Bug 时,不再需要打开 Swagger 文档去对参数;当你需要加一个新字段时,不需要去改前后端两份代码,也不需要去更新文档;当你写好 Mutation 时,乐观更新会给你带来即时的反馈快感。
这就是 React 大师级的实践。这不是关于某种特定的语法糖,这是关于构建思维模型的一致性。
所以,扔掉你的 Swagger 文档,扔掉那些丑陋的 DTO 定义。拥抱 tRPC,拥抱类型共享,拥抱那个不再需要翻译的程序员世界。
去写代码吧,让 TypeScript 去处理类型,让 tRPC 去处理连接,你只需要负责写出惊艳的用户体验。
谢谢大家。