banner
约 2,100 字
7 分钟

用好 TanStack Start 的路由层

摘要

写 TanStack Start 项目时,路由层能做的事情远比你想的多。这篇文章以博客的真实代码为例,聊聊怎么用好 beforeLoad、loader 和 head,把权限、校验和 SEO 收拢到正确的地方。

《我的博客项目架构 - Tanstack Start与依赖注入实战》里,我们聊了 TanStack Start 里的中间件和依赖注入。这篇想换个角度,专门聊聊在这个博客项目里,我是怎么理解和使用路由层的。

刚接触这类全栈框架时,最常见的做法就是把路由文件当成一个“页面壳子”:随便拉点数据,然后一股脑全丢给 React 组件去渲染。

但实际跑下来,我发现一旦把所有逻辑都往组件里塞,页面代码会变得非常臃肿。其实,路由层是一个极佳的前置防线,它天然适合处理这些事:

  • 权限守卫(能不能看)

  • 搜索参数校验(参数对不对)

  • 首屏数据预取(尽早发起请求)

  • SEO 元信息(标题和 Meta 标签)

  • 404 和 Loading 边界(异常怎么兜底)

把这些逻辑提上来,你的 React 组件就能干干净净地只负责渲染。

文章详情页的核心逻辑

文章也算是整个博客最核心的入口了。看看我现在的路由配置是怎么写的:

TSX
export const Route = createFileRoute("/_public/post/$slug")({
  validateSearch: searchSchema,
  component: RouteComponent,
  loader: async ({ context, params }) => {
    // 1. 拉取首屏渲染必需的核心数据
    const [post, domain, siteConfig] = await Promise.all([
      context.queryClient.ensureQueryData(postBySlugQuery(params.slug)),
      context.queryClient.ensureQueryData(siteDomainQuery),
      context.queryClient.ensureQueryData(siteConfigQuery),
    ]);

    // 2. 顺手预热一下“相关文章”,不阻塞首屏
    void context.queryClient.prefetchQuery(
      relatedPostsQuery(params.slug, relatedPostsLimit),
    );

    // 3. 查不到文章直接抛 404
    if (!post) throw notFound();

    return {
      post,
      authorName: siteConfig.author,
      canonicalHref: buildCanonicalUrl(
        domain,
        `/post/${encodeURIComponent(post.slug)}`,
      ),
    };
  },
  head: ({ loaderData }) => {
    // 4. 根据 loader 数据生成 SEO 标签
    const post = loaderData?.post;
    const canonicalHref = loaderData?.canonicalHref ?? "";

    return {
      meta: [
        { title: post?.title },
        { name: "description", content: post?.summary ?? "" },
        { property: "og:title", content: post?.title ?? "" },
        { property: "og:description", content: post?.summary ?? "" },
      ],
      links: [canonicalLink(canonicalHref)],
    };
  },
  pendingComponent: () => <theme.PostPageSkeleton />,
});

在这个文件里,还没有碰到真正的页面组件,路由层其实已经把“验参数、拉主数据、预取边缘数据、处理 404、生成 SEO 信息”这五件事全包办了。

拦截脏数据:validateSearch

你看文章页里的搜索参数其实很简单,主要用来定位评论:

TSX
const searchSchema = z.object({
  highlightCommentId: z.coerce.number().optional(),
  rootId: z.number().optional(),
});

为什么要在路由里校验,而不是等进了 React 组件再用 useSearchParams 自己去 parse 呢?

因为组件不该去处理 URL 里的脏数据。在路由层校验,哪怕参数乱填,在进入页面前就能被规整或者拦截掉。而且好处是,下游的 loader 和组件拿到的,永远是类型安全的参数。

列表页也是同样的操作,而且还能通过 loaderDeps 明确告诉系统:只有这些参数变了,才需要重新跑一次拉取逻辑

TSX
validateSearch: z.object({
  tagName: z.string().optional(),
}),
loaderDeps: ({ search: { tagName } }) => ({ tagName }),

权限守卫:beforeLoad

说到门卫,后台管理页面是最典型的场景:

TSX
export const Route = createFileRoute("/admin")({
  beforeLoad: async ({ context }) => {
    const session = await context.queryClient.ensureQueryData(sessionQuery);

    if (!session) {
      throw redirect({ to: "/login" });
    }
    if (session.user.role !== "admin") {
      throw redirect({ to: "/" });
    }

    return { session };
  },
});

在这个博客里,我对 beforeLoad 的态度非常克制:它只用来决定你此时此刻能不能进这条路由。

千万别为了图省事,把页面该展示的业务数据也塞进 beforeLoad 去查。查权限归 beforeLoad,查数据归 loader,这两者一旦混在一起,后面的重构会非常痛苦。

如果是诸如 OAuth 回调或者登录判断,更是天然顺手:直接在 beforeLoad 里检查 Session,如果没有,带上 location.href 原路跳回登录页,组件里连一行判断逻辑都不用写。说起这个,在给后台接入 OAuth 时,我还遇到了 Antigravity MCP 的 Auth 状态坑,有兴趣的可以顺道避个雷。

数据加载与预热:loader

很多时候,你的页面不止依赖一份数据。文章详情页除了加载文章本身,可能还需要配图、作者信息、相关推荐。

如果把所有的请求都一个大 Promise.all 框起来 await,只要有一个接口慢,首屏就得一直白板。这里路由层的轻量编排能力就体现出来了:

TSX
const [post, domain, siteConfig] = await Promise.all([
  context.queryClient.ensureQueryData(postBySlugQuery(params.slug)),
  context.queryClient.ensureQueryData(siteDomainQuery),
  context.queryClient.ensureQueryData(siteConfigQuery),
]);

// 不阻塞,仅仅是预热
void context.queryClient.prefetchQuery(
  relatedPostsQuery(params.slug, relatedPostsLimit),
);

我们只 await 最核心的内容,至于底部的相关推荐,直接扔一个 prefetchQuery 到后台默默拉取。等用户滚动到底部组件 mount 时,缓存里已经有数据了,完美做到无缝切换。关于这种缓存思路的极致榨取,前两天写的 发布后缓存系统如何联动 也有异曲同工之妙。

SEO 原生支持:head

以前写单页应用,总喜欢在页面最外层套个 <Helmet> 或者 <Head> 组件,把标题和摘要拼进去。

但这很不直观。文章的标题、描述、Canonical 链接,甚至是用作结构化数据的 JSON-LD,它们本身就是由刚才 loader 拿回来的数据决定的。

既然如此,直接在路由配置的 head 属性里返回不是更顺畅吗?

TSX
head: ({ loaderData }) => {
  const post = loaderData?.post;
  // ...
  return {
    meta: [
      { title: post?.title },
      { name: "description", content: post?.summary ?? "" },
    ],
    // SSR 期间直接带上
  };
},

这样最大的好处是:SSR 的时候,HTML 头部信息天然就是完整的,并且不会出现客户端渲染时默认标题闪烁的尴尬情况。这正是 《上手Tanstack Start框架,舒适的开发体验》 里提到的 Data-Only SSR 模式的威力。

异常与骨架屏:notFound 和 pendingComponent

你看 loader 代码里有一句:

TSX
if (!post) throw notFound();

这意味着我们绝不让一个“空的文章对象”流到底下的 React 组件里去。组件不需要写 if(!post) return <div>找不到文章</div> 这种恶心的分支逻辑。交给路由系统,它会自动渲染全局的 404 组件,而且 HTTP 状态码也能顺理成章地变成准确的 404。

如果是网络稍微慢一点呢?路由级别的 pendingComponent 就能接管:

TSX
pendingComponent: () => <theme.PostPageSkeleton />,

不过说实话,整页 Skeleton 比较适合大页面的切换。如果你只是局部某个小按钮、小区域要更新,那还是把 Loading 状态交给具体组件内部去维护更自然。有两个颗粒度的设计,我们在 《全栈开发的错误处理》 也有提到类似的关注点分离。

总结

经过一番折腾,我现在觉得 TanStack Start 最舒服的体位就是:业务逻辑继续留在 Service 层,而把“进入页面”的一系列把关动作全交接给路由层完成。

路由层干的活儿虽然杂,但很关键。它帮你挡住了乱填的 URL 参数,掐断了未授权的访问,预热了该有的缓存,还顺手把 SEO 安排明白了。

当你把这些“杂活”都从 UI 代码里剥离出去后,你会发现底下的 React 组件其实非常纯粹:只需要专注做数据展现、渲染骨架、绑定事件。这正是全栈开发里最不容易重构到吐血的安全感来源。

END
© 2026 阿旷. All Rights Reserved. / RSS / Sitemap
Powered by Tanstack Start & Flare Stack Blog