首页
/ tRPC 路由懒加载实战:examples/lazy-load 示例详解与 lazy() 函数源码解析

tRPC 路由懒加载实战:examples/lazy-load 示例详解与 lazy() 函数源码解析

2026-09-05 20:40:53作者:秋阔奎Evelyn

本文以 tRPC 仓库中的 examples/lazy-load 示例为核心,完整讲解如何用 lazy() 函数动态加载路由模块:从示例的目录结构、完整运行命令(dev/build/start)、端到端类型推导的客户端写法,到 @trpc/server 内部 router.tslazy() 的实现细节。读完本文,你将掌握 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.jsonscripts 字段可以看到这些命令的完整定义,比 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/serverzod 三个运行时依赖(版本锁定为工作区内的 npm:@trpc/client / npm:@trpc/server),开发依赖则包含 tsxtypescriptnpm-run-allwait-portstart-server-and-test,这是一个刻意保持最小化的工程。

目录结构与服务端懒加载写法

示例的源码布局如下:

根路由 _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() 的两种使用形态:

  1. 直接传入动态 import 的结果lazy(() => import('./user.js'))。只要目标模块满足导出约束(见下文源码分析),lazy() 会自动从中解析出路由;
  2. 显式解出命名导出的路由:注释中标注为 "Alternative way to lazy load" 的写法,官方文档中的完整形态是 lazy(() => import('./user.js').then((m) => m.userRouter)),即通过 .then 明确指定要取哪个导出。这在模块中导出了多个值时是必须的写法。

两个被懒加载的路由模块各有教学目的:

  • user.ts 在模块顶层执行 console.log('💤 Lazy loading user router...'),用于直观验证"模块只在被首次访问时才会被真正 import 执行";其 byIdcreate procedure 均使用 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.listtrpc.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>;
}

从源码可以确认以下行为:

  1. 参数签名即契约lazy 接收一个返回 Promise 的函数,Promise 的载荷允许是"路由本身"或"恰好导出一个路由的模块对象"。这解释了示例中 lazy(() => import('./user.js')) 为何成立——user.ts 的唯一导出就是 userRouter;而 slow.ts 同样只有 slowRouter 一个导出。

  2. 模块解析策略resolve() 先执行 isRouter(mod) 判断模块是否本身就是路由;若不是,则取 Object.values(mod),要求"有且仅有一个导出,且它是路由",否则抛出带示例代码的错误,明确指引开发者改用 .then((m) => m.router) 的显式写法。

  3. 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)供路由工厂在构建时识别"这是一个待加载项,而非已就绪的路由"。

  4. 路由定义中懒加载项的存储形态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 时出现的原因。

  5. 类型层的同步化lazy() 的返回类型 Lazy<NoInfer<TRouter>> 携带完整的路由类型参数,因此 appRouterAppRouter 类型中 userslow 两个命名空间的 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 解包写法以及模块顶层副作用只在首次访问时执行的特性,是落地时最值得注意的三个细节。

登录后查看全文
热门项目推荐
相关项目推荐