用好 TanStack Start 的路由层

在《我的博客项目架构 - Tanstack Start与依赖注入实战》里，我们聊了 TanStack Start 里的中间件和依赖注入。这篇想换个角度，专门聊聊在这个博客项目里，我是怎么理解和使用路由层的。刚接触这类全栈框架时，最常见的做法就是把路由文件当成一个“页面壳子”：随便拉点数据，然后一股脑全丢给 React 组件去渲染。但实际跑下来，我发现一旦把所有逻辑都往组件里塞，页面代码会变得非常臃肿。其实，路由层是一个极佳的前置防线，它天然适合处理这些事：权限守卫（能不能看）搜索参数校验（参数对不对）首屏数据预取（尽早发起请求） SEO 元信息（标题和 Meta 标签） 404 和 Loading 边界（异常怎么兜底）把这些逻辑提上来，你的 React 组件就能干干净净地只负责渲染。文章详情页的核心逻辑文章也算是整个博客最核心的入口了。看看我现在的路由配置是怎么写的： 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: () => , }); 在这个文件里，还没有碰到真正的页面组件，路由层其实已经把“验参数、拉主数据、预取边缘数据、处理 404、生成 SEO 信息”这五件事全包办了。拦截脏数据：validateSearch 你看文章页里的搜索参数其实很简单，主要用来定位评论： const searchSchema = z.object({ highlightCommentId: z.coerce.number().optional(), rootId: z.number().optional(), }); 为什么要在路由里校验，而不是等进了 React 组件再用 useSearchParams 自己去 parse 呢？因为组件不该去处理 URL 里的脏数据。在路由层校验，哪怕参数乱填，在进入页面前就能被规整或者拦截掉。而且好处是，下游的 loader 和组件拿到的，永远是类型安全的参数。列表页也是同样的操作，而且还能通过 loaderDeps 明确告诉系统：只有这些参数变了，才需要重新跑一次拉取逻辑。 validateSearch: z.object({ tagName: z.string().optional(), }), loaderDeps: ({ search: { tagName } }) => ({ tagName }), 权限守卫：beforeLoad 说到门卫，后台管理页面是最典型的场景： 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，只要有一个接口慢，首屏就得一直白板。这里路由层的轻量编排能力就体现出来了： 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 以前写单页应用，总喜欢在页面最外层套个 或者 组件，把标题和摘要拼进去。但这很不直观。文章的标题、描述、Canonical 链接，甚至是用作结构化数据的 JSON-LD，它们本身就是由刚才 loader 拿回来的数据决定的。既然如此，直接在路由配置的 head 属性里返回不是更顺畅吗？ 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 代码里有一句： if (!post) throw notFound(); 这意味着我们绝不让一个“空的文章对象”流到底下的 React 组件里去。组件不需要写 if(!post) return
找不到文章
这种恶心的分支逻辑。交给路由系统，它会自动渲染全局的 404 组件，而且 HTTP 状态码也能顺理成章地变成准确的 404。如果是网络稍微慢一点呢？路由级别的 pendingComponent 就能接管： pendingComponent: () => , 不过说实话，整页 Skeleton 比较适合大页面的切换。如果你只是局部某个小按钮、小区域要更新，那还是把 Loading 状态交给具体组件内部去维护更自然。有两个颗粒度的设计，我们在 《全栈开发的错误处理》 也有提到类似的关注点分离。总结经过一番折腾，我现在觉得 TanStack Start 最舒服的体位就是：业务逻辑继续留在 Service 层，而把“进入页面”的一系列把关动作全交接给路由层完成。路由层干的活儿虽然杂，但很关键。它帮你挡住了乱填的 URL 参数，掐断了未授权的访问，预热了该有的缓存，还顺手把 SEO 安排明白了。当你把这些“杂活”都从 UI 代码里剥离出去后，你会发现底下的 React 组件其实非常纯粹：只需要专注做数据展现、渲染骨架、绑定事件。这正是全栈开发里最不容易重构到吐血的安全感来源。