☰
tRPC 路由拆分与合并完全指南:子路由嵌套、mergeRouters 与 lazy 按需加载
2026/10/10 16:44:20 网站建设 项目流程

tRPC 路由拆分与合并完全指南:子路由嵌套、mergeRouters 与 lazy 按需加载

【免费下载链接】trpc🧙‍♀️ Move Fast and Break Nothing. End-to-end typesafe APIs made easy.项目地址: https://gitcode.com/GitHub_Trending/tr/trpc

本文将系统讲解 tRPC 服务端 router 的三种组织形态:把功能拆成独立子路由后用嵌套方式组合、用t.mergeRouters合并出扁平命名空间,以及用lazy()实现路由的按需动态加载以降低应用冷启动成本。读完你不仅会写出清晰可维护的多文件路由结构,还能理解合并与懒加载在 @trpc/server 内部究竟如何工作。

随着接口数量增长,把所有 API 代码写在同一个文件里会变得难以维护(原文语)。tRPC 提供了多套互补的组合机制,让你既能按业务模块拆分代码,又能保持端到端类型安全。本文以 merging-routers 官方文档为骨架,结合 packages/server 源码与 examples/lazy-load 完整示例逐层展开。

基础:从统一的 tRPC 实例导出构建工具

拆分路由的前提是"一个后端只初始化一次initTRPC",然后从这唯一实例导出router、publicProcedure等构建工具,供所有子路由文件复用(关于初始化与创建 router 的基础,可参见 routers.md 与 procedures.md):

// 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;

从源码看,initTRPC.create()返回的对象同时挂载了router、mergeRouters、procedure、middleware、createCallerFactory等构建单元,见 initTRPC.ts。router与mergeRouters的注释都指向merging-routers文档,说明拆分合并正是这两个 API 的核心职责。后续所有子路由都从../trpc导入同一批工具,从而保证配置、errorFormatter、transformer 的一致性。

方式一:把子路由作为命名空间嵌套进根路由

第一种拆分思路是"按模块建路由,再按命名空间挂在根路由上"。先分别定义两个子路由,例如用户模块与文章模块:

// routers/user.ts import { router, publicProcedure } from '../trpc'; export const userRouter = router({ list: publicProcedure.query(() => { // [..] return []; }), });
// 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; // [...] }), list: publicProcedure.query(() => { // ... return []; }), });

然后在根路由文件里把这两个子路由作为普通值传给router(),用键名充当命名空间:

// routers/_app.ts import { router } from '../trpc'; import { userRouter } from './user'; import { postRouter } from './post'; const appRouter = router({ user: userRouter, post: postRouter, }); appRouter.user // ^? 类型提示:嵌套的 user 命名空间下的全部 procedure appRouter.post // ^? 类型提示:嵌套的 post 命名空间下的全部 procedure export type AppRouter = typeof appRouter;

嵌套方式背后的"展平"逻辑

嵌套并不产生真正的"路由器对象套路由器",而是被router()内部结构性地展平。查看 router.ts 中的类型定义可以发现:当一个选项值$Value extends Router<any, infer TRecord>时,它会被解包为TRecord;procedure 则保留自身。因此router({ user: userRouter })产生的 record 结构形如{ user: { list: procedure } }。

运行时的step()递归(router.ts)会把所有 procedure 以key1.key2...的点号路径登记进统一的_def.procedures注册表(如user.list、post.create),这也是 HTTP 请求中 procedure 路径的来源。可以推断,客户端调用时就会呈现出与命名空间一致的层级:trpc.user.list、trpc.post.create。同时 router.ts 规定then、call、apply是保留字,不能用作 router 或 procedure 的名字,因为then会破坏 Promise 化、call/apply会影响函数调用语义。

方式二:用t.mergeRouters合并成单层扁平命名空间

如果你更希望所有 procedure 都平铺在同一个顶层命名空间下,可以使用t.mergeRouters。注意子路由内部的过程名此时必须自解释(如userList、postCreate),因为合并后不再有父级命名空间来区分归属:

// routers/user.ts import { router, publicProcedure } from '../trpc'; 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 { mergeRouters } from '../trpc'; import { userRouter } from './user'; import { postRouter } from './post'; const appRouter = mergeRouters(userRouter, postRouter); // ^? 类型提示:合并后平铺的 procedure 集合 export type AppRouter = typeof appRouter;

运行时发生了什么:配置协商与冲突检测

t.mergeRouters的实现位于 router.ts,其关键行为如下:

  1. 记录合并:先取每个参与合并路由的_def.record,通过mergeWithoutOverrides合并。该工具函数(utils.ts)在遇到重复键且值不同时会直接抛出Duplicate key <key>,从而防止合并后过程名冲突被静默吞掉;若两侧是同一个引用则允许通过。这一规则对应文档中"需要为 procedure 起全局唯一名字"的隐含约束。
  2. errorFormatter 协商:逐项检查参与者的 errorFormatter,若出现多个互不相同且非默认的 formatter,抛错You seem to have several error formatters。
  3. transformer 协商:逻辑与 formatter 一致,冲突时抛错You seem to have several transformers。这意味着参与合并的各 router 最好来自配置相同的 tRPC 实例。
  4. 合并结果重新走createRouterFactory:把mergeWithoutOverrides得到的新 record 交给路由工厂重建出一个全新的BuiltRouter,因此合并产物的类型与运行时都和一个原生创建的 router 完全一致(isDev、isServer、allowOutsideOfServer取所有参与者的"与")。

以上合并与冲突行为都有对应测试用例佐证,见 router.mergeRouters.test.ts:其中验证了正常合并后caller.foo()/caller.bar()都能调用、仅一方带自定义 formatter/transformer 时可成功合并,而双方各带不同 formatter 或 transformer 时分别抛出上述两条错误。

两种方式怎么选

  • 嵌套(router({ user, post })):保留业务命名空间,procedure 天然分组,路径带前缀,适合模块间边界清晰的团队协作,客户端调用是user.list这类层级路径。
  • mergeRouters(t.mergeRouters):所有 procedure 平铺在单层命名空间,路径更短,但要求过程名全局不重复。

两者的类型都是完全端到端推导的,写法偏好而已,不影响客户端类型安全。

方式三:lazy动态加载子路由

如果某些路由体积大、初始化开销高,可以用lazy把它们"延迟到首次被访问时才加载"。这在减少应用冷启动开销时很有用(原文语:reduce cold starts);懒加载完成之后,路由的使用方式与普通路由没有任何区别。lazy从@trpc/server顶层导出(见 @trpc/server/index.ts),是服务器包面向用户的公开 API。

沿用前文的初始化代码,先定义两个普通的子路由:

// routers/greeting.ts import { router, publicProcedure } from '../trpc'; export const greetingRouter = router({ hello: publicProcedure.query(() => 'world'), });
// routers/user.ts import { router, publicProcedure } from '../trpc'; export const userRouter = router({ list: publicProcedure.query(() => ['John', 'Jane', 'Jim']), });

接着在根路由中用lazy包装动态import():

// routers/_app.ts import { lazy } from '@trpc/server'; import { router } from '../trpc'; export const appRouter = router({ // Option 1: 模块恰好只导出 1 个 router 时的简写 greeting: lazy(() => import('./greeting.js')), // Option 2: 模块导出多个 router 时,用 .then 指明取哪一个 user: lazy(() => import('./user.js').then((m) => m.userRouter)), }); export type AppRouter = typeof appRouter;

lazy 的判定规则

lazy的函数签名与运行逻辑见 router.ts。加载函数被调用后:

  • 若importRouter()直接解析出一个 router(例如用了.then((m) => m.xxxRouter)),直接返回它;
  • 否则把模块视为"导出表",Object.values后要求恰好只有 1 个导出且该导出是 router,否则抛出错误:Invalid router module - either define exactly 1 export or return the router directly。

因此 Option 1 的简写成立的前提,正是模块文件里只export了一个 router。

懒加载在框架内部如何生效

在 router.ts 的step()里,isLazy(item)的值不会立即加载,而是被登记进_def.lazy(键为完整点号路径)并生成一个createLazyLoader;其load()用once()做了记忆化(router.ts),保证模块只会被真实加载一次,加载完成后把该模块的 record 递归合并进总 record,并把其内部嵌套的懒加载项继续注册。

真正"按需触发"的地方是getProcedureAtPath(router.ts):当某个点号路径在_def.procedures中找不到时,它会查找第一个匹配该路径前缀的 lazy 键,await lazyRouter.load()后再查一次。由于HTTP 分发路径与createCaller(服务端直调)都经由getProcedureAtPath解析 procedure(见 router.ts 与 router.ts),可以推断:无论请求来自网络还是同一进程内的服务端调用,懒加载对两者都一致生效。类型层面,AppRouter = typeof appRouter的推导依赖lazy的泛型参数,故客户端拿到的是完整类型而无需感知懒加载。

仓库中的完整可运行示例见 examples/lazy-load,其 routers/_app.ts 用lazy同时挂载了user与slow(模拟慢速模块)两个子路由,trpc.ts 负责初始化与导出,适合作为对照实现的参考。

使用注意事项

  • lazy依赖运行时动态import(),在打包环境中应配合支持代码分割(code-splitting)的构建器使用,才能把子路由拆成独立 chunk 并在真需要时才发起加载。
  • 懒加载之后的路由调用方式与普通路由完全一致(文档明确说明 no difference),因此对调用方透明,迁移成本低。
  • 若一个模块导出多个 router,必须用.then((m) => m.routerName)显式指定,否则会触发上文提到的校验错误。

小结

拆分布局不是玄学,而是由清晰的 API 支撑的结构化实践:

组合手段结果形态适用场景类型/运行约束
router({ user: userRouter, post: postRouter })带命名空间的层级路径模块边界清晰、希望路径自带归属procedure 路径为namespace.method;避免保留字键名
t.mergeRouters(r1, r2, ...)单层扁平命名空间希望所有过程平铺、短路径顶层过程名不得重复;冲突的 formatter/transformer 会抛错
router({ x: lazy(() => import(...)) })按需加载的子路由降低冷启动/首包体积模块需恰好导出 1 个 router,或用.then显式挑选

具体到实现层面,三种方式的底层都收敛到 router.ts 中的同一套"record + 点号路径注册表"模型:嵌套与mergeRouters只是以不同方式组装 record,lazy则把 record 的展开推迟到首次访问。理解这一点后,你可以放心地按照业务模块拆分文件、自由组合命名空间,同时保持 tRPC 一贯的端到端类型安全。

【免费下载链接】trpc🧙‍♀️ Move Fast and Break Nothing. End-to-end typesafe APIs made easy.项目地址: https://gitcode.com/GitHub_Trending/tr/trpc

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询