十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

create-t3-app 中的 tRPC 实战:端到端类型安全的 Next.js API 开发指南

create-t3-app 中的 tRPC 实战:端到端类型安全的 Next.js API 开发指南 create-t3-app 中的 tRPC 实战端到端类型安全的 Next.js API 开发指南【免费下载链接】create-t3-appThe best way to start a full-stack, typesafe Next.js app项目地址: https://gitcode.com/gh_mirrors/cr/create-t3-app本篇指南以 create-t3-app 官方文档www/src/pages/pt/usage/trpc.md为核心系统讲解如何在由 create-t3-app 生成的项目中编写、挂载和调用 tRPC 程序procedure、利用 Zod 校验输入并推断前端错误类型、理解脚手架生成的每个 tRPC 文件的职责以及通过服务端调用、CORS 配置、乐观更新和集成测试等实战片段把 tRPC 能力真正落地。读完本文你将掌握 tRPC 从零配置脚手架到生产级调用的完整链路并能在 Next.js 的 Pages Router 与 App Router 两种模式下自由切换。tRPC 是什么省去传统 API 层的端到端类型安全tRPC 允许你在不生成任何代码、不引入额外运行时开销的前提下编写端到端类型安全的 API。它充分利用 TypeScript 强大的类型推断能力从 API 路由器的定义中直接推断出类型让你在前端调用 API 程序时获得完整的类型安全与自动补全。tRPC 的创造者 Alex 在文档引言中这样解释其设计动机我创建 tRPC 是为了让人们走得更快——移除传统 API 层的必要性同时在快速迭代时依然对应用不会出问题保持信心。在 create-t3-app 生成的模板中tRPC 通常与 Prisma/Drizzle、NextAuth/Better Auth 组合使用你在后端用 TypeScript 写函数这些函数就是 tRPC 程序等价于传统后端中的路由处理器然后从前端直接调用它们。由于前后端共享同一套类型系统调用路径上的任何错误都会在编译期暴露。从零认识一个 tRPC 程序procedure 与 router一个最小的 procedureconst userRouter createTRPCRouter({ getById: publicProcedure.input(z.string()).query(({ ctx, input }) { return ctx.prisma.user.findFirst({ where: { id: input, }, }); }), });这个getById就是一个 tRPC 程序等价于传统后端里的一个路由处理器。流程是输入校验先用 Zod 校验输入Zod 与 create-t3-app 校验环境变量用的是同一个库。这里要求输入必须是字符串如果传入的不是字符串tRPC 会抛出一个信息明确的错误而不会进入处理逻辑。解析器resolver在.input()之后链式调用一个解析函数它可以是查询query、变更mutation或订阅subscription。示例解析器通过 prisma 客户端查询数据库返回id匹配的用户记录。通过 router 组织程序并合并为 appRouter你在routers中定义程序每个 router 是一组带共享命名空间的关联程序的集合。你可以为users、posts、messages各建一个 router然后在根路由里把它们合并成一个集中的appRouterconst appRouter createTRPCRouter({ users: userRouter, posts: postRouter, messages: messageRouter, }); export type AppRouter typeof appRouter;注意我们只导出 router 的类型定义这意味着客户端永远不会 import 任何服务器端代码——这是 tRPC 保证类型安全又不泄漏服务端实现的关键设计。在前端调用程序tRPC 为tanstack/react-query提供了一个封装层让你既能使用 React Query 的全部 hooks 能力又能享受 API 调用被类型化、被推断的额外好处import { useRouter } from next/router; import { api } from ../../utils/api; const UserPage () { const { query } useRouter(); const userQuery api.users.getById.useQuery(query.id); return ( div h1{userQuery.data?.name}/h1 /div ); };一旦输入api.自动补全就会列出所有 router选中 router 后又会列出其程序。如果传入的输入与后端定义的校验器不匹配TypeScript 会直接报错——这份补全即安全的开发体验正是 tRPC 的核心价值。推断 Zod 校验错误error.data.zodError默认情况下create-t3-app 配置了一个错误格式化器error formatter允许你在前端推断后端校验失败时的 Zod 错误。这背后是模板在初始化 tRPC 时注入的errorFormatterconst t initTRPC.contexttypeof createTRPCContext().create({ transformer: superjson, errorFormatter({ shape, error }) { return { ...shape, data: { ...shape.data, zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, }, }; }, });前端使用示例function MyComponent() { const { mutate, error } api.post.create.useMutation(); return ( form onSubmit{(e) { e.preventDefault(); const formData new FormData(e.currentTarget); mutate({ title: formData.get(title) }); }} input nametitle / {error?.data?.zodError?.fieldErrors.title ( {/** mutate 返回时在 title 字段上有错误 */} span classNamemb-8 text-red-500 {error.data.zodError.fieldErrors.title} /span )} ... /form ); }当 mutation 因校验失败返回时error.data.zodError.fieldErrors会携带按字段归类的错误信息你可以直接渲染到对应输入框下方无需自己手写任何错误解析逻辑。脚手架生成了哪些 tRPC 文件逐文件解剖tRPC 依赖 create-t3-app 为你配置的大量模板文件。这些文件由 cli/src/installers/trpc.ts 中的trpcInstaller负责生成。先看它安装了哪些依赖tanstack/react-querysuperjsontrpc/servertrpc/clienttrpc/react-query如果使用 Pages Router还会额外安装trpc/next并生成src/utils/api.ts如果使用 App Router则会额外安装server-only并生成src/trpc/下的多个文件。下面按 Pages Router文档主线逐文件说明。pages/api/trpc/[trpc].ts—— API 入口这是整个 API 的入口负责暴露 tRPC router。模板内容如下cli/template/extras/src/pages/api/trpc/[trpc].tsimport { createNextApiHandler } from trpc/server/adapters/next; import { env } from ~/env; import { appRouter } from ~/server/api/root; import { createTRPCContext } from ~/server/api/trpc; // export API handler export default createNextApiHandler({ router: appRouter, createContext: createTRPCContext, onError: env.NODE_ENV development ? ({ path, error }) { console.error( ❌ tRPC failed on ${path ?? no-path}: ${error.message} ); } : undefined, });平时你几乎不需要改动这个文件。但如果你想启用 CORS 中间件之类的功能需要知道导出的createNextApiHandler本质上是一个接收 request/response 的 Next.js API 处理器因此你可以把它包进任何你想用的中间件里见下文 启用 CORS 示例。onError仅在开发环境下打印详细的失败日志生产环境则静默处理。server/api/trpc.ts—— 上下文与 tRPC 初始化这个文件分为两部分以带认证与数据库的 Pages Router 模板 cli/template/extras/src/server/api/trpc-pages/with-auth-db.ts 为例1. 定义上下文Context。上下文是所有 tRPC 程序都能访问的数据适合放数据库连接、认证信息等。create-t3-app 用两个函数来支持没有 request 对象时使用上下文的子集createInnerTRPCContext定义不依赖请求的上下文比如数据库连接。你可以用它做集成测试或 tRPC 的ssg-helpers——这些场景下没有 request/response 对象。createTRPCContext定义依赖请求的上下文比如用户会话。通过opts.req获取会话后再调用createContextInner构造最终上下文。const createInnerTRPCContext (opts: CreateContextOptions) { return { session: opts.session, db, }; }; export const createTRPCContext async (opts: CreateNextContextOptions) { const { req, res } opts; // Get the session from the server using the getServerSession wrapper function const session await auth(req, res); return createInnerTRPCContext({ session, }); };2. 初始化 tRPC 并定义可复用的 procedure 与 middleware。按惯例你不应该导出整个t对象而是创建好可复用的 procedures 和 middlewares 再导出。模板导出了三类核心构建块createTRPCRouter创建 router / 子 router 的工厂函数。publicProcedure公共程序任何请求都能访问不过已登录用户的会话数据依然可用。它挂载了timingMiddleware——该中间件在开发环境会给每次调用注入 100–500ms 的随机人工延迟Math.floor(Math.random() * 400) 100并在终端打印[TRPC] path took Xms to execute用于暴露本地开发中不会出现的请求瀑布waterfall问题。protectedProcedure受保护程序只有登录用户可访问。它校验ctx.session.user是否存在否则抛出TRPCError({ code: UNAUTHORIZED })通过后会把session的类型收窄为非空保证ctx.session.user.id可用。这个文件里还能看到superjson被用作数据转换器data transformer——它让你的数据类型在到达客户端时保持原样例如后端返回Date对象前端拿到的就是Date而不是大多数 API 会给出的字符串。同时createCallerFactory被导出供 root.ts 构造服务端调用器。server/api/routers/*.ts—— 业务路由定义这里定义你的 API 路由与程序。按惯例为关联的程序创建独立的 router。create-t3-app 会生成一个示例 router见 cli/template/extras/src/server/api/routers/post/with-auth-prisma.tsexport const postRouter createTRPCRouter({ hello: publicProcedure .input(z.object({ text: z.string() })) .query(({ input }) { return { greeting: Hello ${input.text}, }; }), create: protectedProcedure .input(z.object({ name: z.string().min(1) })) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: { name: input.name, createdBy: { connect: { id: ctx.session.user.id } }, }, }); }), getLatest: protectedProcedure.query(async ({ ctx }) { const post await ctx.db.post.findFirst({ orderBy: { createdAt: desc }, where: { createdBy: { id: ctx.session.user.id } }, }); return post ?? null; }), });可以看到hello公共查询、create受保护变更、getLatest受保护查询被组合在同一个 router 中。安装器会根据你选择的组合是否启用 NextAuth/Better Auth、Prisma/Drizzle从base.ts、with-auth.ts、with-prisma.ts、with-drizzle.ts、with-auth-prisma.ts、with-auth-drizzle.ts中选择对应的模板文件。server/api/root.ts—— 合并所有子路由这里把所有routers/**下定义的子路由合并成单个应用 routercli/template/extras/src/server/api/root.tsexport const appRouter createTRPCRouter({ post: postRouter, }); // export type definition of API export type AppRouter typeof appRouter; export const createCaller createCallerFactory(appRouter);新增 router 后必须手动在这里挂载。同时它导出AppRouter类型和createCaller——后者用于服务端直接调用你的 API。utils/api.ts—— 前端入口Pages Router这是 tRPC 的前端入口cli/template/extras/src/utils/api.ts。在这里你 import 路由器的类型定义创建 tRPC 客户端与 react-query hooks。因为后端启用了superjson数据转换器前端也必须启用它——后端序列化的数据要在前端反序列化。export const api createTRPCNextAppRouter({ config() { return { links: [ loggerLink({ enabled: (opts) process.env.NODE_ENV development || (opts.direction down opts.result instanceof Error), }), httpBatchLink({ transformer: superjson, url: ${getBaseUrl()}/api/trpc, }), ], }; }, ssr: false, transformer: superjson, });getBaseUrl会按环境智能取址浏览器端用相对 URLVercel 部署时用VERCEL_URL本地 SSR 用http://localhost:${process.env.PORT ?? 3000}。这里定义了两个tRPC linkhttpBatchLinktRPC 的标准link支持请求批处理把多个请求合并成一次 HTTP 请求。loggerLink开发环境下输出有用的请求日志opts.result instanceof Error时也记录向下的错误响应。最后还会导出两个推断辅助类型RouterInputsinferRouterInputsAppRouter和RouterOutputsinferRouterOutputsAppRouter用于在前端手动推断输入输出类型。App Router 模式下的文件差异如果选择 App Router安装器会走routeHandlerFilesrc/app/api/trpc/[trpc]/route.ts基于fetchRequestHandler与src/trpc/目录见 cli/template/extras/src/app/api/trpc/[trpc]/route.ts。核心差异src/trpc/react.tsx客户端入口cli/template/extras/src/trpc/react.tsx。用createTRPCReactAppRouter()创建api使用httpBatchStreamLink流式批处理并通过TRPCReactProvider组合QueryClientProvider与api.Provider。浏览器端用单例模式复用同一个 query client。src/trpc/server.tsRSCReact Server Component支持cli/template/extras/src/trpc/server.ts。通过createHydrationHelpers导出apiRSC 客户端与HydrateClient配合createCaller在服务端组件中直接调用程序。src/trpc/query-client.ts统一的 query client 工厂cli/template/extras/src/trpc/query-client.ts设置 30 秒默认staleTime避免客户端立即重复请求并用 SuperJSON 负责 dehydration/hydration 的序列化。App Router 下createTRPCContext只接收{ headers: Headers }不再依赖req/res。如何从外部调用你的 API普通 API 可以用curl、Postman、fetch、Insomnia或直接在浏览器里调用端点。tRPC 略有不同——想脱离 tRPC 客户端调用程序官方推荐两条路方案一单独暴露一个程序如果你只想把单个程序暴露出去用服务端调用server-side calls创建一个普通的 Next.js API 端点但复用 tRPC 程序的解析器部分。import { type NextApiRequest, type NextApiResponse } from next; import { appRouter, createCaller } from ../../../server/api/root; import { createTRPCContext } from ../../../server/api/trpc; const userByIdHandler async (req: NextApiRequest, res: NextApiResponse) { // Create context and caller const ctx await createTRPCContext({ req, res }); const caller createCaller(ctx); try { const { id } req.query; const user await caller.user.getById(id); res.status(200).json(user); } catch (cause) { if (cause instanceof TRPCError) { // An error from tRPC occurred const httpCode getHTTPStatusCodeFromError(cause); return res.status(httpCode).json(cause); } // Another error occurred console.error(cause); res.status(500).json({ message: Internal server error }); } }; export default userByIdHandler;关键在于createTRPCContext需要真实请求对象所以这里显式传入{ req, res }随后createCaller(ctx)返回一个可以直接await调用各程序的调用器。tRPC 错误通过getHTTPStatusCodeFromError映射为合适的 HTTP 状态码。方案二把每个程序都暴露为 REST 端点想全量暴露所有程序社区有现成的trpc-openapi插件只要给程序补充少量元数据就能从 tRPC router 生成一套 OpenAPI 兼容的 REST API。补充tRPC 本质就是 HTTP 请求tRPC 通过 HTTP 通信所以理论上也可以用普通 HTTP 请求调用。但受限于 tRPC 使用的 RPC 协议请求/响应的语法相当繁琐。你可以在浏览器的 Network 面板里观察真实的 tRPC 请求长什么样——官方建议仅把它当作学习练习实际开发还是走上面两种推荐方案。与 Next.js API 端点的对比假设要从数据库取一个用户对象返回给前端。用 Next.js API 路由写是这样的import { type NextApiRequest, type NextApiResponse } from next; import { prisma } from ../../../server/db; const userByIdHandler async (req: NextApiRequest, res: NextApiResponse) { if (req.method ! GET) { return res.status(405).end(); } const { id } req.query; if (!id || typeof id ! string) { return res.status(400).json({ error: Invalid id }); } const examples await prisma.example.findFirst({ where: { id, }, }); res.status(200).json(examples); }; export default userByIdHandler;前端还要手写fetch、手动管理状态import { useState, useEffect } from react; import { useRouter } from next/router; const UserPage () { const router useRouter(); const { id } router.query; const [user, setUser] useState(null); useEffect(() { fetch(/api/user/${id}) .then((res) res.json()) .then((data) setUser(data)); }, [id]); };对照本文开头的 tRPC 版本可以清晰看到 tRPC 的优势无需为每条路由拼 URL整个 router 就是一个带自动补全的对象移动文件也不会出现URL 断裂式的调试噩梦。无需手动校验 HTTP 方法query/mutation的语义已经编码在调用方式里。无需在程序里手动校验 query/body 数据Zod 已经接管了这部分。无需手动构造响应像普通 TypeScript 函数一样返回值、抛错误即可。前端调用自带补全与类型安全接口契约由类型系统保证。实用代码片段启用 CORS如果你的 API 需要被不同域名消费——比如在包含 React Native 应用的 monorepo 里——可能就需要启用 CORSimport { type NextApiRequest, type NextApiResponse } from next; import { createNextApiHandler } from trpc/server/adapters/next; import { appRouter } from ~/server/api/root; import { createTRPCContext } from ~/server/api/trpc; import cors from nextjs-cors; const handler async (req: NextApiRequest, res: NextApiResponse) { // Ativar CORS await cors(req, res); // Criar e chamar o handler do tRPC return createNextApiHandler({ router: appRouter, createContext: createTRPCContext, })(req, res); }; export default handler;这正是文档前面强调的createNextApiHandler是一个普通 Next.js API handler可以包进任意中间件——这里把nextjs-cors的cors()与 tRPC handler 组合成了一个自定义 handler。乐观更新Optimistic Updates乐观更新指在 API 调用完成之前就更新 UI让用户不必等待请求结束就能看到自己操作的结果。它适合对实时性要求高的场景如果应用对数据准确性要求极高则应避免使用因为它并不是后端状态的真实反映。const MyComponent () { const listPostQuery api.post.list.useQuery(); const utils api.useContext(); const postCreate api.post.create.useMutation({ async onMutate(newPost) { // Cancele as requisições de saída (para que não substituam nossa atualização otimista) await utils.post.list.cancel(); // Obtenha os dados do queryCache const prevData utils.post.list.getData(); // Atualizar os dados de forma otimista com nosso novo post utils.post.list.setData(undefined, (old) [...old, newPost]); // Retornar os dados anteriores para que possamos reverter se algo der errado return { prevData }; }, onError(err, newPost, ctx) { // Se a mutation falhar, usar o valor de contexto de onMutate utils.post.list.setData(undefined, ctx.prevData); }, onSettled() { // Sincronizar com o servidor assim que a mutação for estabelecida utils.post.list.invalidate(); }, }); };三段钩子的分工很清晰onMutate取消进行中的列表请求并乐观写入新数据同时缓存旧数据用于回滚onError在失败时用onMutate返回的ctx.prevData还原onSettled在请求尘埃落定后invalidate()使列表失效与服务器重新同步。集成测试示例下面的集成测试用 Vitest 验证 router 是否按预期工作、输入解析器是否推断出正确类型、返回数据是否符合预期输出import { type inferProcedureInput } from trpc/server; import { expect, test } from vitest; import { appRouter, type AppRouter } from ~/server/api/root; import { createInnerTRPCContext } from ~/server/api/trpc; test(example router, async () { const ctx await createInnerTRPCContext({ session: null }); const caller appRouter.createCaller(ctx); type Input inferProcedureInputAppRouter[example][hello]; const input: Input { text: test, }; const example await caller.example.hello(input); expect(example).toMatchObject({ greeting: Hello test }); });注意这里用的是createInnerTRPCContext而不是createTRPCContext——因为测试环境没有真实的 request 对象这正是模板把上下文拆成 inner/outer 两层的意义所在。如果程序受保护需要登录构造上下文时传入 mock 的 session 即可test(protected example router, async () { const ctx await createInnerTRPCContext({ session: { user: { id: 123, name: John Doe }, expires: 1, }, }); const caller appRouter.createCaller(ctx); // ... });进一步阅读tRPC 官方文档与大量可运行的示例代码可在 tRPC 官方仓库的examples目录中查阅。React Query 官方文档中关于乐观更新的章节适合深入理解onMutate/onError/onSettled的生命周期。本仓库中 tRPC 安装与模板相关的核心文件cli/src/installers/trpc.ts安装与文件生成逻辑、cli/template/extras/src/utils/api.tsPages Router 前端入口、cli/template/extras/src/server/api/trpc-pages/with-auth-db.ts服务端上下文与初始化、cli/template/extras/src/server/api/root.ts路由合并。【免费下载链接】create-t3-appThe best way to start a full-stack, typesafe Next.js app项目地址: https://gitcode.com/gh_mirrors/cr/create-t3-app创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表