React 驱动的多端应用:利用 tRPC 共享 NestJS 后端逻辑并在 React 与 RN 间同步类型

拒绝重复造轮子:如何用 tRPC + NestJS + React 打造一套通吃 Web 和 RN 的“魔法”架构

各位观众朋友们,大家好!我是你们今天的领路人。今天我们不聊虚的,我们来聊聊前端开发界那个永恒的痛点——“多端开发”

想象一下这个场景:

你的老板是个天才,他说:“咱们有个超级厉害的 SaaS 平台,既要在电脑浏览器上跑,又得能在 iOS 和 Android 手机上跑。代码得复用,逻辑得统一,但界面体验得各不相同。”

于是,你开始了:

  1. 写后端 API,用 Swagger 写文档,还得手写 TypeScript 接口定义,发给前端小弟。
  2. 前端小弟拿到文档,又开始定义一遍接口。
  3. 业务逻辑改了,后端改了,前端接口还得跟着改。
  4. 重复!重复!还是重复!你感觉自己像个只会复读的机器人。

这就像什么呢?这就像你为了吃一顿大餐,先要自己种地、养猪、织布,最后才在厨房里做饭。太累赘了!

今天,我要介绍一位“神助攻”,它能让你在后端和前端之间建立一条没有任何废话的“高速数据专线”。这位大神的名字,叫做 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 的性能提升是巨大的。


第五幕:架构图解与实战总结

为了让大家彻底明白这个架构,我们来画个思维导图(虽然我不能画,但我可以描述):

  1. NestJS (Server):

    • 是大脑。
    • 负责数据库操作。
    • 负责业务逻辑验证。
    • 负责鉴权。
    • 它定义了 appRouter(包含所有的 API 函数)。
  2. tRPC (The Bridge):

    • 是翻译官。
    • 它读取 Server 的 Router 定义,生成 TypeScript 类型。
    • 它生成 JSON RPC 协议来传输数据。
  3. React Web:

    • 是桌面端的工人。
    • 它直接调用 trpc.router.method.useQuery()
    • 它接收自动推断的类型。
  4. React Native:

    • 是移动端的工人。
    • 它也直接调用 trpc.router.method.useQuery()
    • 它也接收自动推断的类型。

优势总结(这才是重点):

  1. 零样板代码:不需要写 Swagger 文档,不需要在 shared/types 文件夹下手动复制粘贴接口。
  2. 极致类型安全:任何字段拼写错误,编译期就能发现,而不是运行时才发现。
  3. 开发效率倍增:前后端开发可以并行进行,只要约定好接口名就行。
  4. 维护成本低:改一处,全端生效(指类型层面)。

结尾

好了,今天的讲座到这里就结束了。

想象一下,如果你还在用 REST API,每天面对着成百上千个 .ts 接口文件,在 Web 和 Mobile 之间来回同步定义,你会不会想把键盘砸了?

而现在,你只需要写一次函数,就能在 Web 和 RN 上无缝切换。这就是 tRPC 带给你的快乐。

记住,技术是为人类服务的。如果一种技术让你写更多重复的代码,那它就是垃圾。tRPC 和 NestJS 的结合,就是为了让你把时间花在真正的业务创新上,而不是在 VS Code 里做“复制粘贴”的机械运动。

现在,拿起你的代码,去享受这种“全自动类型同步”的快乐吧!如果遇到报错,别慌,那是你的大脑在适应新技术的过程中产生的静电反应。

谢谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注