完整实战:子路由命名空间与 mergeRouters 的原理与取舍)
tRPC v10 路由合并Merging Routers完整实战子路由命名空间与 mergeRouters 的原理与取舍【免费下载链接】trpc♀️ Move Fast and Break Nothing. End-to-end typesafe APIs made easy.项目地址: https://gitcode.com/GitHub_Trending/tr/trpc把全部 API 代码堆在同一个文件里不利于长期维护。tRPC 提供了将多个独立 Router 组合成根 RouterappRouter的机制让你可以按业务域拆分代码同时端到端类型安全不受任何损失。本指南以 v10.x 版本文档 为骨架讲解「子路由嵌套」与t.mergeRouters两种官方合并方式并结合当前仓库的 服务端核心实现 与测试用例帮助你理解底层展平机制、命名冲突规则以及如何做出正确的架构取舍。读完你将能独立搭建多模块、可维护、类型完整的 tRPC 路由分层结构。为什么需要合并 RoutertRPC 中一次initTRPC.create()会返回一个根对象tt.router()用于声明一组 procedure查询、变更、订阅以及嵌套的子 Router。随着业务增长把用户、文章、订单等所有 procedure 写进同一个文件会迅速变得不可维护。官方文档给出的解决思路非常直接Router 之间可以互相嵌套、互相合并从而把代码按领域拆分到不同文件最终在根入口通常命名为_app.ts统一组装成AppRouter并导出其类型。当前仓库中几乎每个完整示例都遵循这一模式例如 next-prisma-starter 的根路由// examples/next-prisma-starter/src/server/routers/_app.ts import { createCallerFactory, publicProcedure, router } from ../trpc; import { postRouter } from ./post; export const appRouter router({ healthcheck: publicProcedure.query(() yay!), post: postRouter, }); export const createCaller createCallerFactory(appRouter); export type AppRouter typeof appRouter;注意它既可以直接在根层级挂一个 procedurehealthcheck也可以把另一个独立 Router 作为子路由挂进来post: postRouter——这正是官方文档推荐的文件拆分 根路由汇总方式。合并方式一通过子路由命名空间嵌套Child Routers最直观的方式是每个业务模块导出一个自己的 Router然后在根 Router 中以对象属性的形式把它们挂载到不同的命名空间下。第一步初始化 tRPC 并在模块内导出构造器不同文件都需要使用同一个t实例创建的router与publicProcedure因此通常把它们集中在一个共享文件如routers/trpc.ts导出// routers/trpc.ts import { initTRPC } from trpc/server; const t initTRPC.create(); export const router t.router; export const publicProcedure t.procedure;第二步分别定义业务模块 Routeruser模块提供查询用户列表的 procedure// routers/user.ts import { router, publicProcedure } from ../trpc; import { z } from zod; export const userRouter router({ list: publicProcedure.query(() { // [..] return []; }), });post模块同时提供创建与列表两种 procedure并用 zod 声明输入// routers/post.ts import { router, publicProcedure } from ../trpc; import { z } from zod; export const postRouter router({ create: publicProcedure .input( z.object({ title: z.string(), }), ) .mutation((opts) { const { input } opts; // 此处 input 类型已被推断为 { title: string } // [...] }), list: publicProcedure.query(() { // ... return []; }), });第三步在_app.ts中组装根路由// routers/_app.ts import { router } from ../trpc; import { z } from zod; import { userRouter } from ./user; import { postRouter } from ./post; const appRouter router({ user: userRouter, // put procedures under user namespace post: postRouter, // put procedures under post namespace }); export type AppRouter typeof appRouter;把子 Router 挂到user/post键下所有 procedure 便进入对应命名空间。客户端的访问路径与 HTTP 端点也同步带上前缀正如文档注释所提示的http://localhost:3000/trpc/user.list // 调用 userRouter.list http://localhost:3000/trpc/post.create // 调用 postRouter.create http://localhost:3000/trpc/post.list // 调用 postRouter.list即 URL 与类型路径都遵循NAMESPACE.PROCEDURE的点分命名规则。因为AppRouter是typeof appRouter导入该类型后客户端在trpc.user.list、trpc.post.create等调用点上会自动获得输入与输出的完整类型推断无需任何手动类型标注。子路由同样支持更深的嵌套从当前仓库的RouterRecord类型定义router.ts可以看到Router 的键值既可以是一个 procedure也可以是另一个 Router 记录因此嵌套不限于一层。仓库中的 express-minimal 示例 展示了即使不单独建 Router也能在router()中直接书写对象字面量来完成分组// examples/express-minimal/src/router.ts import { initTRPC } from trpc/server; import { z } from zod; const t initTRPC.create(); const publicProcedure t.procedure; const router t.router; export const appRouter router({ hello: { greeting: publicProcedure .input(z.object({ name: z.string() }).nullish()) .query(({ input }) { return Hello ${input?.name ?? World}; }), }, }); export type AppRouter typeof appRouter;这种写法下hello下分组键在底层同样会被展开为点分路径hello.greeting证明Router 记录本身是一棵可递归的对象树这一设计思想。合并方式二使用t.mergeRouters做扁平合并如果你希望所有 procedure平铺在同一个命名空间下而不是带user.、post.前缀官方提供了第二种合并 APIt.mergeRouters。第一步导出mergeRouters// routers/trpc.ts import { initTRPC } from trpc/server; const t initTRPC.create(); export const router t.router; export const publicProcedure t.procedure; export const mergeRouters t.mergeRouters;第二步模块 Router 使用带前缀的 procedure 名因为合并后所有 procedure 处于同一层级为避免将来键名冲突官方示例约定在定义时就为 procedure 加上模块前缀如userList、postCreate、postList// routers/user.ts import { router, publicProcedure } from ../trpc; import { z } from zod; export const userRouter router({ userList: publicProcedure.query(() { // [..] return []; }), });// routers/post.ts import { router, publicProcedure } from ../trpc; import { z } from zod; export const postRouter router({ postCreate: publicProcedure .input( z.object({ title: z.string(), }), ) .mutation((opts) { const { input } opts; // [...] }), postList: publicProcedure.query(() { // ... return []; }), });第三步合并进单一命名空间// routers/_app.ts import { router, publicProcedure, mergeRouters } from ../trpc; import { z } from zod; import { userRouter } from ./user; import { postRouter } from ./post; const appRouter mergeRouters(userRouter, postRouter); export type AppRouter typeof appRouter;合并后的调用路径不再有中间命名空间例如trpc.userList trpc.postCreate trpc.postList对应 HTTP 端点则约简为http://localhost:3000/trpc/userList、http://localhost:3000/trpc/postList这类平铺路径。同时mergeRouters会把每个子 Router 内部原有的点分路径打平后组合因此在类型层面AppRouter依然是完整可推断的。两种方式如何取舍维度子路由嵌套Child Routerst.mergeRouters扁平合并调用路径trpc.user.list带命名空间trpc.userList平铺键名冲突风险低各模块天然隔离在不同命名空间高需靠模块前缀约定规避重名代码拆分按业务域自然分组仍可按文件拆分但键名须全局唯一根路由可读性一眼看出模块边界扁平适合 procedure 数量可控的中小规模典型场景大型、多域项目procedure 名全局唯一、偏好平铺路径的项目需要特别说明的是mergeRouters 并不会自动为你去重。根据 mergeWithoutOverrides 的实现当后合并的记录中出现同名但不同值的键时会直接抛出Duplicate key X错误只有当同名键的值完全相同时才被允许。这意味着跨模块重名 procedure 在构建期就会被发现并抛错而不是悄悄覆盖——这就是扁平合并要求你自行维护前缀约定的原因。底层原理Router 在运行时如何展平理解两种方式的关键在于 tRPC 服务端在构建 Router 时会把嵌套结构展平为一张点分路径表。以当前仓库 router.ts 中的createRouterFactory为例router({...})内部的step()会递归遍历传入的记录遇到普通对象/嵌套 Router 就继续下钻遇到 procedure一个函数就以完整点分路径如user.list、post.create为键登记到_def.procedures表中因此子路由嵌套与普通对象分组最终在运行时并无本质差别都会被抹平成点分路径mergeRouters则先把各 Router 的_def.record用mergeWithoutOverrides合并成一个扁平记录再交给同一个createRouterFactory走一遍展平逻辑。从结构上还可以推断_def.procedures点分路径 → procedure、_def.record保留原始嵌套结构的记录、_def.lazy懒加载表共同构成了一个 Router 的运行时全貌。HTTP 适配层、createCaller等服务端调用路径最终都通过点分路径在该表中查找并执行对应 procedure。构建期冲突检测在createRouterFactory的step()内部router.ts如果同一路径被登记两次会抛出Duplicate key: ${path}错误。这样类似通过对象展开合并两个各含同名 procedure 的记录这类做法会在启动时被尽早拦截。保留字限制createRouterFactory还维护了一份保留字表then、call、applyrouter.ts。源码注释给出的原因很直白then被保留是因为返回的 Proxy 会被 JavaScript 当作 Promise 进行.then探测call与apply被保留是为了避免方法调用语义被劫持。因此不要把顶层 procedure 或子路由命名为这三个保留字否则router({...})会抛出Reserved words used in router({}) call错误。mergeRouters 的配置一致性校验mergeRouters在合并多个 Router 时还会校验底层配置的一致性router.tserrorFormatter当多个 Router 携带了不同的非默认errorFormatter 时抛出You seem to have several error formatterstransformer同理多个不同的非默认 transformer 会抛出You seem to have several transformers只有一个 Router 配置了非默认值或值彼此相同则合并成功$types取自被合并的第一个 Router因此实践中要求被合并的 Router 都来自同一套t实例同一 context / meta 配置以保证类型一致。这些规则在仓库的单元测试 router.mergeRouters.test.ts 中均有对应的正反例验证合并两个普通 Router 后可分别调用caller.foo()与caller.bar()用对象展开...router1, ...router2再交给router()也能得到等价合并结果一方使用默认 errorFormatter / transformer 时合并成功两方使用不同自定义 errorFormatter / transformer 时抛出对应错误测试以快照形式断言了错误消息文本。工程实践推荐的文件组织与要点综合官方文档与仓库示例如 next-prisma-starter、fastify-server一个可长期维护的组织方式如下src/server/ ├─ trpc.ts # initTRPC 初始化导出 router / publicProcedure / mergeRouters / createCallerFactory ├─ routers/ │ ├─ _app.ts # 根路由组装 appRouter 并导出 AppRouter 类型 │ ├─ user.ts # 业务模块 A │ └─ post.ts # 业务模块 B几点实践建议initTRPC每个应用只初始化一次。仓库示例中普遍把基于同一个t的router、procedure、mergeRouters、createCallerFactory统一再导出参见 next-prisma-starter 的 trpc.ts既便于复用也从源头保证所有模块共享同一套根配置。模块内尽量独立、自包含每个模块 Router 只依赖共享的trpc.ts不要跨模块互相引用避免循环依赖。根路由中既可以挂子 Router也可以直接放少量全局 procedure如健康检查healthcheck混合使用完全合法。选择合并方式后保持一致若走mergeRouters扁平路线务必按模块统一给 procedure 加前缀若走嵌套路线命名空间本身就是冲突隔离层。延伸阅读本主题的 v11 版本文档 在两种合并方式之外还增加了基于lazy(() import(...))的动态懒加载 Router用于削减冷启动时间仓库对应示例见 examples/lazy-load/src/server/routers/_app.ts本仓库源码 router.ts 中的lazy函数即其实现。服务端核心实现与类型定义见 router.tst.mergeRouters的暴露位置在 initTRPC.ts。合并相关的行为与冲突测试见 router.mergeRouters.test.ts。服务端如何在非 HTTP 场景下直接调用合并后的路由可研究根路由_def.procedures点分路径表与createCallerFactoryrouter.ts的配合关系。【免费下载链接】trpc♀️ Move Fast and Break Nothing. End-to-end typesafe APIs made easy.项目地址: https://gitcode.com/GitHub_Trending/tr/trpc创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考