
一句话:TanStack Router 的核心卖点是「把 URL(路径 + params + search params)当成『有类型的结构化数据』」——所以它做到了 100% 类型安全的导航与 search 状态,并把「数据加载(loader)/ 鉴权(beforeLoad)/ 加载态与错误态」变成路由的一等公民。
📌 本篇是「TanStack Router 的定位与用法」;「React Router 的通用用法」见
6.1 React Router v6 核心 API、6.2 路由配置方式。
一、它解决的三个「React Router 的痛」
⭐⭐⭐ React Router 在生产项目里的三个常见痛点:
① 【类型不安全】
const { id } = useParams(); // ⚠️ id 是 string | undefined
const [sp] = useSearchParams();
sp.get('page'); // ⚠️ string | null,还要自己 Number() 和校验
<Link to={`/user/${id}`} /> // ⚠️ 路径写错了「运行时才发现」
→ ⭐ 结论:「URL 是一等公民的数据源,但类型系统完全帮不上忙」
② 【search params 是「二等公民」】
要手写:
const page = Number(sp.get('page') ?? '1');
const q = sp.get('q') ?? '';
// ⚠️ 校验、默认值、序列化、类型 全靠自己
→ 于是「筛选/分页/排序状态」很容易「散落在 useState 与 URL 之间」
(⭐ 导致「刷新丢失」「分享链接看不到同一屏」「后退行为诡异」)
③ 【数据加载与鉴权是「外挂的」】
要用「useEffect + useState」自己写:
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => { ... }, [id]);
→ ⚠️ 「加载态/错误态/竞态/取消」全都要自己处理(见 7.2)
→ ⚠️ 而且「鉴权」要写在「组件里」(先渲染再跳走 → 「闪一下」)
// ✅ TanStack Router 的写法(同样的需求)
import { createFileRoute, redirect } from '@tanstack/react-router';
import { z } from 'zod';
// ⭐ ① search 用 schema 定义(校验 + 默认值 + 类型一次搞定)
const productsSearchSchema = z.object({
page: z.number().int().min(1).catch(1), // ⭐ catch:非法值自动回落(而不是报错)
pageSize: z.union([z.literal(20), z.literal(50), z.literal(100)]).catch(20),
q: z.string().catch(''),
sort: z.enum(['price', 'createdAt', 'sales']).catch('createdAt'),
order: z.enum(['asc', 'desc']).catch('desc'),
tags: z.array(z.string()).catch([]), // ⭐ 数组也能表达(会自动序列化成 ?tags=a&tags=b)
});
export const Route = createFileRoute('/products')({
// ⭐ ② 校验 search(并在「URL 里的值非法」时「用默认值修正」,而不是崩)
validateSearch: productsSearchSchema,
// ⭐⭐ ③ beforeLoad:进入这个路由「之前」鉴权(⭐ 不会先渲染再跳走)
beforeLoad: async ({ context, location }) => {
if (!context.auth.isLoggedIn) {
throw redirect({
to: '/login',
// ⭐ 带上「我想去哪」(登录后跳回来)
search: { redirect: location.href },
});
}
},
// ⭐⭐ ④ loaderDeps:声明「哪些 search/params 会触发『重新加载数据』」
loaderDeps: ({ search }) => ({
page: search.page,
pageSize: search.pageSize,
q: search.q,
sort: search.sort,
order: search.order,
tags: search.tags,
}),
// ⭐⭐ ⑤ loader:数据加载(⭐ 返回值有类型,且可以 await)
loader: async ({ deps, abortController }) => {
// ⭐ 支持取消(离开路由时自动 abort)
const res = await fetchProducts(deps, { signal: abortController.signal });
return res; // ⭐ 类型会自动推导到 useLoaderData
},
// ⭐ ⑥ 缓存策略(内建,不需要额外接 React Query)
staleTime: 30_000, // 30 秒内「不重新请求」
gcTime: 5 * 60_000, // 5 分钟后「释放缓存」
// ⭐ ⑦ 加载态 / 错误态(内建,不用自己写 state 机)
pendingComponent: () => <ProductsSkeleton />,
errorComponent: ({ error, reset }) => (
<div role="alert">
加载失败:{String(error)}
<button onClick={reset}>重试</button>
</div>
),
component: ProductsPage,
});
// ✅ 组件里:类型全部自动推断
function ProductsPage() {
// ⭐ search 有完整类型(page: number、sort: 'price' | 'createdAt' | 'sales'…)
const { page, pageSize, q, sort, order, tags } = Route.useSearch();
// ⭐ loader 的返回值有类型(不需要泛型、不需要断言)
const { items, total } = Route.useLoaderData();
// ⭐ navigate 的入参也被类型约束(to 写错了编译期就报错)
const navigate = Route.useNavigate();
return (
<div>
<SearchBox
value={q}
onChange={(next) =>
// ⭐⭐ search 用「函数式更新」(拿到 prev,避免丢字段)
navigate({ search: (prev) => ({ ...prev, q: next, page: 1 }) })
}
/>
{/* ⭐ 排序:把「排序状态」放进 URL(可分享、可后退) */}
<button
onClick={() =>
navigate({
search: (prev) => ({
...prev,
sort: 'price',
order: prev.sort === 'price' && prev.order === 'desc' ? 'asc' : 'desc',
}),
})
}
>
按价格排序 {sort === 'price' ? (order === 'desc' ? '↓' : '↑') : ''}
</button>
<ul>
{items.map((p) => (
// ⭐ Link 的 to/params/search 全部类型安全
<li key={p.id}><Link to="/products/$id" params={{ id: p.id }}>{p.name}</Link></li>
))}
</ul>
<Pagination
page={page}
pageSize={pageSize}
total={total}
onChange={(next) => navigate({ search: (prev) => ({ ...prev, page: next }) })}
/>
</div>
);
}
// ⭐ 甚至「读其它路由的 search」也有类型
function CartBadge() {
const { q } = Route.useSearch(); // ⭐ 类型来自「这个 Route 的 schema」
// ...
}
⭐⭐⭐ 三个「质变」(对比 React Router 的写法):
① 【URL 是「有类型的结构化数据」】
· 路径参数:`/products/$id` → `params.id` 有类型
· search:**用 schema 定义** → 校验 + 默认值 + 序列化 + 类型「一次搞定」
⭐ 于是「筛选/分页/排序」可以「全放 URL」——
刷新不丢、链接可分享、前进后退符合直觉。
(对比:手写 `Number(sp.get('page') ?? '1')` 到处都是,且**非法值会让页面崩**。)
② 【数据加载是「路由的职责」】
`loader` 在「进入路由之前」跑完 → 组件里**直接拿到数据**(不用 `useEffect`):
· 自动处理「加载态 / 错误态 / 竞态 / 取消」(`abortController`)
· ⭐ 与「预加载(preload)」天然配合:**hover 时就跑 loader** →
点进去「数据已经在了」(⭐ 这就是「瞬时导航」)
· 内建 `staleTime` / `gcTime`(**不需要再叠一个 React Query**,虽然也能集成)
③ 【鉴权在「渲染之前」】
`beforeLoad` 抛 `redirect` → **不会「先渲染页面再跳登录页」**(⭐ 消除了「闪一下」)。
(对比:React Router 的「路由守卫」通常是「组件里 useEffect 检查 + navigate」,
必然先渲染一帧 → 用户看到「内容一闪而过」。)
二、与 React Router 的对比
| 维度 | React Router(v6/v7) | TanStack Router |
|---|---|---|
| 类型安全 | ⚠️ 路径/params 基本靠手写泛型;to 拼字符串 |
⭐ 路径、params、search、loader 全类型安全 |
| search params | useSearchParams(string 层面的操作) |
⭐ schema 定义 + 校验 + 类型 + 默认值 |
| 数据加载 | ⚠️ 靠 loader(v6.4+)或自己 useEffect |
⭐ loader 是一等公民(+ loaderDeps + 缓存 + 预加载) |
| 鉴权 | loader 里 redirect 或组件里判断 |
⭐ beforeLoad(渲染前) |
| 缓存 | ❌ 无内建(要接 React Query/SWR) | ⭐ 内建 staleTime/gcTime |
| 预加载 | 手动 | ⭐ defaultPreload: 'intent'(hover 即预加载) |
| 文件路由 | v7 支持(framework mode) | ⭐ 支持(且「类型生成」完整) |
| 生态与心智 | ⭐ 最主流、文档多、求职常见 | 较新、社区小、心智要适应 |
| 体积 | 较小 | 较大(功能多) |
⭐⭐ 选型建议(现实一点):
✅ 用 **TanStack Router** 的场景:
· 项目**重度依赖 URL 状态**(后台系统、电商筛选、数据看板)
——搜索参数多、要「可分享 + 可后退 + 刷新不丢」
· 团队**重视类型安全**(TS 严格模式、讨厌 `as`)
· 愿意接受「较新的生态」(文档/答案相对少)
✅ 用 **React Router** 的场景:
· 中小项目、路由简单(路径 + 少量参数)
· 团队熟悉它(**降低认知成本**往往比「类型更安全」更重要)
· 需要「最主流的方案」(招聘/交接/第三方库集成)
· ⭐ 已经用了 React Query 之类的数据层(那 Router 的 loader 优势就没那么关键)
⭐ 一句话:「**URL 状态越复杂、越想要类型安全 → TanStack Router 的收益越大**」;
反之「路由简单、生态熟悉度优先 → React Router」。
三、关键 API 速查(含易错点)
// ============ 1. 路由定义(代码式,也可以用文件路由) ============
import { createRouter, createRootRoute, createRoute } from '@tanstack/react-router';
const rootRoute = createRootRoute({
component: () => (
<>
<Nav />
<Outlet /> {/* ⭐ 子路由渲染在这里 */}
</>
),
notFoundComponent: () => <NotFound />,
});
const productsRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'products',
validateSearch: productsSearchSchema,
loader: ...,
component: ProductsPage,
});
// ⭐ 带「路径参数」的子路由(⭐ `$id` 表示参数)
const productDetailRoute = createRoute({
getParentRoute: () => productsRoute,
path: '$id', // ⭐ 完整路径是 /products/$id
loader: ({ params, abortController }) =>
fetchProduct(params.id, { signal: abortController.signal }),
component: ProductDetailPage,
});
const routeTree = rootRoute.addChildren([productsRoute.addChildren([productDetailRoute])]);
export const router = createRouter({
routeTree,
// ⭐⭐ 这个配置是「瞬时导航」的关键
defaultPreload: 'intent', // 'intent'(hover/focus 时预加载)| 'render' | 'viewport' | false
defaultPreloadStaleTime: 30_000, // 预加载的结果 30 秒内有效
defaultPendingComponent: () => <TopProgressBar />,
// ⭐ context:注入「路由之外的东西」(鉴权、queryClient、i18n)
context: { auth: undefined as unknown as AuthContext },
// ⭐ 滚动恢复(默认开启)
scrollRestoration: true,
});
// ============ 2. 一个「无权限」与「404」的正确区分 ============
// ⭐⭐⭐ 常见错误:把「无权限」也当成 404(或反之)——
// 用户会「不知道自己是没有权限还是页面不存在」
const orderDetailRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'orders/$orderId',
beforeLoad: async ({ params, context }) => {
// ⭐ ① 全局鉴权(未登录 → 去登录页)
if (!context.auth.isLoggedIn) {
throw redirect({ to: '/login', search: { redirect: `/orders/${params.orderId}` } });
}
// ⭐ ② 精细权限(有登录但没这个权限 → 明确的 403)
if (!context.auth.hasPermission('order:read')) {
throw new Error('FORBIDDEN'); // ⭐ 会被 errorComponent 接管
}
},
// ⭐ ③ 「资源不存在」由 loader 抛 notFound(→ notFoundComponent)
loader: async ({ params }) => {
const order = await fetchOrder(params.orderId);
if (!order) throw notFound(); // ⭐ 明确的 404
return order;
},
errorComponent: ({ error }) => {
// ⭐ 区分「权限不足」与「其它错误」
if (String(error).includes('FORBIDDEN')) {
return <ForbiddenPage />; // 403 页面
}
return <ErrorPage error={error} />;
},
component: OrderDetailPage,
});
// ============ 3. 命令式导航与「函数式 search 更新」 ============
function Toolbar() {
const navigate = useNavigate();
const search = Route.useSearch();
return (
<button
onClick={() =>
// ⭐⭐ 关键:用「函数式」更新 search(拿到 prev)
// 否则「只写自己关心的字段」会把其它字段丢掉
navigate({
to: '/products',
search: (prev) => ({ ...prev, page: 1 }), // ⭐ 重置分页但保留筛选
replace: true, // ⭐ 用 replace(不产生历史记录)
})
}
>
重置分页
</button>
);
}
⭐⭐⭐ 三个「必须做对」的 search 细节(用错会出诡异 bug):
① 【用「函数式更新」而不是「整体替换」】
navigate({ search: { page: 2 } }); // ❌ 其它 search 字段全丢
navigate({ search: (prev) => ({ ...prev, page: 2 }) }); // ✅
② 【用 schema 的 `.catch()` 让「非法值」自动回落到默认值】
z.number().int().min(1).catch(1)
⭐ 为什么重要:用户可能手改 URL(`?page=abc`)、
或「旧链接里是旧格式」→ 没有 `catch` 就会「抛错 → 页面白屏」。
⚠️ 另一种做法是「抛错 + errorComponent」,但「静默修正」对用户更友好。
③ 【`replace: true` 的取舍】
· 「筛选条件变化」→ ⭐ 用 `replace`(用户按后退不该退到「上一个筛选」)
· 「进入详情页」→ 不用 `replace`(后退应该回到列表)
⭐ 判据:「**用户按『后退』时期望回到哪**」。
四、完整实现:一个类型安全的商品搜索页(220 行)
// routes/products.tsx —— 完整示例(含 URL 驱动的筛选/分页/排序)
import { createFileRoute, Link, notFound, redirect } from '@tanstack/react-router';
import { z } from 'zod';
import { useCallback, useMemo, useState } from 'react';
/** ============ 1. search schema(⭐ 单一事实源) ============ */
export const productsSearchSchema = z.object({
// ⭐ 每个字段都带 catch(非法值 → 默认值)
q: z.string().trim().catch(''),
page: z.coerce.number().int().min(1).catch(1),
pageSize: z.coerce.number().int().refine((v) => [20, 50, 100].includes(v)).catch(20),
sort: z.enum(['relevance', 'price', 'sales', 'createdAt']).catch('relevance'),
order: z.enum(['asc', 'desc']).catch('desc'),
// ⭐ 数组:URL 里表现为 `?tags=a&tags=b`
tags: z.array(z.string()).catch([]),
// ⭐ 布尔:URL 里表现为 `?inStock=1`
inStock: z
.union([z.literal('1'), z.literal('0'), z.boolean()])
.transform((v) => v === '1' || v === true)
.catch(false),
});
type ProductsSearch = z.infer<typeof productsSearchSchema>;
/** ============ 2. 数据层(真实项目里放在 api/ 目录) ============ */
interface Product { id: string; name: string; price: number; sales: number; createdAt: string; inStock: boolean }
interface ProductsResult { items: Product[]; total: number }
async function fetchProducts(
deps: Pick<ProductsSearch, 'q' | 'page' | 'pageSize' | 'sort' | 'order' | 'tags' | 'inStock'>,
opts: { signal: AbortSignal }
): Promise<ProductsResult> {
const params = new URLSearchParams();
params.set('q', deps.q);
params.set('page', String(deps.page));
params.set('pageSize', String(deps.pageSize));
params.set('sort', deps.sort);
params.set('order', deps.order);
params.set('inStock', deps.inStock ? '1' : '0');
deps.tags.forEach((t) => params.append('tags', t));
const res = await fetch(`/api/products?${params}`, { signal: opts.signal });
if (!res.ok) throw new Error(`请求失败:${res.status}`);
return res.json();
}
/** ============ 3. 路由定义 ============ */
export const Route = createFileRoute('/products')({
validateSearch: productsSearchSchema,
beforeLoad: ({ context, location }) => {
if (!context.auth.isLoggedIn) {
throw redirect({ to: '/login', search: { redirect: location.href } });
}
},
// ⭐⭐ 只声明「会触发重新加载」的 search(否则「改 sort 不该重新请求」之类会被误判)
loaderDeps: ({ search }) => ({
q: search.q,
page: search.page,
pageSize: search.pageSize,
sort: search.sort,
order: search.order,
tags: search.tags,
inStock: search.inStock,
}),
loader: async ({ deps, abortController }) => {
// ⚠️ 注意:分页「超出范围」时的处理
const result = await fetchProducts(deps, { signal: abortController.signal });
if (result.items.length === 0 && result.total > 0 && deps.page > 1) {
// ⭐ 数据变化导致「当前页空了」→ 回到最后一页(避免「空白页」)
const lastPage = Math.ceil(result.total / deps.pageSize);
throw redirect({ to: '/products', search: (prev) => ({ ...prev, page: lastPage }) });
}
return result;
},
staleTime: 20_000,
pendingComponent: () => <ListSkeleton rows={6} />,
errorComponent: ({ error, reset }) => (
<div role="alert" style={{ padding: 16 }}>
加载失败:{String(error)}
<button onClick={reset}>重试</button>
</div>
),
component: ProductsPage,
});
/** ============ 4. 页面组件 ============ */
function ProductsPage() {
const search = Route.useSearch();
const { items, total } = Route.useLoaderData();
const navigate = Route.useNavigate();
// ⭐ 搜索框用「本地 state + 防抖」,避免「每敲一个字符就改 URL」
const [keyword, setKeyword] = useState(search.q);
const commitKeyword = useCallback(
(value: string) => {
navigate({
// ⭐⭐ 函数式更新(保留其它字段);⭐ replace(否则后退要退 10 次)
search: (prev) => ({ ...prev, q: value, page: 1 }),
replace: false, // ⭐ 「搜索」是一次「新导航」,保留历史
});
},
[navigate]
);
const setPage = useCallback(
(page: number) => {
navigate({ search: (prev) => ({ ...prev, page }), replace: true });
// ⭐ 翻页用 replace(用户按后退不该退到「上一页」)—— 这是产品决策,可调整
},
[navigate]
);
const toggleTag = useCallback(
(tag: string) => {
navigate({
search: (prev) => ({
...prev,
tags: prev.tags.includes(tag) ? prev.tags.filter((t) => t !== tag) : [...prev.tags, tag],
page: 1,
}),
replace: true,
});
},
[navigate]
);
const totalPages = useMemo(() => Math.max(1, Math.ceil(total / search.pageSize)), [total, search.pageSize]);
return (
<div style={{ display: 'grid', gap: 16 }}>
{/* 搜索框 */}
<form
onSubmit={(e) => { e.preventDefault(); commitKeyword(keyword); }}
role="search"
>
<input
type="search"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索商品"
aria-label="搜索商品"
/>
<button type="submit">搜索</button>
{search.q && (
<button type="button" onClick={() => { setKeyword(''); commitKeyword(''); }}>
清除
</button>
)}
</form>
{/* 筛选:标签(⭐ 状态在 URL 里 → 可分享、可后退) */}
<div role="group" aria-label="标签筛选">
{['新品', '热销', '折扣'].map((tag) => (
<button
key={tag}
aria-pressed={search.tags.includes(tag)}
onClick={() => toggleTag(tag)}
>
{tag}
</button>
))}
</div>
{/* 排序 */}
<label>
排序:
<select
value={`${search.sort}:${search.order}`}
onChange={(e) => {
const [sort, order] = e.target.value.split(':') as [ProductsSearch['sort'], ProductsSearch['order']];
navigate({ search: (prev) => ({ ...prev, sort, order }) });
}}
>
<option value="relevance:desc">相关度</option>
<option value="price:asc">价格从低到高</option>
<option value="price:desc">价格从高到低</option>
<option value="sales:desc">销量</option>
<option value="createdAt:desc">最新</option>
</select>
</label>
{/* 列表 */}
共 {total} 件商品
<ul style={{ listStyle: 'none', padding: 0, display: 'grid', gap: 8 }}>
{items.map((p) => (
<li key={p.id}>
{/* ⭐ 类型安全:to + params 都受约束 */}
<Link to="/products/$id" params={{ id: p.id }}>{p.name}</Link>
<span> ¥{p.price}</span>
{!p.inStock && <span> (缺货)</span>}
</li>
))}
</ul>
{/* 分页 */}
<nav aria-label="分页">
<button disabled={search.page <= 1} onClick={() => setPage(search.page - 1)}>上一页</button>
<span>{search.page} / {totalPages}</span>
<button disabled={search.page >= totalPages} onClick={() => setPage(search.page + 1)}>下一页</button>
</nav>
</div>
);
}
function ListSkeleton({ rows }: { rows: number }) {
return (
<div aria-busy="true" aria-live="polite">
{Array.from({ length: rows }, (_, i) => (
<div key={i} style={{ height: 40, background: '#f0f0f0', borderRadius: 6, marginBottom: 8 }} />
))}
</div>
);
}
⭐⭐⭐ 这个实现展示了四个「URL 驱动状态」的收益:
① 【可分享 / 可收藏】
用户把 `?q=键盘&page=2&sort=price&order=asc&tags=新品` 发给同事 →
⭐ 对方看到「完全一样的屏」(⭐ 这是「搜索页」的基本要求)
② 【刷新不丢】
硬刷新后 `search` 从 URL 恢复 → 页面回到原样
(对比:`useState` 一刷新就没了)
③ 【前进后退符合直觉】
用户点了「排序」→「进详情」→「后退」→ 回到「同样的排序 + 位置」
④ 【`loaderDeps` + 缓存 = 不重复请求】
⭐ 「后退回到上一屏」时,如果还在 `staleTime` 内 → **不重新请求**(秒回)
这正是「TanStack Router 内建缓存」的价值。
五、本篇特有的坑
// ① 用「整体替换」更新 search(丢掉其它字段)
navigate({ search: { page: 2 } }); // ❌ q/sort/tags 全没了
// ✅ navigate({ search: (prev) => ({ ...prev, page: 2 }) });
// ② search schema 没有 `.catch()`(非法 URL → 白屏)
const schema = z.object({ page: z.number().int().min(1) }); // ⚠️ ?page=abc 直接抛错
// ✅ `.catch(1)`(静默修正)或「抛错 + errorComponent」(显式提示)
// ③ 用 `useState` 存「筛选条件」而不放 URL(不可分享、刷新丢失)
const [page, setPage] = useState(1); // ⚠️
// ✅ 放 search(⭐ 这是「搜索/列表页」的默认做法)
// ④ `search` 里塞了「不该进 URL 的东西」(如「当前展开的行」)
// ⚠️ 让 URL 变得很长且难读
// ✅ 判据:「**用户希望分享/后退时保留吗**」——希望 → URL;否则组件内 state
// ⑤ 频繁改 URL(每敲一个字符一次)
onChange={(e) => navigate({ search: { q: e.target.value } })} // ❌ 历史被刷爆 + 请求风暴
// ✅ 本地 state + 提交/防抖后再改 URL
// ⑥ 「翻页」不用 `replace`(历史的每页都进栈 → 后退要点 10 次)
// ✅ 视产品而定(多数后台系统翻页用 replace)
// ⑦ `loaderDeps` 漏了字段(改了那个字段但不重新加载)
loaderDeps: ({ search }) => ({ page: search.page }), // ⚠️ 改 sort 不重新请求
// ✅ 把「所有会影响数据的字段」都列进去
// ⑧ `loaderDeps` 里放了「不影响数据的字段`(多余重新加载)
loaderDeps: ({ search }) => search, // ⚠️ 改「UI 相关字段」也重新请求
// ✅ 只放「真正影响数据」的
// ⑨ `beforeLoad` 里做「重计算」(阻塞导航)
beforeLoad: () => { for (let i = 0; i < 1e7; i++) {} } // ❌
// ✅ beforeLoad 只做「鉴权 / 重定向 / 轻量 context 注入」
// ⑩ 「无权限」与「404」混为一谈(用户不知道自己是什么情况)
// ✅ 分开(403 用 errorComponent / 404 用 notFoundComponent)
// ⑪ 「闪一下再跳走」(用 useEffect 做鉴权)
useEffect(() => { if (!loggedIn) navigate('/login'); }, []); // ⚠️ 先渲染了
// ✅ beforeLoad(渲染前)
// ⑫ 忘记「登录后跳回原地址」
throw redirect({ to: '/login' }); // ⚠️ 登录后就到首页了
// ✅ search: { redirect: location.href } + 登录页读取它
// ⑬ 预加载「过度」(hover 每个链接都预加载 → 请求风暴)
// ⭐ 但 `preload: 'intent'` 有延迟(默认约 50ms 的 intent 判定)
// ✅ 对「重的 loader」加 `preloadStaleTime` 或用 `preload={false}` 关掉个别路由
// ⑭ 服务端渲染时「loader 不跑」或「跑两次」
// ⚠️ SSR 集成需要「把 loader 的结果序列化给客户端」(否则客户端再跑一次)
// ✅ 用框架提供的 SSR 集成(Start / 或手动 dehydrate/hydrate)
// ⑮ 认为「有了 Router 的 loader 就不需要 React Query 了」
// ⭐ 部分对:**简单场景够用**;但如果需要「轮询/实时/突变后重取/乐观更新/离线」
// → 仍需专门的数据层(见 7.4)。✅ 两者可以共存(把 queryClient 放进 router context)
// ⑯ 「路径参数」没有类型(因为没用 `$id` 语法)
path: 'products/:id' // ❌ 不是 TanStack 的语法
// ✅ path: 'products/$id'
// ⑰ 文件路由的「类型生成」没跑(类型变成 any)
// ✅ 确保 `tsr generate`/插件在跑(vite 插件会自动生成 `routeTree.gen.ts`)
// ⑱ 「导航时的滚动位置」没恢复(长列表返回时回到顶部)
// ✅ `scrollRestoration: true`;⭐ 但「自定义滚动容器」需要自己处理(见 6.13)
// ⑲ 「路由级 code splitting」没做(首屏加载所有页面)
// ✅ TanStack Router 支持「文件路由自动分割」(或用 `lazyRouteComponent`)
// ⑳ 在「非组件环境」用 Hook(如「在 axios 拦截器里 navigate」)
// ✅ 用 `router.navigate(...)`(从 router 实例调用)
// ⑫ 的完整处理(登录后跳回)
// 登录页:
const { redirect: redirectTo } = Route.useSearch();
const navigate = useNavigate();
async function onLoginSuccess() {
await navigate({ to: redirectTo ?? '/' , replace: true });
}
// ⚠️ 安全:`redirectTo` 来自 URL → ⭐ 必须校验「是不是站内路径」
// (否则可能被用来「跳到钓鱼站」,见 8.10)
function isSafeRedirect(target: string): boolean {
try {
const url = new URL(target, location.origin);
return url.origin === location.origin; // ⭐ 只允许同源
} catch {
return false;
}
}
// ④ 的完整处理(「哪些字段该进 URL」的判据)
// ✅ 进 URL:
// · 筛选 / 搜索 / 排序 / 分页 (用户期望分享与后退)
// · 当前选中的 Tab / 视图模式 (用户期望后退回去)
// · 详情页的 id (必须)
// ❌ 不进 URL:
// · 「某个下拉展开了」
// · 「某个 Modal 打开着」
// · 「鼠标 hover 在哪一行」
// · 「正在输入的草稿」(⭐ 会造成「每个字符一次历史记录」)
六、面试延伸
- 「TanStack Router 与 React Router 的核心区别是什么?」
三点:① ⭐ 类型安全——TanStack Router 让「路径、params、search、loader 返回值」全部类型安全(to写错、params缺字段都在编译期报错),而 React Router 里「URL 基本靠字符串拼接 + 手写泛型」;② ⭐ search params 是一等公民——TanStack Router 用 schema(zod)定义 search,一次搞定「校验 + 默认值 + 序列化 + 类型」,于是「筛选/分页/排序」可以全放 URL(可分享、可后退、刷新不丢),且非法值能自动回落(catch);③ ⭐ 数据加载与鉴权是路由的职责——loader(+loaderDeps声明「哪些字段会触发重新加载」+ 内建staleTime/gcTime+abortController)与beforeLoad(渲染前鉴权,消除「闪一下再跳走」)。此外还有defaultPreload: 'intent'(hover 即预加载 → 瞬时导航)。 -
「为什么说『把筛选/分页状态放进 URL』比放进
useState好?」
四个收益:① ⭐ 可分享/可收藏——?q=键盘&page=2&sort=price发给同事,对方看到完全一样的屏(这是「搜索/列表页」的基本要求);② ⭐ 刷新不丢——硬刷新后从 URL 恢复(useState一刷新就没了);③ ⭐ 前进后退符合直觉——「排序 → 进详情 → 后退」能回到原来的排序与位置;④ ⭐ 配合缓存能「秒回」——loaderDeps参与缓存 key,后退时若还在staleTime内不重新请求。判据是「用户希望分享/后退时保留吗」——希望 → URL;否则(「某个下拉展开了」「Modal 开着」)→ 组件内 state。⚠️ 但「正在输入的草稿」不该进 URL(否则每个字符一条历史记录)。 -
「search schema 里的
.catch()有什么用?」
它让「非法的 search 值自动回落到默认值」,而不是「抛错导致白屏」。为什么必要:① 用户可能手改 URL(?page=abc、?pageSize=999);② 旧链接里可能是旧格式(?size=20而现在是pageSize);③ 外部系统生成的链接不可控。⚠️ 没有catch时,z.parse会抛错 →errorComponent→ 用户看到「错误页」(而不是「默认筛选的列表」)。⭐ 另一种合理做法是「抛错 + 一个友好的 errorComponent(提示『筛选条件无效,已重置』)」,但默认值静默修正对用户更友好。注意:catch与default不同——default只处理undefined,catch处理任何解析失败。 -
「
loader和useEffect拉数据有什么区别?」
五个差异:① ⭐ 时机——loader在「进入路由之前」跑完(组件里直接拿到数据,没有「先渲染空态再填数据」的中间态);② ⭐ 加载态/错误态内建(pendingComponent/errorComponent,不用自己写isLoading/error三个 state);③ ⭐ 取消与竞态内建(abortController在离开路由时自动 abort,避免「旧请求写进新页面」);④ ⭐ 与预加载天然配合(defaultPreload: 'intent'→ hover 就跑 loader → 点进去数据已就绪);⑤ ⭐ 参与路由缓存(staleTime/gcTime+loaderDeps决定「什么时候该重新请求」)。代价:loader是「阻塞导航」的——如果 loader 很慢,用户会「等」(⭐ 所以「慢 loader」要配pendingComponent,或者把「非关键数据」放到组件里用 React Query 拉)。 -
「
beforeLoad和「在组件里用useEffect检查权限」有什么区别?」
⭐ 核心差别是「时机」:beforeLoad在「渲染之前」执行,throw redirect(...)→ 这个路由的组件根本不会被渲染(所以没有「闪一下」);而「组件里useEffect检查 +navigate」必然先渲染一帧(用户看到「页面内容一闪而过」再跳到登录页),而且「那一帧里可能已经发出了不该发的请求」。此外beforeLoad还适合:① 把「鉴权结果」注入context(供子路由/组件用);② 做「重定向」(如「旧路径 → 新路径」);③ 提前「预加载关键数据」(给context注入)。⚠️ 但它应该只做「轻量操作」(鉴权、重定向、轻量 context)——重计算放进去会「阻塞所有导航」。 -
「
loaderDeps为什么必要?」
因为它声明「哪些输入会改变 loader 的结果」——这直接决定两件事:① ⭐ 什么时候重新执行 loader(依赖变了才重新跑);② ⭐ 缓存的 key(不同 deps → 不同缓存条目)。两个方向的错误:① ⭐ 漏了字段——改了sort但不重新请求(用户看到「排序没生效」,最难查);② ⭐ 多写了字段——把「不影响数据的 search」也放进去 → 「改个 UI 状态就重新请求」(浪费)。做法:「把所有『会传给接口』的字段都列进去,别多别少」。⚠️ 注意loaderDeps的返回值会被结构化比较(所以别返回「每次都是新引用的对象」——虽然 Router 内部会做深比较,但「只返回用到的字段」更清晰)。 -
「TanStack Router 的缓存和 React Query 有什么区别?需要一起用吗?」
Router 的内建缓存是「以路由为单位的、面向导航的缓存」——它解决的是「同一个路由在 staleTime 内不重复请求」(⭐ 尤其是「后退回去」的场景),粒度是「路由 + loaderDeps」。React Query 的缓存是「以 queryKey 为单位的、面向数据的缓存」——它提供「增量更新、突变后失效、轮询、乐观更新、离线、无限查询、依赖查询」等数据层能力。是否需要一起用:① 简单场景(「进页面拉一次、翻页、筛选」)→ Router 的 loader 够用,不必再引 React Query;② 需要「突变后重取、乐观更新、轮询/实时、离线」 → ⭐ 一起用(把queryClient放进 Router 的context,在loader里ensureQueryData,在组件里useQuery)。实践比例:后台系统里「两者一起用」很常见。 -
「如果让你选,项目里会用 TanStack Router 吗?」
⭐ 取决于「URL 状态的复杂度」与「团队的生态偏好」:推荐用的场景——① 项目重度依赖 URL 状态(后台系统、电商筛选、数据看板:搜索参数多、要「可分享 + 可后退 + 刷新不丢」);② 团队重视类型安全(TS 严格模式、讨厌as);③ 能接受「较新的生态」(文档/社区答案相对少)。不用(选 React Router)的场景——① 中小项目、路由简单(路径 + 少量参数);② 团队熟悉 React Router(⭐ 降低认知成本往往比「类型更安全」更重要——新人在社区里更容易搜到答案);③ 需要「最主流的方案」(招聘/交接/第三方集成);④ ⭐ 已经用了 React Query 之类的数据层(那 Router 的 loader 优势就没那么关键了)。结论:「URL 状态越复杂 → TanStack Router 收益越大;生态熟悉度优先 → React Router」。
一句话速记
TanStack Router 的核心是「把 URL(路径 + params + search)当成有类型的结构化数据」——所以它做到 100% 类型安全(
to/params/search/loader 返回值都在编译期检查,路径参数用$id语法);⭐ search params 是一等公民:用 schema(zod)定义 → 「校验 + 默认值 + 序列化 + 类型」一次搞定,⚠️ 每个字段都要.catch()(否则用户手改 URL / 旧链接会让页面白屏)——于是「筛选/分页/排序」全放 URL(可分享、可后退、刷新不丢),更新时必须用「函数式search: (prev) => ({...prev, x})」(整体替换会丢字段),并用replace: true/false表达「后退期望回到哪」;⭐ 数据加载是路由的职责:loader(在「进入路由前」跑完,内建 pending/error/取消/缓存)+loaderDeps(声明「哪些输入会改变结果」→ 决定「何时重新加载」与「缓存 key」,⚠️ 漏字段 = 改了不重新请求,多字段 = 浪费);⭐beforeLoad在「渲染之前」鉴权(throw redirect→ 没有「闪一下再跳走」,且能注入 context),⚠️ 但它只该做「轻量操作」;缓存(staleTime/gcTime)解决的是「路由级导航缓存」(⭐ 后退秒回),与 React Query(queryKey 级、含突变/轮询/乐观更新)定位不同、可以共存(queryClient放进context);defaultPreload: 'intent'(hover 即预加载)是「瞬时导航」的关键;选型判据:「URL 状态越复杂、越要类型安全 → TanStack Router;生态熟悉度/主流优先 → React Router」。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!