Next.js
66-实战篇 tRPC 与类型安全

前言

我们先从 RPC 开始说起。

RPC(Remote Procedure Call),中文译为“远程过程调用”,主要用于在远程计算机之间执行操作。它允许一个程序调用另一个程序,就像调用本地服务一样。

其实这个介绍已经很精炼的概括了 RPC,但对于初学者尤其是前端同学,这个介绍可能依然有些不明所以。

其实提 RPC 一定要讲分布式,对于大型网站而言,子系统部署在不同的服务器上,但又需要相互协作,于是诞生了 RPC 这个技术概念。它主要解决 2 个问题:

  1. 分布式系统的服务之间的调用问题
  2. 远程调用时,最好能够像本地调用一样方便,让调用者感知不到远程调用的逻辑

想想 Server Actions,它的本质其实就是 RPC,调用的时候就像本地调用,实际上是客户端调用服务端

至于底层是使用 HTTP 还是 Socket 那是 RPC 框架的事情。

tRPC (opens in a new tab) 是一个基于 TypeScript 的 RPC 框架,不过我们使用 tRPC 倒不是要解决分布式问题,而是为了解决客户端和服务端之间共享类型的问题。引用 tRPC 首页的介绍图:

v10-dark-landscape.gif

左边是服务端代码,右边是客户端代码,当你修改了服务端的请求参数字段,TypeScript 立刻就在客户端代码提示出了字段错误。这种客户端和服务端之间的类型共享就叫做端到端类型安全(End-to-end typesafe)。

本篇为大家讲解 Next.js App Router 如何集成 tRPC。

不过我个人建议:如果你之前没有用过或喜欢 tRPC,那就不要学了。末尾会给解释。

tRPC 与 Next.js

1. 项目初始化

初始化项目:

npx create-next-app@latest

选择 TypeScript、App Router:

image.png

安装 tRPC 依赖项:

npm install @trpc/server@next @trpc/client@next @trpc/react-query@next @trpc/next@next @tanstack/react-query@latest zod

2. 定义接口

新建 server/trpc.ts,代码如下:

import { initTRPC } from "@trpc/server";
// Avoid exporting the entire t-object
// since it's not very descriptive.
// For instance, the use of a t variable
// is common in i18n libraries.
const t = initTRPC.create();
// Base router and procedure helpers
export const router = t.router;
export const procedure = t.procedure;
export const createCallerFactory = t.createCallerFactory;

新建 server/index.ts,代码如下:

import { z } from "zod";
import { procedure, router } from "./trpc";
 
export const appRouter = router({
  getTodos: procedure.query(() => {
    return {
      todos: ["运动", "冥想", "阅读"],
    };
  }),
});
 
// export type definition of API
export type AppRouter = typeof appRouter;

这里就是具体定义 trpc 方法的地方,我们声明了一个 getTodos 方法。

新建 app/api/trpc/[trpc]/route.ts,代码如下:

import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/server";
 
function handler(req: Request) {
  return fetchRequestHandler({
    endpoint: "/api/trpc",
    req,
    router: appRouter,
    createContext: () => ({}),
  });
}
export { handler as GET, handler as POST };

此时访问 http://localhost:3000/api/trpc/getTodos (opens in a new tab),就可以查看到接口数据:

image.png

3. tRPC Client

新建 app/_trpc/client.ts,代码如下:

import { type AppRouter } from "@/server";
import { createTRPCReact } from "@trpc/react-query";
 
export const trpc = createTRPCReact<AppRouter>({});

新建 app/_trpc/Provider.tsx,代码如下:

"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import React, { useState } from "react";
 
import { trpc } from "./client";
 
export default function Provider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient({}));
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: "http://localhost:3000/api/trpc",
        }),
      ],
    })
                               );
  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </trpc.Provider>
  );
}

修改 app/layout.tsx,代码如下:

import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import Provider from "@/app/_trpc/Provider";
const inter = Inter({ subsets: ["latin"] });
 
export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};
 
export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <Provider>{children}</Provider>
      </body>
    </html>
  );
}

新建 app/_trpc/serverclient.ts,代码如下:

import { appRouter } from "@/server";
import { createCallerFactory } from "@/server/trpc";
import { httpBatchLink } from "@trpc/client";
 
const createCaller = createCallerFactory(appRouter);
 
export const serverClient = createCaller({
  links: [
    httpBatchLink({
      url: "http://localhost:3000/api/trpc",
    }),
  ],
});

4. 服务端调用示例

当需要服务端渲染的时候,导入 app/_trpc/serverclient 调用方法。

修改 app/page.tsx,代码如下:

import { serverClient } from "@/app/_trpc/serverclient";
 
export default async function Home() {
  const todos = await serverClient.getTodos();
  return <div>{JSON.stringify(todos)}</div>;
}

浏览器效果如下:

image.png

5. 客户端调用示例

当客户端调用接口的时候,导入 app/_trpc/client调用方法。

新建 app/todos/page.js,代码如下:

"use client";
 
import { trpc } from "@/app/_trpc/client";
 
export default function page() {
  const getTodos = trpc.getTodos.useQuery();
 
  return (
    <main>
      <div>{JSON.stringify(getTodos, null, "\t")}</div>
    </main>
  );
}
 

浏览器效果如下:

29.gif

不一定非要用 tRPC

首先,正如 tRPC 官方网站首页的介绍,tRPC 的最大特点是解决了全栈应用端到端类型安全的问题:

image.png

但是 Next.js 的 Server Actions 已经解决了这一问题(实际上 Server Actions 和 tRPC 本质都是 RPC),当 Server Actions 搭配 TypeScript 的时候,已经能够给出准确的类型。

而且 Next.js App Router 已经出来 2 年了,tRPC 官方至今没有给出权威的接入 App Router 的教程(以上接入的教程更多是参考业界的实践总结而来)。

为什么至今没有呢?于是就有人发起了 docs: Provide examples using tRPC with Next.js app router (opens in a new tab) 的 Issue,而 tRPC 的作者在 5 月给出的回应是:

截屏2024-07-04 21.44.15.png

也就是说,因为 RSC 和 Server Actions 解决了不少创建 trpc 时要解决的问题,所以作者也不知道人们到底要怎么用 tRPC。

最后,tRPC 学习门槛高。毕竟 rpc 并不是一个小概念,它涉及的内容很多,使用 tRPC 还要重新学习 API 比如如何做校验、做鉴权、做缓存、做跨域、错误处理等等,对于没有经验的人又要踩上一批坑。

所以我个人觉得如果你之前没有用过或喜欢 tRPC,那就不用学了,觉得 tRPC 听起来帅就更没必要了。

但这并不是说 tRPC 一点用也没有,实际上,tRPC 官方给出了结合 Next.js Server Actions 的教程 (opens in a new tab)。因为 tRPC 本身的 API 做的不错,所以使用 tRPC 定义 Server Actions 可以使用 tRPC 的如输入验证、身份验证和授权、输出验证、数据转换器等功能。

个人意见,仅供参考,欢迎留言讨论

参考链接

  1. https://www.youtube.com/watch?v=qCLV0Iaq9zU&t=12s&ab_channel=JackHerrington (opens in a new tab)