在 pnpm Monorepo 中实现 TanStack Router 全链路类型安全的架构实战(router-monorepo-simple 深度解析)

发布时间:2026/9/15 15:32:45
在 pnpm Monorepo 中实现 TanStack Router 全链路类型安全的架构实战(router-monorepo-simple 深度解析) 在 pnpm Monorepo 中实现 TanStack Router 全链路类型安全的架构实战router-monorepo-simple 深度解析【免费下载链接】router A client-first, server-capable, fully type-safe router and full-stack framework for the web (React and more).项目地址: https://gitcode.com/GitHub_Trending/ro/router本文以仓库 examples/react/router-monorepo-simple 为完整案例讲解如何在 pnpm workspace 构成的 monorepo 中落地 TanStack Router通过独立的「路由库」持有全部路由定义与 TypeScript 类型增强type augmentation再借助「组件库」与「应用入口」分层集成从而让Link、useLoaderData、getRouteApi等 API 在跨包调用时依然保持全链路类型安全同时避免库与库之间的循环依赖。读完本文你将掌握一套可复制到真实业务 monorepo 的路由分层方案。一、为什么 monorepo 中路由类型安全会「失效」TanStack Router 之所以能做到极致的类型安全核心依赖两点路由树代码生成由tanstack/router-plugin依据文件路由file-based routing自动生成routeTree.gen.ts把每个路由文件的类型信息固化下来TypeScript 类型增强type augmentation应用通过declare module tanstack/react-router注册路由实例类型让框架的类型层面知道「当前应用里存在哪些路由」。问题恰恰出在第二步。如果在最终应用app里直接做类型增强那么app 内部的路由调用是类型安全的但功能库feature library中编写的Link、useLoaderData等组件由于是独立编译、独立消费tanstack/react-router的类型看不到app 里注册的路由类型to/$postId这类调用退化成宽泛的字符串类型完全失去类型检查。这正是文档强调的挑战「it requires TypeScript type augmentations. However, if you set this up directly in the final app, the links inside the libraries wont be type-safe.」原文见 examples/react/router-monorepo-simple/README.md二、解法总览用「路由库」充当类型增强的枢纽本示例给出的方案是把类型增强下沉到独立的 router 库并让其他包只从这个库再导出re-export路由 API。这样类型增强就能沿依赖链传播到所有下游包。整个 monorepo 划分为三个包依赖方向app → post-feature → routerapp → router包作用是否包含组件packages/routerrouter-mono-simple/router持有路由定义、loader、类型增强并再导出全部路由 API否packages/post-featurerouter-mono-simple/post-feature持有帖子列表/详情/错误页等 UI 组件与业务逻辑是packages/approuter-mono-simple/app应用入口将路由与组件以映射方式装配是包的依赖关系与构建产物位置可见下图这一分层带来的直接收益按 README 原文可归纳为三点路由库router library集中存放路由定义与类型增强功能库feature libraries只写组件与业务逻辑应用app负责把路由映射到组件、装配并渲染。三、核心原理再导出re-export如何让类型增强「传染」阅读 packages/router/src/index.ts 可以清楚看到这套机制的两个关键动作。动作一注册路由实例类型。import { router } from ./router export type { RouterType, RouterIds } from ./router // Register the router instance for type safety declare module tanstack/react-router { interface Register { router: typeof router } } export { router }declare module tanstack/react-router的Register接口把当前路由实例注册进框架的类型系统此后tanstack/react-router里所有需要「感知路由形状」的泛型Link、Route、getRouteApi等都会基于router实例的类型展开。动作二再导出全部路由 API。export { Outlet, Link, useRouteContext, useRouter, RouterProvider, getRouteApi, ErrorComponent, } from tanstack/react-router export type { ErrorComponentProps } from tanstack/react-router这段再导出的注释点明了设计意图「By re exporting the api from TanStack router, we can enforce that other packages rely on this one instead, making the type register being applied」—— 强制功能库只能通过router-mono-simple/router拿到路由 API而不能直接import from tanstack/react-router。由于类型增强声明在该包内生效凡是经由本包再导出出去的 API其类型展开时都会带上注册信息于是类型安全被「传染」给了所有下游消费方。由此带来的直接结果就是功能库中可以放心地写import { Link, Outlet, getRouteApi } from router-mono-simple/router const route getRouteApi(/)并完整保留to/$postId、params{{ postId: post.id }}等参数的类型校验。四、包级配置解析Vite 库模式 workspace 依赖三个包都以 Vite库模式lib mode构建并配合vite-plugin-dts产出.d.ts类型声明这是跨包类型安全的工程基础。4.1 router 包接入路由插件并产出库构建packages/router/vite.config.ts 的关键配置import { tanstackRouter } from tanstack/router-plugin/vite export default defineConfig({ plugins: [ dts({ entryRoot: src, tsconfigPath: path.join(__dirname, tsconfig.json) }), react(), tanstackRouter(), // 根据 src/routes 生成 routeTree.gen.ts ], build: { outDir: ./dist, lib: { entry: src/index.ts, name: router, fileName: index, formats: [es], }, rolldownOptions: { external: [react, react-dom, react/jsx-runtime, tanstack/react-router], }, }, })要点tanstackRouter()插件负责在开发/构建时扫描src/routes生成 packages/router/src/routeTree.gen.ts库入口是src/index.ts即上文带类型增强与再导出的文件对外暴露的main/types分别指向./dist/index.js与./dist/index.d.ts见 packages/router/package.jsonexternal列表把react、tanstack/react-router等声明为外部依赖避免重复打包同时保证下游统一解析到同一份类型。4.2 post-feature 包仅依赖 router 包packages/post-feature/vite.config.ts 与 router 包结构一致只是external中额外排除了router-mono-simple/router与router-mono-simple/post-query。其 package.json 中通过router-mono-simple/router: workspace:*声明 workspace 依赖——这正是功能库只能从 router 包取路由 API 的强制手段。4.3 app 包纯应用装配packages/app/package.json 同时依赖router-mono-simple/post-feature与router-mono-simple/router均workspace:*并额外引入tanstack/react-router-devtools、tailwindcss等运行时/dev 依赖其nx配置声明dev目标dependsOn: [^build]保证开发启动前先构建依赖包。4.4 workspace 与根脚本workspace 声明位于 pnpm-workspace.yaml.examplepackages: - packages/*根 package.json 提供统一脚本scripts: { post-feature: pnpm --filter router-mono-simple/post-feature, router: pnpm --filter router-mono-simple/router, app: pnpm --filter router-mono-simple/app, dev: pnpm router build pnpm post-feature build pnpm app dev }注意dev脚本先构建 router 与 post-feature 两个库再启动 app因为 app 引用的是两个库的dist产物main/types指向dist库未构建则类型与运行时都不可用。这是本示例「库 应用」模式与直接跑文件路由的重要区别。五、路由层的源码解剖5.1 路由实例全局 pending 与滚动恢复packages/router/src/router.tsx 创建路由实例export const router createRouter({ routeTree, defaultPendingComponent: () ( divLoading form global pending component.../div ), scrollRestoration: true, }) export type RouterType typeof router export type RouterIds RouteIdsRouterType[routeTree]defaultPendingComponent路由切换时的全局占位组件scrollRestoration: true开启滚动位置恢复RouterIds由RouteIdsRouterType[routeTree]推导而来即__root__ | / | /$postId的联合类型它是 app 中「路由 → 组件」映射表的类型基石。5.2 路由定义与 loader数据获取放在路由库路由定义遵循文件路由约定相关文件位于 packages/router/src/routes// __root.tsx —— 根路由配置全局 notFound 组件 export const Route createRootRoute({ notFoundComponent: () ( div pThis is the notFoundComponent configured on root route/p Link to/Start Over/Link /div ), })// index.ts —— 列表路由loader 拉取帖子列表 export const Route createFileRoute(/)({ loader: () fetchPosts(), })// $postId.ts —— 详情路由loader 按参数拉取单帖 export const Route createFileRoute(/$postId)({ loader: ({ params }) fetchPost(params.postId), })数据请求封装在 packages/router/src/fetch/posts.ts其中定义了PostType类型、PostNotFoundError异常类以及fetchPost/fetchPosts两个函数内部使用 redaxios 请求jsonplaceholder.typicode.com并对 404 抛出PostNotFoundError。PostNotFoundError被 router 包再导出功能库的错误组件据此做分支渲染详见下文。5.3 生成的 routeTree类型系统的地基packages/router/src/routeTree.gen.ts 由插件自动生成文件头明确提示「You should NOT make any changes in this file as it will be overwritten」。它做两件事汇总__root、/、/$postId三个路由组装routeTree通过declare module tanstack/react-router声明FileRoutesByPath等类型接口固化to、fullPath、id等路由形状如to: / | /$postId。这就是getRouteApi(/)、to/$postId能拿到精确类型的前置条件。六、功能库组件层如何「零类型损失」消费路由post-feature 包只存放 UI 组件源码见 packages/post-feature/src且统一从router-mono-simple/router导入路由 API。列表组件PostList.tsximport { Link, Outlet, getRouteApi } from router-mono-simple/router const route getRouteApi(/) export function PostsListComponent() { const posts route.useLoaderData() // ... Link to/$postId params{{ postId: post.id }} ... // ... Outlet / }getRouteApi(/)基于路由库注册的RouterIds推导出useLoaderData的返回类型为PostType[]Link的params也被约束为{ postId: string }。详情组件PostIdPage.tsx同理通过getRouteApi(/$postId)拿到带类型的post数据。错误组件PostError.tsximport { ErrorComponent, PostNotFoundError } from router-mono-simple/router import type { ErrorComponentProps } from router-mono-simple/router export function PostErrorComponent({ error }: ErrorComponentProps) { if (error instanceof PostNotFoundError) { return divNot found from api: {error.message}/div } return ErrorComponent error{error} / }它利用 router 包再导出的PostNotFoundError与ErrorComponent对「接口返回 404」与「其他错误」做差异化展示且全部类型ErrorComponentProps同样来自 router 包。七、应用层装配路由 → 组件的类型安全映射app 的入口 packages/app/src/main.tsx 是本方案「最后一公里」把路由和组件解耦后重新绑定。import { Outlet, router } from router-mono-simple/router import { PostErrorComponent, PostIdComponent, PostsListComponent } from router-mono-simple/post-feature import { RootComponent } from ./rootComponent import type { RouterIds } from router-mono-simple/router const routerMap { /: PostsListComponent, /$postId: PostIdComponent, __root__: RootComponent, } as const satisfies RecordRouterIds, (() React.ReactElement) | null Object.entries(routerMap).forEach(([path, component]) { const foundRoute router.routesById[path as RouterIds] foundRoute.update({ component: component ?? EmptyComponent, }) })这段代码的价值在于as const satisfies RecordRouterIds, ...用RouterIds__root__ | / | /$postId约束映射表键名路由新增或拼写错误时编译期直接报错router.routesById[path]是路由实例上按 id 索引的路由表foundRoute.update({ component })在运行时把组件挂到对应路由上EmptyComponent仅渲染Outlet /作为占位保证「路由存在但组件暂缺」时仍可正常渲染子路由代码注释明确说明「Not lazy loaded for simplicity, but you could expose from your library component individually, and enforce here to use react lazy components via typings so that you have code splitting」—— 示例为简单起见未做懒加载但完全可以把组件按路由粒度从库中单独导出并在这里用React.lazy 类型约束实现代码分割。错误组件以同样模式装配foundRoute.update({ errorComponent })注释补充道「And you can do the same logic with custom error pages, and any other properties」—— 即beforeLoad、pendingComponent、loader等任意路由属性都可沿用这套「映射 update」的装配手法。根布局组件 rootComponent.tsx 负责渲染导航链接与Outlet /并挂载TanStackRouterDevtoolsLink to/ activeOptions{{ exact: true }} activeProps{{ className: font-bold }} Posts list /Link Outlet / TanStackRouterDevtools positionbottom-right /注意这里的Link、Outlet同样来自router-mono-simple/router与功能库保持同一类型来源。最终由RouterProvider router{router}渲染整个应用。八、构建依赖链与循环依赖规避README 特别指出本方案的价值「With this approach, we can use loaders in the router and the feature library without creating circular dependencies.」从依赖图看router包不依赖任何业务包是依赖链的最底层post-feature只依赖router组件消费 loader 数据与路由 APIapp依赖post-feature与router做映射装配。loader 与组件被刻意分置在router与post-feature两个包中loader 返回的数据类型如PostType在 router 包定义组件只通过类型安全的useLoaderData/getRouteApi消费。由于post-feature不反向依赖router之外的任何业务包router → post-feature → app之间不存在环。若把 loader 与组件都塞进 app再要求功能库提供组件就会出现「功能库想用 app 的路由类型、app 想用功能库的组件」的循环依赖这正是本方案刻意避免的场景。九、快速上手与运行命令基于本示例启动新项目可按 README 提供的方式npx gitpick TanStack/router/tree/main/examples/react/router-monorepo-simple router-monorepo-simple拉取模板然后pnpm install # 安装 workspace 依赖 pnpm dev # 依次构建 router、post-feature再启动 app 开发服务器app 端口 3001生产构建pnpm build # 各包分别执行 vite build tsc --noEmit运行注意点pnpm dev必须先构建两个库因为 app 通过包的main/types指向dist消费它们各库独立执行vite build tsc --noEmit见各包package.json的build脚本tsc校验类型声明与源码一致router 包构建时tanstackRouter()插件会自动重新生成 routeTree.gen.ts新增路由文件后无需手动维护该文件。十、IDE 类型推断的限制说明README 的「Stackblitz limitation」一节提醒由于 Stackblitz 在线环境限制示例代码在 IDE 中的类型推断可能不完整点击右下角 fork 到本地后类型即可正确推断。本地开发时请确保已执行pnpm install使 workspace 软链与类型声明生效并先构建依赖库再打开编辑器以获得完整的类型提示。十一、小结一套可复制的 monorepo 路由分层范式从本示例可以提炼出在 monorepo 中落地 TanStack Router 类型安全的四条准则路由定义与类型增强必须下沉到独立的 router 库不要放在最终应用里router 库再导出全部路由 API并用依赖声明workspace:*强制其他包从它取 API让类型增强沿依赖链传播功能库只写组件与业务逻辑通过getRouteApi、useLoaderData等类型安全 API 消费 loader 数据app 负责最终装配用as const satisfies RecordRouterIds, ...约束「路由 → 组件」映射表必要时配合React.lazy实现按路由代码分割。遵循这套范式新增一条路由只需在 router 包src/routes下添加路由文件loader 随之定义→ 重新构建 router 包 → 在功能库补组件 → 在 app 的routerMap/errorComponentMap中登记映射。全程由RouterIds与插件生成的FileRoutesByPath类型保驾护航任何不一致都会在编译期暴露。【免费下载链接】router A client-first, server-capable, fully type-safe router and full-stack framework for the web (React and more).项目地址: https://gitcode.com/GitHub_Trending/ro/router创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考