ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

TanStack Router 数据加载完全指南:loader、SWR 路由缓存与加载生命周期

TanStack Router 数据加载完全指南:loader、SWR 路由缓存与加载生命周期 TanStack Router 数据加载完全指南loader、SWR 路由缓存与加载生命周期【免费下载链接】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/routerTanStack Router 将“路由”与“数据加载”深度绑定每次 URL 变化时路由系统按固定的生命周期完成匹配、预加载、加载并通过内置的 Stale-While-RevalidateSWR缓存让已访问过的路由瞬时回显。本文基于仓库官方指南>// src/routes/posts.tsx export const Route createFileRoute(/posts)({ loader: () fetchPosts(), })// src/routes/posts.tsx export const Route createFileRoute(/posts)({ loader: { handler: () fetchPosts(), }, })当你需要配置 loader 专属行为如staleReloadMode时使用对象形式。这一点在源码中可以直接印证load-client.ts 中通过typeof routeLoader function区分函数形式与对象形式并从对象形式读取staleReloadMode。loader的参数loader函数接收一个对象其属性如下完整继承自官方文档abortController— 本次可共享 loader 调用的控制器。预加载与导航可以共享同一份进行中的 loader 工作当该调用过时且没有消费者还需要它时其 signal 会被取消。cause— 当前路由匹配的起因取值之一enter— 路由在上一位置未匹配、现在被匹配并加载。preload— 路由正在被预加载。stay— 路由在上一位置已匹配现在再次被匹配并加载。context— 路由上下文对象是以下两者的合并父路由 context本路由通过beforeLoad选项提供的 contextdeps—Route.loaderDeps函数返回的对象值。若未定义Route.loaderDeps则提供一个空对象。location— 当前位置params— 路由路径参数parentMatchPromise—PromiseRouteMatchroot 路由为undefinedpreload— 布尔值路由被“预加载”而非“加载”时为trueroute— 路由本身消费loader数据useLoaderData与getRouteApi消费loader数据使用定义在 Route 对象上的useLoaderDatahookconst posts Route.useLoaderData()如果你无法直接拿到路由对象例如你处在当前路由组件树的较深层级可以用getRouteApi访问同一个 hook以及 Route 对象上的其他 hook。应优先使用getRouteApi而不是直接 import Route 对象——后者很容易造成循环依赖。Reactimport { getRouteApi } from tanstack/react-router // in your component const routeApi getRouteApi(/posts) const data routeApi.useLoaderData()Solidimport { getRouteApi } from tanstack/solid-router // in your component const routeApi getRouteApi(/posts) const data routeApi.useLoaderData()底层实现上React/Solid 包的getRouteApi最终都会落到 router-core 的 useLoaderData 等通用 hook 上因此三框架行为一致。基于依赖的 SWR 缓存TanStack Router 提供内置的 Stale-While-Revalidate 缓存层缓存键基于路由的依赖路由的完整解析后 pathname例如/posts/1与/posts/2是两个键loaderDeps选项提供的额外依赖例如loaderDeps: ({ search: { pageIndex, pageSize } }) ({ pageIndex, pageSize })以这些依赖作为键TanStack Router 会缓存loader返回的数据并用于满足对同一路由匹配的后续请求。也就是说如果路由数据已在缓存中会立即返回然后视数据“新鲜度”决定是否在后台重新拉取。关键选项Key Options按最常用顺序排列控制路由依赖与“新鲜度”的选项如下routeOptions.loaderDeps一个确定性、无副作用的函数接收校验后的 search params返回一个可序列化的依赖对象供loader使用。当这些依赖在导航之间发生变化时无论staleTime如何都会强制路由重载。依赖使用深度相等比较。routeOptions.staleTime/routerOptions.defaultStaleTime加载时路由数据被视作新鲜fresh的毫秒数。routeOptions.preloadStaleTime/routerOptions.defaultPreloadStaleTime预加载时路由数据被视作新鲜的毫秒数。routeOptions.gcTime/routerOptions.defaultGcTime路由数据在被垃圾回收前保留在缓存中的毫秒数。routeOptions.shouldReload接收与beforeLoad相同的beforeLoad/loaderContext参数、返回布尔值的函数表示路由是否应重载。它在staleTime与loaderDeps之上提供又一层重载控制可实现类似 RemixshouldLoad的模式。routeOptions.loader.staleReloadMode/routerOptions.defaultStaleReloadMode控制当匹配的路由已有过期成功数据时发生什么background为 stale-while-revalidateblocking则等待过期的 loader 重载完成后再继续。重要默认值源码印证以下默认值均可在 router.ts 与 load-client.ts 中直接确认staleTime默认为0可复用的成功数据会立即被视为过期。当再次进入同一 loader 键或显式调用router.load()时过期数据默认在后台重新校验revalidate。不同的 loader 键对应独立的缓存条目若无可复用数据则必须加载。由预加载产生的 loader 数据默认保持新鲜 30 秒源码route.options.preloadStaleTime ?? router.options.defaultPreloadStaleTime ?? 30_000见 load-client.ts#L789-L794。每次预加载和导航仍会各自执行beforeLoad链但后续预加载与首次导航可以在该时间窗内复用预加载的 loader 数据或进行中的 loader 工作。导航接受该 loader 代之后后续新鲜度检查改用标准staleTime。gcTime与preloadGcTime默认是 **5 分钟300_000ms**保留窗口源码route.options.gcTime ?? router.options.defaultGcTime ?? 300_000见 load-client.ts#L1638-L1644。一旦未使用的数据超过对应窗口就会在后续缓存协调reconciliation时被清除。两个窗口可独立配置。staleReloadMode默认为background过期的成功匹配继续用现有loaderData渲染loader 在后台重新校验。router.invalidate()会选择匹配的已提交、已缓存与进行中的 loader 代进行失效并退役匹配的活跃预加载通道。当前活跃路由通过正常加载协议重载已缓存的非活跃数据保持“过期”标记在被复用时再重载。默认情况下过期的成功 loader 数据在后台重新校验除非请求sync: true。用loaderDeps访问 search params假设/posts路由通过 search paramsoffset和limit支持分页。为了让缓存能唯一地存储这份数据需要通过loaderDeps函数访问这些 search params。显式声明后不同offset/limit的/posts匹配就不会互相污染。有了这些依赖路由会在依赖变化时总是重载。loaderDeps在路由规划阶段定义缓存键。对相同的已校验 search 输入它必须返回相同的值且不得导航或改变状态返回值及自定义序列化方法如toJSON也必须无副作用。// /routes/posts.tsx export const Route createFileRoute(/posts)({ loaderDeps: ({ search: { offset, limit } }) ({ offset, limit }), loader: ({ deps: { offset, limit } }) fetchPosts({ offset, limit, }), })警告只把 loader 真正用到的依赖放进loaderDeps。常见错误是返回整个search对象// ❌ Dont do this - causes unnecessary cache invalidation loaderDeps: ({ search }) search, loader: ({ deps }) fetchPosts({ page: deps.page }), // only uses page!这会导致任何search param 变化包括 loader 未使用的viewMode、sortDirection都触发重载。正确做法是只提取需要的部分// ✅ Do this - only reload when used params change loaderDeps: ({ search }) ({ page: search.page, limit: search.limit, }), loader: ({ deps }) fetchPosts(deps),用staleTime控制数据新鲜时长默认情况下已被导航接受的数据staleTime为0ms而preloadStaleTime为 30 秒。因此一次成功的预加载可以在该时间窗内向首次导航提供 loader 数据导航接受该 loader 代之后正常导航新鲜度规则生效。以默认staleTime计同一 loader 键的后续复用会立即视为过期并在缓存数据保持可见的同时后台重新校验。不同 loader 键对应独立的缓存条目。这对多数场景是好的默认值但你可能发现某些路由数据更静态、或加载成本更高。此时可用staleTime控制新鲜时长// /routes/posts.tsx export const Route createFileRoute(/posts)({ loader: () fetchPosts(), // Consider the routes data fresh for 10 seconds staleTime: 10_000, })传入10_000表示路由数据在 10 秒内视为新鲜用户在这 10 秒内从/about回到/posts不会重新加载超过 10 秒再回来数据会在后台重新加载。选择 background 还是 blocking 的过期重载默认情况下过期的成功匹配走 stale-while-revalidate 行为路由器可以立即用现有loaderData渲染然后后台刷新。若希望某个 loader 等待过期重载完成后再继续使用对象形式并设置staleReloadMode: blocking// /routes/posts.tsx export const Route createFileRoute(/posts)({ loader: { handler: () fetchPosts(), staleReloadMode: blocking, }, })也可以在 router 层面修改默认值const router createRouter({ routeTree, defaultStaleReloadMode: blocking, })选择依据显示过期数据的过渡可接受时用background希望过期匹配表现得更像“全新加载”、等待新 loader 结果时用blocking。在源码中这一决策点位于 load-client.ts#L834-L845只有当match.status success、非 preload、非 sync 且生效的staleReloadMode路由级loader.staleReloadMode优先否则取router.options.defaultStaleReloadMode不是blocking时才走后台路径blocking 路径会将匹配状态置为pending并中止被替代的 flight。关闭自动过期重载要让某路由关闭自动过期重载把staleTime设为Infinity// /routes/posts.tsx export const Route createFileRoute(/posts)({ loader: () fetchPosts(), staleTime: Infinity, })也可以在 router 层对全部路由关闭const router createRouter({ routeTree, defaultStaleTime: Infinity, })注意这与staleReloadMode: blocking的区别staleTime: Infinity让路由根本不进入过期状态staleReloadMode: blocking仍允许过期重载只是等待其完成而非后台执行用shouldReload与gcTime退出缓存类似 Remix 的默认行为你可能希望某路由只在进入时或关键 loader 依赖变化时加载。这可以通过gcTime与shouldReload组合实现——后者接受boolean或一个接收beforeLoad/loaderContext参数并返回布尔值的函数// /routes/posts.tsx export const Route createFileRoute(/posts)({ loaderDeps: ({ search: { offset, limit } }) ({ offset, limit }), loader: ({ deps }) fetchPosts(deps), // Do not cache this routes data after its unloaded gcTime: 0, // Only reload the route when the user navigates to it or when deps change shouldReload: false, })退出缓存但保留预加载收益即便退出了常规路由数据的保留你依然可以享受预加载收益预加载结果用preloadGcTime控制保留、preloadStaleTime控制新鲜度。因此默认配置下最近的预加载会保留在内存中首次导航可直接复用而无须再次调用 loader。另外用routerOptions.defaultPreload控制链接的自动预加载。把routeOptions.preload设为false影响更窄投机speculative通道仍会执行该路由的beforeLoad但会跳过其loader导航时两者照常执行。把所有 loader 事件交给外部缓存这一用法的完整拆解见 External Data Loading。若你想用 TanStack Query 之类的外部缓存可以把所有 loader 事件都转发给外部缓存。只要保持其他默认值唯一需要做的调整是把 router 的defaultPreloadStaleTime设为0const router createRouter({ routeTree, defaultPreloadStaleTime: 0, })这让预加载落定的数据在 Router 中立即视为过期从而由外部缓存决定是否发起请求。保留retention仍遵循preloadGcTime重叠的预加载或导航消费者仍可以共享进行中的 loader 工作路由的shouldReload选项也可以抑制 loader 调用。使用 Router Context传给loader的context参数是以下两者的合并对象父路由 context本路由通过beforeLoad选项提供的 context从路由树最顶层开始你可以通过 router 的context选项传入初始上下文。它对该路由器的所有路由可见并随着每个被匹配的路由被逐层拷贝、扩展——扩展发生在各路由的beforeLoad中扩展后的 context 会向下传给所有子路由最终体现在该路由的loader参数里。下面示例在路由 context 中创建一个拉取 posts 的函数并在loader中使用思考提示Context 是依赖注入的强大工具。你可以用它向 router 与路由注入服务、hooks 和其他对象也可以借助每个路由的beforeLoad在路由树上逐层追加数据。/utils/fetchPosts.tsxexport const fetchPosts async () { const res await fetch(/api/posts?page${pageIndex}) if (!res.ok) throw new Error(Failed to fetch posts) return res.json() }/routes/__root.tsxReactimport { createRootRouteWithContext } from tanstack/react-router // Create a root route using the createRootRouteWithContext{...}() function and pass it whatever types you would like to be available in your router context. export const Route createRootRouteWithContext{ fetchPosts: typeof fetchPosts }() // NOTE: the double call is on purpose, since createRootRouteWithContext is a factory ;)/routes/__root.tsxSolidimport { createRootRouteWithContext } from tanstack/solid-router // Create a root route using the createRootRouteWithContext{...}() function and pass it whatever types you would like to be available in your router context. export const Route createRootRouteWithContext{ fetchPosts: typeof fetchPosts }() // NOTE: the double call is on purpose, since createRootRouteWithContext is a factory ;)/routes/posts.tsx// Notice how our postsRoute references context to get our fetchPosts function // This can be a powerful tool for dependency injection across your router // and routes. export const Route createFileRoute(/posts)({ loader: ({ context: { fetchPosts } }) fetchPosts(), })/router.tsximport { routeTree } from ./routeTree.gen // Use your routerContext to create a new router // This will require that you fullfil the type requirements of the routerContext const router createRouter({ routeTree, context: { // Supply the fetchPosts function to the router context fetchPosts, }, })createRootRouteWithContext的双调用是有意为之——它是一个工厂函数外层调用注入泛型类型内层调用创建路由实例。使用路径参数在loader中通过参数对象的params属性访问路径参数// src/routes/posts.$postId.tsx export const Route createFileRoute(/posts/$postId)({ loader: ({ params: { postId } }) fetchPostById(postId), })使用 Route ContextbeforeLoad向 router 传递全局上下文固然好但如果你只想为某个路由提供专属上下文就该用beforeLoad选项。它在尝试加载路由前执行接收与loader相同的参数除了重定向潜在匹配、拦截 loader 请求之外它还可以返回一个对象该对象会被合并进路由 context// src/routes/posts.tsx export const Route createFileRoute(/posts)({ // Pass the fetchPosts function to the route context beforeLoad: () ({ fetchPosts: () console.info(foo), }), loader: ({ context: { fetchPosts } }) { fetchPosts() // foo // ... }, })在 Loader 中使用 Search Params等等search params 去哪儿了你可能疑惑为什么loader的参数里没有直接的search。这是有意为之的设计原因有三在 loader 中使用的 search params 是很强的信号表明这些 search params 也应当被用来唯一标识所加载的数据。例如某路由用pageIndex唯一标识路由匹配中的数据或者/users/user路由用?userId123指定具体用户这时user路由就需要额外信息来定位具体用户。在 loader 中直接访问 search params 会导致缓存/预加载 bug所加载的数据并不唯一对应当前 URL 的 pathname search。例如你让/posts路由预取第 2 页结果但路由配置里没有页码区分你最终会在/posts或?page1屏幕上拿到、存下并展示第 2 页的数据而不是在后台预取在 search params 与 loader 之间设置一道“门槛”让路由器能理解你的依赖与响应式关系。// /routes/users.user.tsx export const Route createFileRoute(/users/user)({ validateSearch: (search) search as { userId: string }, loaderDeps: ({ search: { userId } }) ({ userId, }), loader: async ({ deps: { userId } }) getUser(userId), })通过routeOptions.loaderDeps访问 Search Params// /routes/posts.tsx export const Route createFileRoute(/posts)({ // Use zod to validate and parse the search params validateSearch: z.object({ offset: z.number().int().nonnegative().catch(0), }), // Pass the offset to your loader deps via the loaderDeps function loaderDeps: ({ search: { offset } }) ({ offset }), // Use the offset from context in the loader function loader: async ({ deps: { offset } }) fetchPosts({ offset, }), })仓库中还提供了tanstack/react-router生态下的校验适配器包如 zod-adapter、valibot-adapter、arktype-adapter可配合validateSearch使用。使用 Abort Signalloader参数的abortController是本次 loader 调用的AbortController。由于预加载与导航可以共享一次进行中的调用只要还有消费者需要该工作signal 就保持激活当调用过时且没有剩余消费者时才会取消。与 fetch 配合的示例// src/routes/posts.tsx export const Route createFileRoute(/posts)({ loader: ({ abortController }) fetchPosts({ // Pass this to an underlying fetch call or anything that supports signals signal: abortController.signal, }), })使用preload标志loader参数的preload属性在路由被“预加载”而非“加载”时为true。一些数据加载库对预加载与标准请求的处理不同你可以把preload传给数据加载库、或据此执行相应的加载逻辑// src/routes/posts.tsx export const Route createFileRoute(/posts)({ loader: async ({ preload }) fetchPosts({ maxAge: preload ? 10_000 : 0, // Preloads should hang around a bit longer }), })处理慢 Loader理想情况下大多数 route loader 能在很短时间内容解数据这样就不需要渲染占位 spinner直接依赖 Suspense 在数据完全就绪时渲染下一路由即可。但当渲染路由组件所必需的关键数据很慢时你有两个选择把快慢数据拆成独立的 Promise用defer让慢数据在快数据加载完之后再到达见 Deferred Data Loading 指南核心实现在 router-core 的 defer 模块在一个乐观的 Suspense 阈值之后、数据全部就绪之前显示 pending component见下文。显示 pending component默认情况下TanStack Router 会对超过 1 秒才解开的 loader 显示 pending component。这是一个可通过以下选项配置的乐观阈值routeOptions.pendingMs或routerOptions.defaultPendingMs超过 pending 时间阈值后路由器会渲染该路由的pendingComponent若已配置。默认值defaultPendingMs: 1000与defaultPreloadDelay: 50在 router.ts#L1182-L1194 的构造器中给出。避免 pending component 闪烁如果使用了 pending component最糟糕的情况是刚达到 pending 阈值、数据立刻解出造成一次突兀的 pending 组件闪烁。为此TanStack Router 默认会让 pending component 至少显示 500ms。这同样是可通过以下选项配置的乐观阈值routeOptions.pendingMinMs或routerOptions.defaultPendingMinMs源码中该默认值为defaultPendingMinMs: 500router.ts#L1185其文档注释也明确指向本指南的 “Avoiding Pending Component Flash” 一节。错误处理TanStack Router 提供多种方式处理路由加载生命周期中的错误。用routeOptions.onError处理onError在路由加载发生错误时被调用// src/routes/posts.tsx export const Route createFileRoute(/posts)({ loader: () fetchPosts(), onError: ({ error }) { // Log the error console.error(error) }, })用routeOptions.onCatch处理onCatch在错误被路由器的 CatchBoundary 捕获时被调用// src/routes/posts.tsx export const Route createFileRoute(/posts)({ onCatch: (error) { // Log the error console.error(error) }, })用routeOptions.errorComponent处理errorComponent是一个组件在路由加载或渲染生命周期发生错误时渲染。它接收以下 propserror— 捕获到的值React 与 Vue 中为unknownSolid 中为Errorreset— 重置内部CatchBoundary的函数类型层面的细节ErrorComponentProps的error属性在 React 与 Vue 中默认为unknown访问message等属性前必须收窄在 Solid 中默认为Error。ErrorComponentPropsMyError可用于描述收窄之后的错误但它并不限制路由可抛出什么。这适用于边界组件与onCatch回调包括defaultOnCatch但不改变onError回调。各框架对捕获值的传递方式React 与 Vue 边界原样透传捕获值包括 falsy 值Solid 会把非Error抛出包装成cause含原值的Error。这也适用于 Solid 在 SSR 期间渲染的错误组件已有Error实例被保留字符串成为 error message其他值使用 messageUnknown error。Router 状态与 loader 的onError回调始终保留原始值。// src/routes/posts.tsx export const Route createFileRoute(/posts)({ loader: () fetchPosts(), errorComponent: ({ error }) { // Render an error message return div{error instanceof Error ? error.message : String(error)}/div }, })reset函数可以让用户重试重新渲染错误边界的正常子内容// src/routes/posts.tsx export const Route createFileRoute(/posts)({ loader: () fetchPosts(), errorComponent: ({ error, reset }) { return ( div {error instanceof Error ? error.message : String(error)} button onClick{() { // Reset the router error boundary reset() }} retry /button /div ) }, })如果错误是路由加载route load造成的应改为调用router.invalidate()——它会同时协调 router 重载与错误边界重置// src/routes/posts.tsx export const Route createFileRoute(/posts)({ loader: () fetchPosts(), errorComponent: ({ error, reset }) { const router useRouter() return ( div {error instanceof Error ? error.message : String(error)} button onClick{() { // Invalidate the route to reload the loader, which will also reset the error boundary router.invalidate() }} retry /button /div ) }, })使用默认ErrorComponentTanStack Router 提供了默认ErrorComponent在路由加载或渲染生命周期发生错误时渲染。即使你为路由自定义了错误组件也始终建议对未捕获错误回退到默认ErrorComponent// src/routes/posts.tsx export const Route createFileRoute(/posts)({ loader: () fetchPosts(), errorComponent: ({ error }) { if (error instanceof MyCustomError) { // Render a custom error message return div{error.message}/div } // Fallback to the default ErrorComponent return ErrorComponent error{error} / }, })小结把数据加载能力落到实处的检查清单为每个有数据的路由定义loader需要缓存专属行为staleReloadMode时用对象形式。用loaderDeps显式声明缓存键pathname 之外的依赖只放入 loader 真正使用的字段避免不必要的缓存失效。需要较长新鲜期的路由设置staleTime完全静态数据用staleTime: Infinity只进时加载的组合是gcTime: 0shouldReload: false。希望过期数据等待刷新而不是闪烁新内容时用staleReloadMode: blocking路由级或defaultStaleReloadMode全局级。跨路由注入服务/工具函数用 routercontextcreateRootRouteWithContext路由级注入用beforeLoad返回值。慢数据要么defer拆分快慢数据要么用pendingMs/pendingMinMs默认 1000ms/500ms呈现 pending component。错误分级处理onError记录加载错误、onCatch记录边界捕获、errorComponent渲染可重试 UI并始终回退默认ErrorComponent。以上所有行为的最终裁判都是 packages/router-core/src 中的实现——尤其是 load-client.ts缓存判定、SWR 背景/阻塞决策、GC 协调与 router.ts默认值与路由选项遇到本文覆盖不到的细节时直接对照源码即可。【免费下载链接】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),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进