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,其关键行为如下:
- 记录合并:先取每个参与合并路由的
_def.record,通过mergeWithoutOverrides合并。该工具函数(utils.ts)在遇到重复键且值不同时会直接抛出Duplicate key <key>,从而防止合并后过程名冲突被静默吞掉;若两侧是同一个引用则允许通过。这一规则对应文档中"需要为 procedure 起全局唯一名字"的隐含约束。 - errorFormatter 协商:逐项检查参与者的 errorFormatter,若出现多个互不相同且非默认的 formatter,抛错
You seem to have several error formatters。 - transformer 协商:逻辑与 formatter 一致,冲突时抛错
You seem to have several transformers。这意味着参与合并的各 router 最好来自配置相同的 tRPC 实例。 - 合并结果重新走
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),仅供参考