在 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 (应用壳,做路由 → 组件的最终绑定)这样设计有两个关键收益(原文明确强调):
- 数据库的 query options 可以同时被 Router 库(loader 中)和特性库(组件中)复用,而不会产生循环依赖——数据层位于依赖图的最底部,只依赖
@tanstack/solid-query与数据获取逻辑。 - Router 库 re-export 路由组件(
Link、Outlet、getRouteApi等),特性库一律从 Router 库导入而不是直接 import@tanstack/solid-router,从而保证这些组件始终与类型增强绑定在一起,天然类型安全。
下面是该示例在 monorepo 中的结构示意(来自示例自带的架构图):
图中可见四个包之间的依赖箭头:app → post-feature → post-query,以及app → router、router → post-query,依赖方向自底向上单向流动。
三、四个包的分工与关键源码
3.1packages/router:路由树的唯一主人
Router 库是整个方案的枢纽,包含四类文件:路由文件(src/routes/)、生成的routeTree.gen.ts、router.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 同理,只是把postId从params中取出传入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 强制类型安全"的设计:Link、Outlet来自 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 库导入Link、Outlet,并挂载TanStackRouterDevtools与SolidQueryDevtools便于调试。
四、工程配置: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 devpnpm 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/getRouteApi等 | post-feature/src/PostList.tsx |
| 数据与路由解耦 | query options 放独立数据包,router loader 用ensureQueryData | router/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),仅供参考