在 Monorepo 中落地类型安全的 TanStack Solid Router:Router 独立库 + Solid Query 的工程实践
2026/9/15 16:37:50 网站建设 项目流程

在 Monorepo 中落地类型安全的 TanStack Solid Router:Router 独立库 + Solid Query 的工程实践

【免费下载链接】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

导读

本文以本仓库中的 router-monorepo-solid-query 示例 为蓝本,讲解在 pnpm/npm monorepo 工程中,如何将 TanStack Solid Router 拆分为"纯路由库 + 数据查询库 + UI 特性库 + 应用壳"的多包结构,从而解决 monorepo 场景下 TypeScript 类型增强(type augmentation)失效、跨包链接不类型安全、以及路由与数据层循环依赖这三大痛点。读完本文,你将掌握:如何在独立包中注册 Router 类型、为何必须由 Router 库统一 re-export 路由 API、以及如何在应用入口用一张"路由 → 组件"映射表把整棵路由树与组件绑定起来,最终获得完全类型安全的 Solid 单页应用。

一、问题背景:为什么 monorepo 会破坏路由的类型安全

TanStack Router(包括 Solid 版本)实现端到端类型安全的核心机制是TypeScript 模块增强(module augmentation)routeTree.gen.ts会把FileRoutesByPath等接口注入@tanstack/solid-router的模块声明中,同时应用需要声明:

declare module '@tanstack/solid-router' { interface Register { router: typeof router } }

这套机制在单包应用里没有问题。但在 monorepo 中,一个典型场景是数据查询库或 UI 特性库内部也要使用<Link to="...">getRouteApi('/$postId')等 API。如果类型注册只发生在最终 app 包里,那么:

  • 库内代码引用@tanstack/solid-router时,TypeScript 解析到的是node_modules 里的原始类型声明,而不是 app 里增强过的版本;
  • 于是库里的to="/$postId"params等属性退化为宽松的string,拼错路径、写错参数、漏写必填字段都不会在编译期报错;
  • 类型安全的链路在"路由库 → 特性库"这一段直接断裂。

原文档明确指出这一困境:"如果你直接把这个设置放在最终 app 里,库内部的链接将不再类型安全。"

二、解法总览:独立的 Router 库 + 自下而上的依赖方向

示例给出的工程化答案是:单独建一个只负责路由、不包含任何业务组件的 Router 库,并让依赖方向保持单向流动:

packages/router (路由树 + 注册 + 路由 API re-export) ↑ packages/post-query (数据查询选项集合,可被 router 与 feature 同时引用) ↑ packages/post-feature (UI 组件,仅依赖 router 与 post-query) ↑ packages/app (应用壳,做路由 → 组件的最终绑定)

这样设计有两个关键收益(原文明确强调):

  1. 数据库的 query options 可以同时被 Router 库(loader 中)和特性库(组件中)复用,而不会产生循环依赖——数据层位于依赖图的最底部,只依赖@tanstack/solid-query与数据获取逻辑。
  2. Router 库 re-export 路由组件LinkOutletgetRouteApi等),特性库一律从 Router 库导入而不是直接 import@tanstack/solid-router,从而保证这些组件始终与类型增强绑定在一起,天然类型安全。

下面是该示例在 monorepo 中的结构示意(来自示例自带的架构图):

图中可见四个包之间的依赖箭头:app → post-feature → post-query,以及app → routerrouter → post-query,依赖方向自底向上单向流动。

三、四个包的分工与关键源码

3.1packages/router:路由树的唯一主人

Router 库是整个方案的枢纽,包含四类文件:路由文件(src/routes/)、生成的routeTree.gen.tsrouter.tsx(创建实例)与index.ts(注册 + re-export)。

创建 Router 实例(router.tsx):

import { createRouter } from '@tanstack/solid-router' import { QueryClient } from '@tanstack/solid-query' import { routeTree } from './routeTree.gen' import type { RouteIds } from '@tanstack/solid-router' export const queryClient = new QueryClient() export const router = createRouter({ routeTree, context: { queryClient, }, defaultPendingComponent: () => ( <div>Loading form global pending component...</div> ), // loader 等待 200ms 即显示 pending 组件,而非默认的 1000ms defaultPendingMs: 200, defaultPreload: 'intent', // 使用 Solid Query 时不希望 loader 结果过期: // 保证每次 preload 或访问路由都会调用 loader defaultPreloadStaleTime: 0, scrollRestoration: true, }) export type RouterType = typeof router export type RouterIds = RouteIds<RouterType['routeTree']>

这里值得注意的配置项及其作用:

  • context.queryClient:把 Solid Query 的QueryClient注入路由上下文,后续 loader 通过{ context: { queryClient } }取用,避免在路由文件里手动 new 客户端;
  • defaultPendingMs: 200:缩短 pending 组件出现前的等待阈值,让慢 loader 时 UI 反馈更快;
  • defaultPreload: 'intent':按鼠标悬停等意图预加载;
  • defaultPreloadStaleTime: 0:与 React Query 版示例注释一致——使用数据查询库时,禁止 loader 结果被视为"新鲜",确保预加载/访问时始终重新执行 loader;
  • scrollRestoration: true:启用滚动位置恢复。

类型注册与 API re-export(index.ts)是整套方案的核心:

import { queryClient, router } from './router' export type { RouterType, RouterIds } from './router' // Register the router instance for type safety declare module '@tanstack/solid-router' { interface Register { router: typeof router } } export { router, queryClient } // 通过 re-export 路由 API,强制其他包依赖本包而非直接依赖 @tanstack/solid-router, // 从而使类型注册对所有下游生效 export { Outlet, Link, useRouteContext, useRouter, RouterProvider, getRouteApi, ErrorComponent, } from '@tanstack/solid-router' export type { ErrorComponentProps } from '@tanstack/solid-router'

关键点在于最后一段 re-export:特性库里的import { Link, Outlet } from '@router-solid-mono-solid-query/router'走的是经过 module augmentation 的模块实例,而declare module '@tanstack/solid-router'只在此包中出现一次。这正好回应了原文档的结论:"由于 Router 库 re-export 了路由组件,在特性库中导入它们就能保证类型安全,因为它们与 TypeScript 增强绑定在一起。"

3.2packages/post-query:与路由无关的数据层

该包不感知路由,只负责定义查询选项与数据获取函数:

  • postsQueryOptions.tsx:queryOptions({ queryKey: ['posts'], queryFn: () => fetchPosts() })
  • postQueryOptions.tsx:按参数化postId生成queryKey: ['posts', { postId }]
  • posts.tsx:用redaxios请求jsonplaceholder.typicode.com,并定义PostNotFoundError(404 时抛出)。

由于它位于依赖图最底层,Router 库可以在 loader 中这样消费它(routes/index.ts):

import { createFileRoute } from '@tanstack/solid-router' import { postsQueryOptions } from '@router-solid-mono-solid-query/post-query' export const Route = createFileRoute('/')({ loader: ({ context: { queryClient } }) => { return queryClient.ensureQueryData(postsQueryOptions) }, })

参数路由 routes/$postId.ts 同理,只是把postIdparams中取出传入postQueryOptions(postId)ensureQueryData保证"已有缓存则直接复用,否则发起请求",避免组件与 loader 双重请求——这正是把 query options 放进独立库的最大收益:loader 与组件共享同一份查询定义,无循环依赖

根路由 routes/__root.tsx 还展示了类型化的上下文声明:

import { Link, createRootRouteWithContext } from '@tanstack/solid-router' import type { QueryClient } from '@tanstack/solid-query' export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({ notFoundComponent: () => { return ( <div> <p>This is the notFoundComponent configured on root route</p> <Link to="/">Start Over</Link> </div> ) }, })

createRootRouteWithContext<{ queryClient: QueryClient }>context的类型在createFileRoute的 loader 签名里自动可用。

3.3packages/post-feature:只从 Router 库导入

特性库的所有组件统一从@router-solid-mono-solid-query/router导入路由 API,从post-query导入数据:

  • PostList.tsx:useQuery(() => postsQueryOptions)渲染列表,并用类型安全的<Link to="/$postId" params={{ postId: post.id }}>跳转详情,同时渲染<Outlet />挂载子路由;
  • PostIdPage.tsx:用getRouteApi('/$postId')获得类型化的useParams,再useQuery(() => postQueryOptions(postId))拉取详情;
  • PostError.tsx:详情页错误组件;
  • index.ts:统一export *

观察PostList.tsx的导入来源,就能验证"re-export 强制类型安全"的设计:LinkOutlet来自 router 库,postsQueryOptions来自 post-query 库——特性包从不直接触碰@tanstack/solid-router

3.4packages/app:把路由树与组件绑定的最终拼图

应用壳(main.tsx)做了两件事。

其一,路由 → 组件映射表

const routerMap = { '/': PostsListComponent, '/$postId': PostIdComponent, __root__: RootComponent, } as const satisfies Record<RouterIds, () => JSX.Element> Object.entries(routerMap).forEach(([path, component]) => { const foundRoute = router.routesById[path as RouterIds] foundRoute.update({ component }) })

Record<RouterIds, ...>satisfies保证映射表的键必须是真实存在的路由 id——如果路由树里没有/$postId,这一行就会编译报错。原文档特别指出:"在这里本可以强制执行懒加载(lazy loading),但为了简单起见被省略了"——注释中也提示,可以逐个从特性库导出组件,并在 app 层用lazy包装以达成代码分割。

其二,错误组件映射

const errorComponentMap = { '/': null, '/$postId': PostErrorComponent, __root__: null, }

遍历时跳过null,把PostErrorComponent通过foundRoute.update({ errorComponent })挂到/$postId上。这验证了update()是一个可反复调用的通用接口,组件、错误页乃至其他路由属性都可以在应用层按需注入。

最后用QueryClientProvider包裹RouterProvider完成挂载(main.tsx):

render( () => ( <QueryClientProvider client={queryClient}> <RouterProvider router={router} /> </QueryClientProvider> ), rootElement, )

根组件 rootComponent.tsx 同样只从 router 库导入LinkOutlet,并挂载TanStackRouterDevtoolsSolidQueryDevtools便于调试。

四、工程配置:pnpm workspace 与包依赖

示例提供了 pnpm-workspace.yaml.example,内容极简:

packages: - 'packages/*'

各包通过"workspace:*"相互引用,例如 app 的 package.json:

{ "name": "@router-solid-mono-solid-query/app", "private": true, "type": "module", "scripts": { "dev": "vite --port=3001", "build": "vite build && tsc --noEmit", "preview": "vite preview", "start": "vite" }, "dependencies": { "@tanstack/solid-query": "^5.90.9", "@router-solid-mono-solid-query/post-feature": "workspace:*", "@router-solid-mono-solid-query/router": "workspace:*", "solid-js": "^1.9.10" } }

其中nx.targets.dev.dependsOn: ['^build']表示开发模式下先构建上游依赖包,保证 router/post-query 等包的最新代码被 app 消费。pnpm install会把pnpm-workspace.yaml.example复制为pnpm-workspace.yaml后生效。

五、运行方式

原文档给出的运行步骤(以仓库根目录为基准):

cd examples/solid/router-monorepo-solid-query pnpm install # 或 npm install / yarn pnpm dev # 或 npm dev / yarn dev

pnpm dev会触发vite --port=3001,启动后访问本地服务即可看到帖子列表与详情页。应用入口 HTML 位于 packages/app/index.html。

六、已知限制:Stackblitz 上的 IDE 类型反馈

原文档记录了一个环境相关的注意点:由于 Stackblitz 的限制,示例的类型在 IDE 中不会立即被正确推断,但只要点击右下角的 fork,类型推断就会恢复正常。这是 Stackblitz 云端环境的已知行为,与代码本身无关;在本地 clone 后使用 VSCode 等编辑器打开时不受影响。

七、模式总结与可迁移要点

关注点推荐做法本示例中的落点
类型注册只做一次独立 Router 库内declare module '@tanstack/solid-router'router/src/index.ts
库内必须类型安全所有包从 Router 库导入Link/Outlet/getRouteApipost-feature/src/PostList.tsx
数据与路由解耦query options 放独立数据包,router loader 用ensureQueryDatarouter/src/routes/index.ts
组件与路由绑定推迟到应用层router.routesById[id].update({ component })映射表app/src/main.tsx
后续代码分割在 app 层对组件做 lazy 包装(示例为简洁略去)见 main.tsx 内注释

这套"独立 Router 库 + 单向依赖 + 应用层绑定"的架构同样适用于 React 版 TanStack Router(本仓库在 examples/react/router-monorepo-react-query 提供了对等示例),可以作为团队在 monorepo 中推行全栈类型安全的通用模板。

【免费下载链接】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),仅供参考

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

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

立即咨询