tRPC 路由懒加载实战:examples/lazy-load 示例详解与 lazy() 函数源码解析
本文以 tRPC 仓库中的 examples/lazy-load 示例为核心,完整讲解如何用 lazy() 函数动态加载路由模块:从示例的目录结构、完整运行命令(dev/build/start)、端到端类型推导的客户端写法,到 @trpc/server 内部 router.ts 中 lazy() 的实现细节。读完本文,你将掌握 tRPC 路由懒加载的两种声明写法、其降低冷启动收益的原理,以及该能力在源码层面的判定与错误处理机制。
示例定位:一个最小可用的懒加载 tRPC 工程
examples/lazy-load/README.md 将该工程描述为 "A minimal working tRPC example"(一个最小可运行的 tRPC 示例),并明确了运行前提:需要 Node 18 及以上(因为依赖全局 fetch API)。示例的常规工作流在 README 中给出:
npm i
npm run dev
README 还提示可以随意编辑其中的 TypeScript 文件来体验端到端类型检查:例如重命名某个变量,前后端会同步报错或更新。构建与生产启动流程为:
npm run build
npm run start
从 package.json 的 scripts 字段可以看到这些命令的完整定义,比 README 提供的信息更具体:
build:tsc,将 TypeScript 编译到dist目录;dev:server:tsx watch src/server,用 tsx 以 watch 模式启动服务端;dev:client:wait-port 3000 && tsx watch src/client,等待 3000 端口就绪后再启动客户端;dev:run-p dev:* --print-label,并行执行上面两个 dev 任务;test-dev:start-server-and-test 'tsx src/server' 3000 'tsx src/client',以源码方式启动服务端并等待端口后运行客户端作为端到端验证;test-start:start-server-and-test 'node dist/server' 3000 'node dist/client',先build再以编译产物方式做同样的验证。
依赖上,示例只声明了 @trpc/client、@trpc/server 和 zod 三个运行时依赖(版本锁定为工作区内的 npm:@trpc/client / npm:@trpc/server),开发依赖则包含 tsx、typescript、npm-run-all、wait-port 与 start-server-and-test,这是一个刻意保持最小化的工程。
目录结构与服务端懒加载写法
示例的源码布局如下:
- src/server/index.ts:服务入口,基于 standalone 适配器创建 HTTP 服务并监听 3000 端口;
- src/server/trpc.ts:
initTRPC.create()初始化,导出可复用的router与publicProcedure; - src/server/routers/_app.ts:应用根路由,懒加载演示的核心所在;
- src/server/routers/user.ts:包含
list/byId/create三个 procedure 的 user 路由; - src/server/routers/slow.ts:模拟加载耗时 3 秒的慢路由;
- src/server/db.ts:一个假想的内存数据库;
- src/client/index.ts:客户端,仅从服务端导入
AppRouter类型。
根路由 _app.ts 是理解懒加载的关键,完整代码如下:
import { lazy } from '@trpc/server';
import { router } from '../trpc.js';
export const appRouter = router({
user: lazy(() => import('./user.js')),
// Alternative way to lazy load
slow: lazy(() => import('./slow.js')),
});
export type AppRouter = typeof appRouter;
这段代码展示了 lazy() 的两种使用形态:
- 直接传入动态 import 的结果:
lazy(() => import('./user.js'))。只要目标模块满足导出约束(见下文源码分析),lazy()会自动从中解析出路由; - 显式解出命名导出的路由:注释中标注为 "Alternative way to lazy load" 的写法,官方文档中的完整形态是
lazy(() => import('./user.js').then((m) => m.userRouter)),即通过.then明确指定要取哪个导出。这在模块中导出了多个值时是必须的写法。
两个被懒加载的路由模块各有教学目的:
- user.ts 在模块顶层执行
console.log('💤 Lazy loading user router...'),用于直观验证"模块只在被首次访问时才会被真正 import 执行";其byId与createprocedure 均使用z.string()、z.object({ name: z.string() })做输入校验,操作 db.ts 中基于内存数组的假想用户表(findMany/findById/create)。 - slow.ts 在模块顶层执行
await new Promise((resolve) => setTimeout(resolve, 3000)),人为模拟一个加载耗时 3 秒的路由模块,用于观察首次请求slow.hello时服务端先触发模块加载、后续请求则直接命中的行为差异。
服务端入口 index.ts 保持最小化:createHTTPServer({ router: appRouter }) 之后 server.listen(3000),与懒加载本身无关——懒加载完全发生在 appRouter 的组装层,对 HTTP 适配层透明。
客户端:懒加载不影响端到端类型推导
client/index.ts 的关键点在于:懒加载是纯服务端概念,客户端的静态类型不因此打任何折扣。
import { createTRPCClient, httpBatchLink } from '@trpc/client';
// We only import the `AppRouter` type from the server - this is not available at runtime
import type { AppRouter } from '../server/routers/_app.js';
const trpc = createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: 'http://localhost:3000' })],
});
const users = await trpc.user.list.query();
const createdUser = await trpc.user.create.mutate({ name: 'sachinraja' });
const user = await trpc.user.byId.query('1');
const hello = await trpc.slow.hello.query();
客户端通过 import type 只导入 AppRouter 类型(运行时不存在这份引用),随后 trpc.user.list、trpc.slow.hello 全部具备完整的入参/出参推断。也就是说,lazy() 在类型层面必须让 typeof appRouter "看起来像"一个同步组装完成的普通路由——否则 trpc.slow.hello 这类点链调用在编译期就无法通过。README 中"Try editing the ts files to see the type checking in action"的体验(悬停看类型、Cmd/Ctrl+click 跳转定义、跨前后端重命名同步报错)正是建立在这一保证之上。
源码解析:lazy() 如何实现"类型同步、运行时延迟"
lazy 函数由 @trpc/server 包导出,其实现位于 router.ts。先看不涉及类型擦除的核心逻辑:
export function lazy<TRouter extends AnyRouter>(
importRouter: () => Promise<
| TRouter
| {
[key: string]: TRouter;
}
>,
): Lazy<NoInfer<TRouter>> {
async function resolve(): Promise<TRouter> {
const mod = await importRouter();
// if the module is a router, return it
if (isRouter(mod)) {
return mod;
}
const routers = Object.values(mod);
if (routers.length !== 1 || !isRouter(routers[0])) {
throw new Error(
"Invalid router module - either define exactly 1 export or return the router directly.\nExample: `lazy(() => import('./slow.js').then((m) => m.slowRouter))`",
);
}
return routers[0];
}
(resolve as Lazy<TRouter>)[lazyMarker] = true as const;
return resolve as Lazy<TRouter>;
}
从源码可以确认以下行为:
-
参数签名即契约。
lazy接收一个返回Promise的函数,Promise 的载荷允许是"路由本身"或"恰好导出一个路由的模块对象"。这解释了示例中lazy(() => import('./user.js'))为何成立——user.ts的唯一导出就是userRouter;而slow.ts同样只有slowRouter一个导出。 -
模块解析策略。
resolve()先执行isRouter(mod)判断模块是否本身就是路由;若不是,则取Object.values(mod),要求"有且仅有一个导出,且它是路由",否则抛出带示例代码的错误,明确指引开发者改用.then((m) => m.router)的显式写法。 -
lazyMarker 品牌标记。文件顶部定义了带
__brand的常量标记(router.ts#L80-L83):const lazyMarker = 'lazyMarker' as 'lazyMarker' & { __brand: 'lazyMarker' }; export type Lazy<TAny> = (() => Promise<TAny>) & { [lazyMarker]: true };lazy()返回前会给resolve函数打上[lazyMarker] = true标记,配合isLazy()类型守卫(router.ts#L137-L139)供路由工厂在构建时识别"这是一个待加载项,而非已就绪的路由"。 -
路由定义中懒加载项的存储形态。
RouterDef接口中包含lazy: Record<string, LazyLoader<AnyRouter>>字段(router.ts#L153),其中LazyLoader带有load: () => Promise<void>与ref两个成员(router.ts#L85-L88)。从这一结构可以推断:路由工厂不会把懒加载项直接并入 procedure 树,而是单独维护一份"名称 → 加载器"的映射,在真正需要访问该命名空间时才触发load()并挂载解析结果——这正是"首次请求触发模块执行、后续请求复用"的运行时基础,也解释了slow.ts顶层 3 秒延时只会在第一次访问slow.hello时出现的原因。 -
类型层的同步化。
lazy()的返回类型Lazy<NoInfer<TRouter>>携带完整的路由类型参数,因此appRouter的AppRouter类型中user、slow两个命名空间的 procedure 签名与同步内联组装的路由完全一致——客户端的trpc.slow.hello.query()能在编译期通过,根源在此。
此外,同文件中还定义了 once() 工具函数(router.ts#L90-L99),用于缓存函数结果、保证副作用只执行一次;从源码结构看它是该模块中可复用的"只执行一次"原语,与懒加载模块的加载时机控制属于同一文件内的配套机制。
配套文档与验证方式
- 官方文档中该主题位于 merging-routers.md 的 "Dynamically load routers" 小节(源码注释中同样引用了这一文档锚点)。文档明确指出懒加载的适用场景是 减少应用冷启动耗时,并强调"懒加载的路由在加载完成后与普通路由的使用方式没有任何区别",与示例中客户端无任何特殊处理的写法相互印证。文档中给出的等价写法
user: lazy(() => import('./user.js').then((m) => m.userRouter))即示例注释 "Alternative way to lazy load" 所指的形态。 - 本地验证:在
examples/lazy-load目录下执行npm i && npm run dev,观察服务端日志中两条 "💤 Lazy loading ..." 输出的出现时机——它们只会在客户端首次调用对应路由(trpc.user.list.query()/trpc.slow.hello.query())时打印;slow.hello首次调用可观察到约 3 秒的模块加载等待。构建后的验证方式为npm run build后执行npm run test-start(源码模式则用npm run test-dev),两者均由start-server-and-test串联服务启动、端口探测与客户端执行。
小结
examples/lazy-load 用极小的工程规模完整演示了 tRPC 路由懒加载的全貌:服务端用 lazy(() => import('./xxx.js')) 声明动态路由,客户端对懒加载完全无感知且保留端到端类型推导;源码层面则由 router.ts 中的 lazy()、lazyMarker 品牌标记与 LazyLoader 映射共同支撑"类型同步、运行时延迟加载"的语义。当应用的路由树庞大、存在明显的冷启动诉求(如函数计算、Serverless 场景)时,这是官方文档推荐的拆分手段;而单模块导出约束、显式 .then 解包写法以及模块顶层副作用只在首次访问时执行的特性,是落地时最值得注意的三个细节。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00