拒绝重复造轮子:如何用 tRPC + NestJS + React 打造一套通吃 Web 和 RN 的“魔法”架构
各位观众朋友们,大家好!我是你们今天的领路人。今天我们不聊虚的,我们来聊聊前端开发界那个永恒的痛点——“多端开发”。
想象一下这个场景:
你的老板是个天才,他说:“咱们有个超级厉害的 SaaS 平台,既要在电脑浏览器上跑,又得能在 iOS 和 Android 手机上跑。代码得复用,逻辑得统一,但界面体验得各不相同。”
于是,你开始了:
- 写后端 API,用 Swagger 写文档,还得手写 TypeScript 接口定义,发给前端小弟。
- 前端小弟拿到文档,又开始定义一遍接口。
- 业务逻辑改了,后端改了,前端接口还得跟着改。
- 重复!重复!还是重复!你感觉自己像个只会复读的机器人。
这就像什么呢?这就像你为了吃一顿大餐,先要自己种地、养猪、织布,最后才在厨房里做饭。太累赘了!
今天,我要介绍一位“神助攻”,它能让你在后端和前端之间建立一条没有任何废话的“高速数据专线”。这位大神的名字,叫做 tRPC。
我们要构建的,是一个 NestJS 后端 + React Web + React Native 移动端 的全栈解决方案,且要做到零文档、零类型重复、零手动同步。
来,让我们把代码铺开,开始这场魔法表演。
第一幕:后端的“真理之源”
我们要先从 NestJS 开始。为什么?因为 NestJS 是个“很有原则”的框架,它喜欢结构化,喜欢依赖注入。而 tRPC 和 NestJS 的结合,简直就是“霸道总裁爱上我”——既有面子,又有里子。
首先,我们要给 NestJS 装上一副“透视眼镜”。
1.1 搭建 NestJS 环境
如果你还没装 NestJS,那请先在终端里敲下这一行命令,虽然我知道你们早就忘了怎么敲了,但为了仪式感:
npm install -g @nestjs/cli
nest new my-multi-platform-app
cd my-multi-platform-app
然后,我们要安装 tRPC 的服务器端套件。这就像是给 NestJS 安装了一个“内置翻译器”。
npm install @trpc/server @trpc/server/adapters/nestjs zod @prisma/client prisma
注:我顺便引入了 zod,它是 tRPC 的最佳拍档,用来定义数据验证规则,比手写 if (typeof === string) 要优雅一万倍。
1.2 定义你的“核心逻辑”
在你的 NestJS 项目中,通常会有很多 Service。在我们的架构里,这些 Service 不再是死气沉沉的类,而是 tRPC 的路由处理器。
让我们创建一个 posts.service.ts,别急,这不仅仅是个 Service,它是整个应用的数据心脏。
// src/posts/posts.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { z } from 'zod';
// 1. 定义输入的 Schema (Validation)
// 就像给数据套了个“安检门”,不合格的数据别想进来
const createPostSchema = z.object({
title: z.string().min(5, "标题太短了,写点什么吧!"),
content: z.string(),
authorId: z.number(),
});
@Injectable()
export class PostsService {
constructor(private prisma: PrismaService) {}
// 2. 定义 tRPC 路由
// 这里没有任何 'any',全是强类型!
async create(data: z.infer<typeof createPostSchema>) {
return this.prisma.post.create({
data: {
title: data.title,
content: data.content,
author: { connect: { id: data.authorId } },
},
});
}
async findAll() {
return this.prisma.post.findMany({
include: { author: true }, // 关联查询,NestJS 的拿手好戏
});
}
}
看,多么干净!这里没有复杂的 Controller,没有繁琐的路由配置。你定义了函数,就定义了 API。这就是 tRPC 的精髓——API 即函数。
1.3 将 tRPC 挂载到 NestJS
接下来,我们要把 NestJS 和 tRPC 连接起来。我们需要一个专门的 app.module.ts 来统领全局。
// src/app.module.ts
import { Module } from '@nestjs/common';
import { PrismaModule } from './prisma/prisma.module';
import { PostsModule } from './posts/posts.module';
import { initTRPC } from '@trpc/server';
import { NestExpressApplication } from '@nestjs/platform-express';
// 1. 初始化 tRPC
const t = initTRPC.create();
// 2. 定义中间件 (比如鉴权)
const middleware = t.middleware(({ next, ctx }) => {
// 模拟一下:如果没登录,直接拒绝
if (!ctx.user) {
throw new Error('请先登录,你是谁?');
}
return next({
ctx: { ...ctx, user: ctx.user },
});
});
// 3. 定义你的路由 API (Router)
const protectedProcedure = t.procedure.use(middleware);
const postsRouter = t.router({
// 暴露给公众的方法
list: t.procedure.query(async ({ ctx }) => {
return ctx.prisma.post.findMany();
}),
// 只有登录用户才能用的方法
create: protectedProcedure
.input(z.object({ title: z.string(), content: z.string() }))
.mutation(async ({ input }) => {
// 这里可以访问 ctx.user,因为我们用了 middleware
return { id: 1, ...input, authorId: ctx.user.id };
}),
});
// 4. 创建 NestJS 的 Controller 来处理 HTTP 请求
import { TRPCAdapterOptions, createTRPCNestAdapter } from '@trpc/server/adapters/nestjs';
import { Controller, Get, Post, Body } from '@nestjs/common';
@Controller('trpc')
export class TRPCController {
constructor(private postsService: PostsService) {}
@Post()
async createPost(@Body() body: any) {
// 这里我们不做复杂的事,直接把请求转给 tRPC router
// 实际项目中可以使用反射来处理
return this.postsService.create(body);
}
@Get()
findAll() {
return this.postsService.findAll();
}
}
上面的代码可能有点啰嗦,为了简化理解,我们通常会用 tRPC 的 NestJS 集成库的高级用法,但核心思想就是:把你的业务逻辑封装在 Router 里。
1.4 服务器端的完整路由
让我们看看完整的 tRPC 定义文件,这将是前端模仿的模板。
// src/trpc/router.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const t = initTRPC.create();
export const appRouter = t.router({
// 获取所有帖子
getAllPosts: t.procedure.query(async () => {
return prisma.post.findMany({
include: { author: true },
orderBy: { createdAt: 'desc' },
});
}),
// 创建帖子
createPost: t.procedure
.input(z.object({
title: z.string().min(3),
content: z.string().optional(),
}))
.mutation(async ({ input }) => {
return prisma.post.create({
data: input,
});
}),
});
// 类型导出,这是关键!
export type AppRouter = typeof appRouter;
好,后端已经准备好了。它就像一个深藏不露的武林高手,手里拿着两把剑(tRPC 和 NestJS),无论谁来挑战,它都能用一套剑法应对。
第二幕:Web 端 React——所见即所得
现在,我们有了后端。接下来是 Web 端 React。如果你之前用过普通的 REST API,你会怀念 axios.post('/api/posts', data) 这种写法的,但在 tRPC 面前,这些都成了“上古时代”的遗物。
2.1 安装 Web 客户端
在 React 项目中:
npm install @trpc/client @trpc/react @tanstack/react-query
2.2 配置 Provider——你的“总管”
React 应用想要使用 tRPC,必须先配置 TRPCProvider。这就像是给你的应用安了一个大脑。
// src/pages/_app.tsx (Next.js 示例) 或 src/App.tsx (React 示例)
import { useState } from 'react';
import { createTRPCReact } from '@trpc/react';
import type { AppRouter } from '../server/trpc/router';
// 1. 创建 tRPC Hooks
export const trpc = createTRPCReact<AppRouter>();
function App() {
const [queryClient] = useState(() => new QueryClient());
// 2. 创建 Client,连接后端
// 注意:这里要根据你部署的 URL 调整
const [trpcClient] = useState(() => trpc.createClient({
links: [
httpBatchLink({
url: 'http://localhost:3000/trpc',
}),
],
}));
return (
<QueryClientProvider client={queryClient}>
<TRPCProvider client={trpcClient} queryClient={queryClient}>
<main>
<h1>我的超级博客</h1>
<PostList />
</main>
</TRPCProvider>
</QueryClientProvider>
);
}
2.3 使用 Query 和 Mutation——行云流水的操作
现在,我们来看看 Web 端是怎么用的。想象一下,你写了一个组件,它去获取数据。在 tRPC 里,这叫 useQuery。
// src/components/PostList.tsx
import { trpc } from '../App';
export const PostList = () => {
// 1. 调用后端函数,自动推断类型!
// 就像你在本地调用函数一样,但数据是远程的
const { data, isLoading, error } = trpc.getAllPosts.useQuery();
if (isLoading) return <div>正在加载,请稍候...</div>;
if (error) return <div>哎呀,出错了:{error.message}</div>;
return (
<ul>
{data?.map((post) => (
<li key={post.id}>
<h2>{post.title}</h2>
<p>{post.content}</p>
<small>作者: {post.author.name}</small>
</li>
))}
</ul>
);
};
看到那个 post.author.name 了吗?类型推断是自动的! 不需要你定义 interface Post { id: number, ... }。后端定义了,前端直接用。如果有任何一个字段拼写错误,TypeScript 会立刻报错,告诉你“你个笨蛋,Post 上面没有 author_name 这个属性!”
2.4 创建内容
再看看创建内容的 useMutation。
// src/components/CreatePost.tsx
import { trpc } from '../App';
import { useState } from 'react';
export const CreatePost = () => {
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
// 使用 mutation
const createPost = trpc.createPost.useMutation({
onSuccess: () => {
alert('文章发布成功!我要去刷新列表了!');
// 可以在这里调用 invalidateQueries 来让列表自动刷新
// invalidateQueries 是 React Query 的特性,配合 tRPC 完美
},
onError: (err) => {
alert(`发布失败:${err.message}`);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
createPost.mutate({ title, content });
};
return (
<form onSubmit={handleSubmit}>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="输入标题"
/>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="输入内容"
/>
<button type="submit" disabled={createPost.isLoading}>
{createPost.isLoading ? '发布中...' : '发布'}
</button>
</form>
);
};
你会发现,Web 端的代码如此简单,甚至带着一种“耍流氓”的快感。你写的不是 API 请求,你写的是业务逻辑。
第三幕:移动端 React Native——同一套代码,不同风景
好了,Web 端完美运行。老板现在指着手机说:“那 App 端呢?”
你心里慌了,心想:“又要写一套?还要适配 Android 和 iOS?还要重新定义接口?”
不,朋友。请记住我们说的:同步类型。
3.1 安装 RN 客户端
在 React Native 项目中,安装同样的 @trpc/client 和 @trpc/react-native。
npm install @trpc/client @trpc/react-native
3.2 RN 的连接方式
Web 通常用 httpBatchLink(批量请求),但 RN 客户端配置略有不同。我们需要告诉它,数据是怎么传输的。通常 RN 可以用 wsLink (WebSocket) 或者 httpLink (HTTP)。
这里我们演示最简单的 httpLink。
// src/App.tsx (RN 版本)
import { createTRPCReact } from '@trpc/react-native';
import { httpLink } from '@trpc/client';
import type { AppRouter } from './server/trpc/router';
import * as React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { StatusBar } from 'expo-status-bar';
import { StyleSheet, Text, View, FlatList, TextInput, Button, Alert } from 'react-native';
export const trpc = createTRPCReact<AppRouter>();
export default function App() {
const [queryClient] = React.useState(() => new QueryClient());
const [trpcClient] = React.useState(() =>
trpc.createClient({
links: [
httpLink({
url: 'http://localhost:3000/trpc', // 记得把 IP 改成你的电脑 IP
}),
],
})
);
return (
<QueryClientProvider client={queryClient}>
<TRPCProvider client={trpcClient} queryClient={queryClient}>
<MainContent />
</TRPCProvider>
</QueryClientProvider>
);
}
function MainContent() {
const { data } = trpc.getAllPosts.useQuery();
const createPost = trpc.createPost.useMutation();
const [title, setTitle] = React.useState('');
return (
<View style={styles.container}>
<Text style={styles.title}>移动端文章列表</Text>
<TextInput
style={styles.input}
placeholder="标题"
value={title}
onChangeText={setTitle}
/>
<Button
title="发布"
onPress={() => {
createPost.mutate({ title, content: "这里是移动端创建的内容" });
}}
/>
<FlatList
data={data}
keyExtractor={(item) => item.id.toString()}
renderItem={({ item }) => (
<View style={styles.card}>
<Text style={styles.cardTitle}>{item.title}</Text>
<Text style={styles.cardAuthor}>By: {item.author.name}</Text>
</View>
)}
/>
</View>
);
}
// 辅助 Provider
function TRPCProvider({ children, client, queryClient }: any) {
return (
<trpc.Provider client={client} queryClient={queryClient}>
{children}
</trpc.Provider>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 20 },
title: { fontSize: 24, marginBottom: 20, fontWeight: 'bold' },
input: { borderWidth: 1, borderColor: '#ccc', padding: 10, marginBottom: 10, borderRadius: 5 },
card: { padding: 10, borderBottomWidth: 1, borderColor: '#eee', marginBottom: 10 },
cardTitle: { fontSize: 18, fontWeight: 'bold' },
cardAuthor: { color: 'gray', fontSize: 14 },
});
看!重点来了!
Web 端的 PostList 和 RN 端的 MainContent,调用的都是 trpc.getAllPosts.useQuery()。
后端定义了 getAllPosts 返回什么,前端就一定能拿到什么。
RN 端甚至不需要看 Web 端的代码,它只需要安装了 tRPC,它就能自动拥有类型提示!
这就是“共享类型”的力量。如果你在后端把 title 改成了 string(100),Web 端会报错,RN 端也会报错。这就像是一个双向锁,确保了两边永远同步。
第四幕:进阶玩法——中间件与懒加载
讲了这么多,你是不是觉得“类型同步”这个功能还不够花哨?别急,tRPC 还有大招。
4.1 全局中间件(鉴权与日志)
在 NestJS 里,我们定义了 protectedProcedure。现在,让我们把它用到实际场景中。比如,只有登录用户才能创建文章。
Web 端和 RN 端代码完全一致:
// 任意前端文件
import { trpc } from './App';
const createProtectedPost = trpc.createPost.useMutation();
const handlePost = () => {
try {
createProtectedPost.mutate({ title: 'Hello', content: 'Secret' });
} catch (e) {
// 如果鉴权失败,tRPC 会抛出一个标准的 Error 对象
if (e.message.includes('请先登录')) {
Alert.alert('访问拒绝', '你是个路人,进不去!');
}
}
};
怎么样?后端拦截了,前端 try/catch 捕获了,整个流程丝滑无比。
4.2 懒加载——拯救你的首屏加载速度
React Native 项目,包体积是硬伤。我们不想把所有路由的 tRPC 调用都在 App 启动时就初始化。
tRPC 支持懒加载。我们可以把路由拆开,按需加载。
// lazy-router.tsx
import { trpc } from './App';
import { Suspense } from 'react';
import { ActivityIndicator } from 'react-native';
const LazyPosts = () => {
return (
<Suspense fallback={<ActivityIndicator size="large" color="#0000ff" />}>
{/* 只有在组件渲染时才会发起请求 */}
const { data } = trpc.getAllPosts.useQuery();
// ... 渲染逻辑
</Suspense>
);
};
这就像是你去超市买东西,不用把整个仓库都搬回家,需要什么直接去拿。这对 RN 的性能提升是巨大的。
第五幕:架构图解与实战总结
为了让大家彻底明白这个架构,我们来画个思维导图(虽然我不能画,但我可以描述):
-
NestJS (Server):
- 是大脑。
- 负责数据库操作。
- 负责业务逻辑验证。
- 负责鉴权。
- 它定义了
appRouter(包含所有的 API 函数)。
-
tRPC (The Bridge):
- 是翻译官。
- 它读取 Server 的 Router 定义,生成 TypeScript 类型。
- 它生成 JSON RPC 协议来传输数据。
-
React Web:
- 是桌面端的工人。
- 它直接调用
trpc.router.method.useQuery()。 - 它接收自动推断的类型。
-
React Native:
- 是移动端的工人。
- 它也直接调用
trpc.router.method.useQuery()。 - 它也接收自动推断的类型。
优势总结(这才是重点):
- 零样板代码:不需要写 Swagger 文档,不需要在
shared/types文件夹下手动复制粘贴接口。 - 极致类型安全:任何字段拼写错误,编译期就能发现,而不是运行时才发现。
- 开发效率倍增:前后端开发可以并行进行,只要约定好接口名就行。
- 维护成本低:改一处,全端生效(指类型层面)。
结尾
好了,今天的讲座到这里就结束了。
想象一下,如果你还在用 REST API,每天面对着成百上千个 .ts 接口文件,在 Web 和 Mobile 之间来回同步定义,你会不会想把键盘砸了?
而现在,你只需要写一次函数,就能在 Web 和 RN 上无缝切换。这就是 tRPC 带给你的快乐。
记住,技术是为人类服务的。如果一种技术让你写更多重复的代码,那它就是垃圾。tRPC 和 NestJS 的结合,就是为了让你把时间花在真正的业务创新上,而不是在 VS Code 里做“复制粘贴”的机械运动。
现在,拿起你的代码,去享受这种“全自动类型同步”的快乐吧!如果遇到报错,别慌,那是你的大脑在适应新技术的过程中产生的静电反应。
谢谢大家!