陌上人如玉
公子世无双

6.11 TanStack Router

6.11 TanStack Router

一句话: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 在哪一行」
//    · 「正在输入的草稿」(⭐ 会造成「每个字符一次历史记录」)

六、面试延伸

  1. 「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 即预加载 → 瞬时导航)。

  2. 「为什么说『把筛选/分页状态放进 URL』比放进 useState 好?」
    四个收益:① ⭐ 可分享/可收藏——?q=键盘&page=2&sort=price 发给同事,对方看到完全一样的屏(这是「搜索/列表页」的基本要求);② ⭐ 刷新不丢——硬刷新后从 URL 恢复(useState 一刷新就没了);③ ⭐ 前进后退符合直觉——「排序 → 进详情 → 后退」能回到原来的排序与位置;④ ⭐ 配合缓存能「秒回」——loaderDeps 参与缓存 key,后退时若还在 staleTime 内不重新请求。判据是「用户希望分享/后退时保留吗」——希望 → URL;否则(「某个下拉展开了」「Modal 开着」)→ 组件内 state。⚠️ 但「正在输入的草稿」不该进 URL(否则每个字符一条历史记录)。

  3. 「search schema 里的 .catch() 有什么用?」
    它让「非法的 search 值自动回落到默认值」,而不是「抛错导致白屏」。为什么必要:① 用户可能手改 URL(?page=abc、?pageSize=999);② 旧链接里可能是旧格式(?size=20 而现在是 pageSize);③ 外部系统生成的链接不可控。⚠️ 没有 catch 时,z.parse 会抛错 → errorComponent → 用户看到「错误页」(而不是「默认筛选的列表」)。⭐ 另一种合理做法是「抛错 + 一个友好的 errorComponent(提示『筛选条件无效,已重置』)」,但默认值静默修正对用户更友好。注意:catch 与 default 不同——default 只处理 undefined,catch 处理任何解析失败。

  4. 「loader 和 useEffect 拉数据有什么区别?」
    五个差异:① ⭐ 时机——loader 在「进入路由之前」跑完(组件里直接拿到数据,没有「先渲染空态再填数据」的中间态);② ⭐ 加载态/错误态内建(pendingComponent/errorComponent,不用自己写 isLoading/error 三个 state);③ ⭐ 取消与竞态内建(abortController 在离开路由时自动 abort,避免「旧请求写进新页面」);④ ⭐ 与预加载天然配合(defaultPreload: 'intent' → hover 就跑 loader → 点进去数据已就绪);⑤ ⭐ 参与路由缓存(staleTime/gcTime + loaderDeps 决定「什么时候该重新请求」)。代价:loader 是「阻塞导航」的——如果 loader 很慢,用户会「等」(⭐ 所以「慢 loader」要配 pendingComponent,或者把「非关键数据」放到组件里用 React Query 拉)。

  5. 「beforeLoad 和「在组件里用 useEffect 检查权限」有什么区别?」
    ⭐ 核心差别是「时机」:beforeLoad 在「渲染之前」执行,throw redirect(...) → 这个路由的组件根本不会被渲染(所以没有「闪一下」);而「组件里 useEffect 检查 + navigate」必然先渲染一帧(用户看到「页面内容一闪而过」再跳到登录页),而且「那一帧里可能已经发出了不该发的请求」。此外 beforeLoad 还适合:① 把「鉴权结果」注入 context(供子路由/组件用);② 做「重定向」(如「旧路径 → 新路径」);③ 提前「预加载关键数据」(给 context 注入)。⚠️ 但它应该只做「轻量操作」(鉴权、重定向、轻量 context)——重计算放进去会「阻塞所有导航」。

  6. 「loaderDeps 为什么必要?」
    因为它声明「哪些输入会改变 loader 的结果」——这直接决定两件事:① ⭐ 什么时候重新执行 loader(依赖变了才重新跑);② ⭐ 缓存的 key(不同 deps → 不同缓存条目)。两个方向的错误:① ⭐ 漏了字段——改了 sort 但不重新请求(用户看到「排序没生效」,最难查);② ⭐ 多写了字段——把「不影响数据的 search」也放进去 → 「改个 UI 状态就重新请求」(浪费)。做法:「把所有『会传给接口』的字段都列进去,别多别少」。⚠️ 注意 loaderDeps 的返回值会被结构化比较(所以别返回「每次都是新引用的对象」——虽然 Router 内部会做深比较,但「只返回用到的字段」更清晰)。

  7. 「TanStack Router 的缓存和 React Query 有什么区别?需要一起用吗?」
    Router 的内建缓存是「以路由为单位的、面向导航的缓存」——它解决的是「同一个路由在 staleTime 内不重复请求」(⭐ 尤其是「后退回去」的场景),粒度是「路由 + loaderDeps」。React Query 的缓存是「以 queryKey 为单位的、面向数据的缓存」——它提供「增量更新、突变后失效、轮询、乐观更新、离线、无限查询、依赖查询」等数据层能力。是否需要一起用:① 简单场景(「进页面拉一次、翻页、筛选」)→ Router 的 loader 够用,不必再引 React Query;② 需要「突变后重取、乐观更新、轮询/实时、离线」 → ⭐ 一起用(把 queryClient 放进 Router 的 context,在 loader 里 ensureQueryData,在组件里 useQuery)。实践比例:后台系统里「两者一起用」很常见。

  8. 「如果让你选,项目里会用 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」。

赞(0) 打赏
未经允许不得转载:陌上寒 » 6.11 TanStack Router

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫