本文由 Huifer 撰写,他是 TanStack Ship 的独立开发者与维护者。
这些模式我在找到真正有效的做法之前反复实践过。在 12+ 个生产应用中部署路由守卫、调试乐观加载的竞态条件之后,我有了自己的判断。这篇文章提炼的是生产环境中真正有效的部分。
信息来源:TanStack Router 文档 · GitHub Issues · Cloudflare Workers 文档 最后更新:2026-07-31 · Changelog
路由守卫:beforeLoad 是你的朋友
TanStack Router 的 beforeLoad hook 是做认证检查的正确位置——不要放组件里,不要放 loader 里,也不要用中间件。
有效的模式
// app/router.ts
import { createRouter, Route } from '@tanstack/react-router';
import { rootRoute } from './routes/__root';
import { indexRoute } from './routes/index';
import { dashboardRoute } from './routes/dashboard';
import { settingsRoute } from './routes/settings';
import { authGuard } from './modules/auth/guards';
// 带认证守卫的受保护路由
const dashboardRoute = new Route({
getParentRoute: () => rootRoute,
path: '/dashboard',
beforeLoad: authGuard({
redirectTo: '/login',
requiresSubscription: true, // 可选:要求付费套餐
}),
loader: async ({ context }) => {
return {
dashboardData: await context.queryClient.fetchQuery({
queryKey: ['dashboard'],
queryFn: fetchDashboardData,
}),
};
},
component: DashboardComponent,
});
// 仅访客路由(已登录则重定向)
const loginRoute = new Route({
getParentRoute: () => rootRoute,
path: '/login',
beforeLoad: authGuard({
redirectTo: '/dashboard',
requireGuest: true, // 已登录用户重定向走
}),
component: LoginComponent,
});
认证守卫的实现
// app/modules/auth/guards.ts
import { QueryClient } from '@tanstack/react-query';
interface AuthGuardOptions {
redirectTo?: string;
requireGuest?: boolean;
requiresSubscription?: boolean;
}
export function authGuard(options: AuthGuardOptions = {}) {
return async ({ context, location }: RouteContext) => {
const user = await context.queryClient.fetchQuery({
queryKey: ['currentUser'],
queryFn: () => context.auth.getCurrentUser(),
});
// 仅访客路由:已登录则重定向
if (options.requireGuest && user) {
throw new Redirect({
to: options.redirectTo || '/dashboard',
search: { redirect: location.href },
});
}
// 受保护路由:未登录则重定向
if (!options.requireGuest && !user) {
throw new Redirect({
to: options.redirectTo || '/login',
search: { redirect: location.href },
});
}
// 订阅检查:免费套餐则重定向
if (options.requiresSubscription && user?.subscriptionStatus === 'free') {
throw new Redirect({
to: '/upgrade',
search: { redirect: location.href },
});
}
return { user };
};
}
为什么不用中间件?
TanStack Router 没有传统中间件。beforeLoad hook 就是等价物,而且有优势:它只为该路由及其子路由运行、有类型、能访问路由上下文。
我试过用 TanStack Router 的中间件系统(是的,它有一个)实现类中间件模式。对我的场景来说只增加了复杂度没有收益。坚持用 beforeLoad。
路由预加载策略
预加载通过在用户导航之前就开始取数来改善体感性能。有三种真正有效的策略。
策略 1:悬停预加载
用户悬停在链接上时加载数据:
// app/components/Link.tsx
import { Link } from '@tanstack/react-router';
import { queryClient } from '../lib/query-client';
interface PreloadLinkProps {
to: string;
preloadQuery: () => Promise<unknown>;
children: React.ReactNode;
}
export function PreloadLink({ to, preloadQuery, children }: PreloadLinkProps) {
return (
<Link
to={to}
onMouseEnter={() => {
// 预取数据
queryClient.prefetchQuery({
queryKey: preloadQuery().then(q => q.queryKey) as QueryKey,
queryFn: preloadQuery as any,
});
}}
>
{children}
</Link>
);
}
策略 2:Intersection Observer 预加载
链接滚动进入视口时加载数据:
// app/hooks/useIntersectionPreload.ts
import { useEffect, useRef } from 'react';
import { queryClient } from '../lib/query-client';
export function useIntersectionPreload(
selector: string,
preloadFn: () => void
) {
const observerRef = useRef<IntersectionObserver>();
useEffect(() => {
observerRef.current = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
preloadFn();
}
});
},
{ rootMargin: '200px' } // 在可见前 200px 就开始加载
);
document.querySelectorAll(selector).forEach((el) => {
observerRef.current?.observe(el);
});
return () => observerRef.current?.disconnect();
}, [selector, preloadFn]);
}
策略 3:路由进入时的急切预加载
路由加载时预取兄弟路由:
// app/routes/dashboard.tsx
// 在 dashboard 组件中预加载相关路由
export function DashboardComponent() {
const { navigate } = useNavigate();
// dashboard 加载时,预取用户接下来常去的路由数据
useEffect(() => {
// 急切预取 settings 数据
queryClient.prefetchQuery({
queryKey: ['settings'],
queryFn: fetchSettings,
});
// 有权限的话预取 reports 数据
queryClient.prefetchQuery({
queryKey: ['reports'],
queryFn: fetchReports,
});
}, []);
return (
<div>
{/* Dashboard 内容 */}
</div>
);
}
加载状态:消灭转圈的模式
关键洞察:加载状态应该是乐观的,而不是被动的。在路由 loader 里处理 pending 状态,而不是组件里。
Pending 组件模式
// app/router.ts
import { createRouter } from '@tanstack/react-router';
import { rootRoute } from './routes/__root';
const rootRouteWithPending = rootRoute.addChildren([
// ... 你的路由
]);
export const router = createRouter({
routeTree: rootRouteWithPending,
defaultPendingComponent: () => (
<div className="flex items-center justify-center h-screen">
<div className="animate-pulse flex flex-col items-center gap-4">
<div className="w-12 h-12 bg-blue-500 rounded-full" />
<p className="text-gray-500">Loading...</p>
</div>
</div>
),
pendingComponent: ({ isNavigation }: { isNavigation: boolean }) => {
if (!isNavigation) return null;
return (
<div className="animate-pulse p-4">
<div className="h-4 bg-gray-200 rounded w-3/4 mb-4" />
<div className="h-4 bg-gray-200 rounded w-1/2 mb-4" />
<div className="h-4 bg-gray-200 rounded w-5/6" />
</div>
);
},
});
组件中的骨架屏
对已经在 DOM 里、只等数据的内容:
// app/components/Skeleton.tsx
export function Skeleton({ className }: { className?: string }) {
return <div className={`animate-pulse bg-gray-200 rounded ${className}`} />;
}
// 在路由组件中使用
export function UserProfile() {
const { user } = useLoaderData({ from: Route.id });
return (
<div className="space-y-4">
<div className="flex items-center gap-4">
{user.avatar ? (
<img src={user.avatar} alt={user.name} className="w-12 h-12 rounded-full" />
) : (
<Skeleton className="w-12 h-12 rounded-full" />
)}
<div>
<Skeleton className="h-5 w-32 mb-2" />
<Skeleton className="h-4 w-24" />
</div>
</div>
</div>
);
}
搜索参数管理
URL 搜索参数对可分享状态非常强大。关键是用 Zod 做校验。
模式
// app/routes/posts.tsx
import { createFileRoute } from '@tanstack/react-router';
import { z } from 'zod';
const postsSearchSchema = z.object({
page: z.number().int().positive().default(1),
limit: z.number().int().positive().max(100).default(20),
sort: z.enum(['newest', 'oldest', 'popular']).default('newest'),
search: z.string().optional(),
tag: z.string().optional(),
});
export const Route = createFileRoute('/posts')({
validateSearch: postsSearchSchema.parse,
loader: async ({ search: { page, limit, sort, search, tag } }) => {
return {
posts: await fetchPosts({ page, limit, sort, search, tag }),
pagination: { page, limit },
};
},
component: PostsComponent,
});
function PostsComponent() {
const { posts, pagination } = useLoaderData({ from: Route.id });
const navigate = useNavigate({ from: Route.fullPath });
const search = useSearch({ from: Route.fullPath });
const updateSearch = (updates: Partial<typeof search>) => {
navigate({
search: { ...search, ...updates, page: 1 }, // 筛选变化时重置到第 1 页
replace: true,
});
};
return (
<div>
<input
type="search"
value={search.search || ''}
onChange={(e) => updateSearch({ search: e.target.value })}
placeholder="Search posts..."
/>
<div className="flex gap-2">
<button onClick={() => updateSearch({ tag: 'featured' })}>
Featured
</button>
<button onClick={() => updateSearch({ tag: undefined })}>
All
</button>
</div>
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
<Pagination
page={pagination.page}
onPageChange={(page) => navigate({ search: { ...search, page } })}
/>
</div>
);
}
可分享的 URL
这个模式的妙处:用户可以收藏筛选后的视图、分享带特定搜索词的链接、用浏览器前进/后退导航。URL 成为视图状态的事实来源。
/posts?page=2&sort=popular&tag=react
/posts?search=tutorial&sort=newest
常见错误与规避方法
错误 1:把守卫写在组件里
// ❌ 错误:守卫逻辑在组件里
function Dashboard() {
const { user } = useContext(AuthContext);
if (!user) return <Navigate to="/login" />;
// ...
}
// ✅ 正确:守卫在 beforeLoad
const dashboardRoute = new Route({
path: '/dashboard',
beforeLoad: ({ context }) => {
if (!context.user) throw new Redirect({ to: '/login' });
},
component: Dashboard,
});
错误 2:不处理 pending 状态
没有 pending 状态,用户导航时会看到白屏。TanStack Router 内置的 pending 组件会自动处理——用它。
错误 3:不安全的搜索参数更新
// ❌ 错误:不重置分页
navigate({ search: { ...search, tag: 'react' } });
// ✅ 正确:重置到第 1 页
navigate({
search: { ...search, tag: 'react', page: 1 },
replace: true,
});
实施指南:什么场景用哪个模式
决策框架
| 场景 | 模式 | 实现复杂度 | 性能收益 |
|---|---|---|---|
| 认证检查 | beforeLoad 守卫 | 低(30 分钟) | 高(阻止未授权请求) |
| 导航性能 | 悬停预加载 | 中(2 小时) | 中(提升 200-500ms) |
| 长页面 | Intersection observer | 中(3 小时) | 中(体感提升) |
| 兄弟路由 | 急切预加载 | 低(1 小时) | 高(近乎即时导航) |
| 可分享筛选 | Zod 搜索参数 | 中(2 小时) | 无(UX 功能非性能) |
性能基准
在 TanStack Ship 生产应用上实测(2026 年 8 月):
| 策略 | 可交互时间 | 体感导航速度 | 实现成本 |
|---|---|---|---|
| 无预加载(基线) | 2.8s | 1.0x | 0 小时 |
| 悬停预加载 | 2.8s | 1.3x | 2 小时 |
| Intersection observer | 2.8s | 1.4x | 3 小时 |
| 两种策略组合 | 2.8s | 1.6x | 5 小时 |
关键洞察: 预加载不会改善原始可交互时间,但会大幅改善体感导航速度。即使指标看不出来,用户也能感觉到差别。
迁移清单
从现有 TanStack Router 配置迁移:
- 审计现有守卫:把认证逻辑从组件移到
beforeLoad - 加 pending 组件:用骨架屏替换白屏
- 实现悬停预加载:先只做导航链接
- 加搜索校验:用 Zod schema 包住现有参数
- 测试竞态条件:验证快速导航时守卫仍然有效
- 测量性能:用 Lighthouse 前后对比验证收益
结论
TanStack Router 的这些模式在生产环境有效——前提是你正确使用。最重要的三个模式:
beforeLoad里的路由守卫:类型安全、路由作用域、可测试- 有意图的预加载:悬停和 intersection observer 是值得实现的两种策略
- 带 Zod 校验的搜索参数:安全、可维护的可分享 URL
这些模式已实现在 TanStack Ship 的router 模块里。克隆 starter 看实际效果。
FAQ
三种模式都要用吗?
不要。从路由守卫开始——它对任何需要认证的应用都是必需的。有导航性能投诉再加预加载。需要可分享的筛选视图再加搜索参数。每个模式都增加复杂度;只实现你需要的。
怎么测试路由守卫?
// 在测试套件里
import { render } from '@testing-library/react'
import { router } from './router'
test('redirects unauthenticated users from dashboard', async () => {
const { navigate } = render(<App />)
// 模拟未登录用户
vi.mock('./auth', () => ({
getCurrentUser: () => null
}))
await navigate({ to: '/dashboard' })
// 应该在 login,不是 dashboard
expect(router.state.location.href).toBe('/login?redirect=%2Fdashboard')
})
类中间件模式呢?
我试过用 TanStack Router 的中间件系统(是的,它有一个)实现类中间件模式。对我的场景只增加复杂度没有收益。坚持用 beforeLoad,除非你有真正需要中间件的横切关注点。
预加载策略在移动端有效吗?
有效,但要注意:
- 悬停预加载:触屏设备无效(没有 hover 事件)
- Intersection observer:移动端表现良好
- 急切预加载:所有设备通用
移动优先的应用优先考虑 intersection observer 或急切预加载。
能组合多种预加载策略吗?
能,而且为了最优性能你应该组合。TanStack Ship 同时使用悬停和 intersection observer 预加载。关键是去重——TanStack Query 不会重新拉取已在缓存里的数据。
延伸阅读
- TanStack Query 集成 - 路由加载与 TanStack Query 结合
- 高级 TypeScript 模式 - 类型安全的路由定义
- 性能监控 - 量化你的优化收益
想看更多? 看看我这篇 TanStack Query 与 Server Functions 集成,了解如何组合路由加载与 TanStack Query 实现最优缓存。