如果你是从 Next.js 或者纯 React 项目转过来的,第一次打开 Remix 项目时大概率会被它的 routes 目录搞糊涂:同一个/blog路径,为什么既要有blog.tsx,又要有blog._index.tsx?这套嵌套路由的设计,初看只是文件命名换了种花样,实际用起来才发现,它把 URL、数据加载和页面生命周期绑定在了一条垂直的链上。我自己的体验是:没理解嵌套路由之前,写 Remix 像是在硬背语法;理解之后,整个框架的设计逻辑突然就通了。
这篇文章我打算把我从"会用"到"想通"的过程完整写出来。内容包括:嵌套路由到底在解决什么问题、flat routes 的文件命名如何映射成 UI 树、loader 在嵌套层级里到底怎么执行、错误边界和加载状态如何顺着路由树往上冒泡,最后再把我踩过的几个坑和现在项目里实际在用的目录设计分享给你。无论你是刚接触 Remix 的新手,还是已经写了一段时间、对数据流和边界处理还模模糊糊的人,这篇应该都能给你一些新的角度。
1. 嵌套路由到底解决了什么问题:从 URL 到页面结构的映射逻辑
1.1 传统"文件即页面"的做法:URL 是 URL,组件树是组件树
大多数服务端渲染框架和早期的 React 路由方案,遵循的是"一个 URL 对应一个页面组件"的思维。/about对应about.tsx,/blog/hello对应blog/hello.tsx,URL 的斜杠路径和物理文件路径基本一一对应。这种模式简单、直觉、好上手,但它有一个长期被忽视的问题:URL 的层级只表达了"页面之间的组织关系",并没有表达"页面 UI 之间的嵌套关系"。
拿一个稍微像样的博客系统举例。页面顶部的导航栏、侧边的作者卡片、正文底部的评论列表,在文章列表页和文章详情页里几乎一模一样。传统做法的套路是抽象一个 Headless 的 Layout 组件,然后手动在每一页外面包一层。问题在于:谁来包、包几层、导航在哪个层级失效,这些规则完全依赖项目内的口头约定和代码纪律。一旦页面多起来,你会发现某些页面误用了全套外壳,某些页面漏掉了侧边栏,只能再补条件判断。
在 Remix 的视角里,URL 本身就是有层级的,/blog/hello这个地址天然可以拆成"博客模块"和"某篇文章"两层。为什么不让文件系统和 URL 的层级自然决定 UI 的层级?这就是嵌套路由切入的位置。它把"页面独享区域"和"模块共享区域"的边界,变成了路由树上两个相邻节点的边界,而不是组件树里需要手工维护的包裹关系。
1.2 Remix 的核心主张:URL 的层级就是 UI 的层级
Remix 最关键的一个设计判断是:路由模块不仅负责渲染页面,还负责为下一级子路由提供容器。根目录的root.tsx是整个应用最外层的壳,它渲染<Outlet />,子路由的组件会被填进这个 Outlet;blog.tsx又是博客区域的外壳,它再渲染一个<Outlet />,/blog/:slug的页面组件会填进这里。
这样一层套一层,最终形成的就是一棵与 URL 段落精确对齐的组件树:
// app/root.tsx export default function Root() { return ( <html lang="zh-CN"> <head> <Meta /> <Links /> </head> <body> <Header /> <Outlet /> {/* 这里是第一层子路由的家 */} <Footer /> </body> </html> ); }// app/routes/blog.tsx export default function BlogLayout() { return ( <section> <BlogNav /> <Outlet /> {/* 这里是 /blog 下所有页面的家 */} </section> ); }这个设计带来的第一个好处,是导航时的"局部更新"变得非常自然。当你从/blog/a跳转到/blog/b时,如果两条路由共享同一个blog.tsx父级,Remix 会复用已经挂载的blog.tsx组件实例,只卸载掉$slug那一层再挂上新的。导航头、侧边栏这类父级 UI 不会重新渲染,滚动位置和状态也都保留着。这种行为不是靠手动 memo 或者 React 的 reconciliation 优化出来的,而是路由结构本身天然给出的结果。
第二个好处是数据加载的职责清晰了。blog.tsx的 loader 只负责获取博客区域共享的数据,比如导航分类列表、侧边栏的作者信息;$slug.tsx的 loader 只负责获取当前文章的数据。每个模块的数据范围、加载时机和 UI 范围完全重合,不再需要一个单独的全局状态仓库在页面之间倒腾数据。
1.3 扁平文件背后的嵌套约定:flat routes 的命名规则速览
Remix 2.x 推荐使用 flat routes,也就是所有路由文件都平铺在app/routes目录下,用文件名里的分隔符表达嵌套关系。刚切换过来的人最容易犯的错,是把.tsx这个类型后缀也当成路径分隔符。实际上 Remix 会忽略最后的扩展名,文件blog.tsx的路径是/blog,不是/blog.tsx。规则上,文件名中用于组织路由的.和真正表示 React 组件的.tsx后缀是两回事。
核心命名约定我整理成了一张表:
| 文件名 | 匹配的 URL | 说明 |
|---|---|---|
_index.tsx | / | 根索引路由,渲染在 root 的 Outlet 里 |
about.tsx | /about | 父路由,占一个 URL 段 |
about._index.tsx | /about | 父路径本身的索引页,渲染在 about.tsx 的 Outlet 里 |
about.team.tsx | /about/team | 子路由 |
blog.$slug.tsx | /blog/:slug | 动态段 |
_marketing.tsx | 不占 URL | 布局路由,前缀下划线表示不参与路径匹配 |
_marketing.pricing.tsx | /pricing | 挂在隐形布局下面的子路由 |
$.tsx | 任意未匹配路径 | catch-all 兜底 |
只看到这张表的时候,可能会觉得"这不就是把目录换成了点号吗"。真正拉开差距的是_前缀和_index后缀这两类特殊文件。它们让"布局层"和"页面层"从物理概念变成了逻辑概念:你可以拥有一个不在 URL 中出现的父布局,也可以为一个已经存在的 URL 段单独补一个"页面本体"。接下来两节,我会把这两个概念放到实际例子里拆开讲。
2. 把路由树搭起来:文件命名、Outlet 与路径段的对应关系
2.1 最基础的四层嵌套:从 root 到详情页
只看单一文件很难建立体感,直接上一套完整的目录结构。假设我要做一个带博客系统的个人网站,routes 目录大致长这样:
app/routes/ ├── _index.tsx # / ├── about.tsx # 父路由,占位 /about ├── about._index.tsx # /about 页面本体 ├── about.team.tsx # /about/team ├── blog.tsx # 父路由,占位 /blog ├── blog._index.tsx # /blog 列表页 ├── blog.$slug.tsx # /blog/:slug 的布局节点 ├── blog.$slug._index.tsx # /blog/:slug 的默认页面 ├── blog.$slug.comments.tsx # /blog/:slug/comments ├── _marketing.tsx # 隐形布局,不占 URL ├── _marketing.pricing.tsx # /pricing ├── _marketing.faq.tsx # /faq └── $.tsx # 全局 404 兜底访问不同 URL 时,路由树的匹配情况和 UI 嵌套关系如下:
/:root+_index/about:root+about+about._index/blog:root+blog+blog._index/blog/hello:root+blog+blog.$slug+blog.$slug._index/blog/hello/comments:root+blog+blog.$slug+blog.$slug.comments
注意/blog/hello实际上命中了三层组件:blog.tsx提供博客外壳,blog.$slug.tsx提供文章页外壳,blog.$slug._index.tsx才是正文本体。很多第一次接触 flat routes 的人会问:为什么不能直接让$slug.tsx自己渲染正文?当然可以,但那样的话,当你想给文章页加/comments这个子路由时,$slug.tsx就没有一个 Outlet 来安放评论列表了。
让$slug变成布局节点,其实是给未来的子页面预留位置:
// app/routes/blog.$slug.tsx import { useLoaderData, Outlet } from "@remix-run/react"; import { json } from "@remix-run/node"; export const loader = async ({ params }: LoaderFunctionArgs) => { return json({ post: await getPost(params.slug) }); }; export default function PostLayout() { const { post } = useLoaderData<typeof loader>(); return ( <article> <PostTitle post={post} /> <Outlet /> </article> ); }正文本体交给blog.$slug._index.tsx,评论列表交给blog.$slug.comments.tsx。这样文章页和评论页共享了同一套文章头部信息,URL 上的父子关系、UI 上的父子关系、数据上的共享范围,三者严丝合缝地对上了。
2.2 用下划线前缀制造"隐形布局":_marketing这类路由的真实用途
上一节的about.tsx和blog.tsx都是"占 URL 段"的父路由,它们会用自己的模块名影响 URL。但实际项目里还有一种很常见的需求:我希望/pricing和/faq共享一个营销页外壳,但我不希望这个外壳出现在 URL 里。
如果按普通命名,我确实可以建一个marketing.tsx,让/marketing/pricing和/marketing/faq共享外壳。问题在于 URL 被污染了,用户看到的路径里多出了一个毫无意义的分组名marketing。更麻烦的是,以后如果想把某个页面移出这个分组,URL 也会跟着变,这对搜索引擎和收藏夹都是灾难。
Remix 用"文件名前缀加下划线"来解决:_marketing.tsx这个文件存在,但它的文件名段不会进入 URL。它只充当子路由的外壳,所有以_marketing.开头的子路由都会渲染到它的<Outlet />里:
// app/routes/_marketing.tsx export default function MarketingLayout() { return ( <div> <MarketingHeader /> <main> <Outlet /> </main> <MarketingFooter /> </div> ); }于是_marketing.pricing.tsx匹配/pricing,_marketing.faq.tsx匹配/faq。两者在 UI 上共享一个父级,在 URL 上却互不纠缠。这就是布局路由的灵活之处:你可以在不改变 URL 语义的前提下,任意调整页面的共享外壳层。类似的手法还可以用在权限区域,比如_account.profile.tsx和_account.orders.tsx都挂在_account.tsx这个登录态布局下面,路径却可以保持/profile、/orders这种干净结构。
写到这里必须提一个新手高频翻车点:如果你建立了_marketing.tsx,不要忘记给它的子路由也提供一个可匹配的"页面本体"。/pricing长得像是一个叶子 URL,但它实际上由_marketing这层外壳和_marketing.pricing这个页面节点共同渲染。两者缺一不可,少了任何一个,访问都可能得到一片空白或者 404。
2.3_index、$slug、$三种特殊段位的使用时机
理解了普通父路由和隐形布局之后,剩下的主要障碍就是三个特殊标识符:_index、$slug、$。
_index的意思是"父级 URL 本身对应的索引页面"。about.tsx占用了/about这个 URL 段,但如果你直接访问/about,Remix 还需要知道该把什么内容填进about.tsx的 Outlet。没有about._index.tsx,/about就会因为没有叶子节点而无法匹配。这和根路由的道理一样:root.tsx永远存在,但访问/时必须靠_index.tsx才能把首页内容渲染出来。很多人一开始会直接把页面内容写在about.tsx里,一旦后面又加了about.team.tsx,这个组件就会同时扮演"父布局"和"页面本体"两个角色,最后 Outlet 到底渲染在哪都理不清。规范做法是父路由只做布局,页面本体单独拆给_index。
$slug是动态段。blog.$slug.tsx匹配/blog/任意值,动态值通过params.slug获取。需要注意,同级目录里静态路由优先级高于动态路由,/blog/about会优先命中文档中显式声明的静态路径,而不会掉进$slug里。$slug还可以嵌套,比如文章作者页可以写成blog.$slug.author.tsx,此时params.slug一样能拿到上一层的动态值。
$则是真正的兜底通配符。单独的$.tsx匹配所有未被其他路由匹配的路径,适合做全局 404;如果只想兜住某个区域,可以写成blog.$.tsx,让/blog/任意内容都在博客外壳下渲染一个"文章不存在"页面。理解_index和$之后,嵌套路由最重要的心智模型就出来了:父布局决定 UI 结构,索引路由决定"这个路径本身是什么",通配路由决定"这个路径的意外情况是什么"。
3. 嵌套路由的数据流:loader 的加载顺序与父子数据互通
3.1 loader 到底是串行还是并行:一个常被误解的问题
第一次看 Remix 文档时,我下意识认为 loader 会像组件树一样,先是父亲的 loader 执行完,再依次执行儿子的 loader。这个理解是错的。访问/blog/hello时,root.loader、blog.loader、blog.$slug.loader会并发执行,而不是串行等待。
Remix 会把匹配到的所有 loader 打包成一批请求,用并行方式去拉取。这样做的好处很直接:三层 loader 如果分别耗时 50ms、100ms、80ms,串行执行要 230ms,并行执行只需要 100ms 左右。首屏和服务端渲染阶段,这个差异会直接体现在 TTFB 上。组件渲染的顺序倒是固定的:数据全部就绪后,从最外层向最内层依次渲染,root先渲染,blog渲染,最后把$slug组件插入 Outlet。
这里有一个需要小心的地方:并行不等于"互不依赖"。你的 loader 之间如果有明确的先后依赖——比如子 loader 需要父 loader 的返回值才能发请求——不能指望 Remix 帮你调度。常见做法有两个:
- 父 loader 把所有需要聚合的数据一次查完,子组件通过 parent 数据来渲染,子 loader 不再重复查询。
- 如果子 loader 确实需要父 loader 的结果做参数,就只能在父 loader 里把数据封装好,通过
json返回给子路由使用,而不是在子 loader 里等待。
服务端渲染时尤其要注意,不要写出"子 loader 调用父 loader 模块里的函数"这种隐式依赖。父 loader 函数中如果有request相关逻辑,调用方和实际执行的上下文会纠缠在一起,排查起来非常痛苦。保持每个 loader 独立、可并行,是嵌套数据流的第一条纪律。
3.2 父 loader 的数据如何在子组件里使用:useLoaderData与useRouteLoaderData
顺着上面的场景继续:blog.tsx的 loader 返回了博客的分类列表,blog.$slug.tsx或者它的子组件想在文章页右侧栏展示这些分类。直接写useLoaderData()能拿到吗?不一定。
useLoaderData的语义是"当前路由模块自己的 loader 数据"。在blog.tsx组件里调用,拿到的是blog.loader的返回值;在blog.$slug.tsx组件里调用,拿到的是$slugloader 的返回值。如果你在blog.$slug.comments.tsx里写useLoaderData(),拿到的会是 comments 模块自己的 loader 数据,而不是博客布局的数据。这是嵌套路由场景最常见的误区。
要读取祖先路由的数据,可以用useRouteLoaderData,参数是目标路由的 route id:
// app/routes/blog.$slug.comments.tsx import { useRouteLoaderData } from "@remix-run/react"; export default function Comments() { const blogData = useRouteLoaderData("routes/blog"); const postData = useRouteLoaderData("routes/blog.$slug"); // ... }route id 默认就是去掉扩展名后的文件名,所以blog.tsx对应routes/blog,blog.$slug.tsx对应routes/blog.$slug。在实际项目里,我建议不要凭记忆手写这些字符串,可以在开发环境里临时调用一次useMatches(),把返回数组的id字段打印出来,确认之后再硬编码。
useRouteLoaderData的调用位置有约束:只能读取当前路由祖先链上的数据,不能随便读取整棵路由树里任意节点的数据。这个限制是有意为之的,它保证了数据流的方向永远是"父级向子级流动",避免出现兄弟节点互相读数据的混乱局面。
3.3 避免重复加载:shouldRevalidate的适用范围
嵌套路由的另一个数据流特性是:当页面里某个 action 提交成功后,Remix 会重新验证所有匹配路由上的 loader,并重新拉取数据。这个默认行为在多数场景下合理——提交一个表单可能会影响顶栏的用户头像,也可能只影响当前区块的列表。
但在嵌套层级较深的应用里,这个"默认全部重新验证"有时会带来明显的性能损耗。比如文章页的评论区提交一条新评论,原本只需要comments区域的 loader 刷新,但默认行为会把blog.loader里那个每篇文章几乎都不变的数据也重新拉一遍。
这时候可以给特定路由模块导出shouldRevalidate函数:
// app/routes/blog.tsx export const shouldRevalidate = ({ formAction, nextUrl }: ShouldRevalidateFunctionArgs) => { // 这里返回 false,表示这个路由的 loader 在 action 之后不重新执行 return false; };需要注意的是,shouldRevalidate只作用于 action 提交后的重新验证流程,不影响初次加载和普通的 GET 导航。另外,它的判断应该基于"这个路由的数据是否真的依赖刚才那次提交",而不是无脑返回false。如果blog.tsx里展示的是当前登录用户的未读消息数,而评论区提交并不会影响这个数据,那返回false是正确的;但如果博客布局里同时显示了"我的收藏"并且评论区的提交会改变收藏状态,问题就来了。我见过一个项目把所有父级 loader 的shouldRevalidate全部关掉,结果用户发表评论后列表区域的计数迟迟不更新,排查了一整天。
3.4 handle 加 useMatches:跨层级状态的轻量方案
有些跨层级的数据不值得动用 loader。比如面包屑导航,它只需要知道每一级路由的标题;或者某个页面的 SEO 信息,只需要由最深层的页面决定。Remix 提供了handle导出约定,可以在路由模块上挂任意元数据:
// app/routes/_marketing.pricing.tsx export const handle = { breadcrumb: "价格方案", };然后在任何父级组件里通过useMatches读取整条匹配链:
// app/components/Breadcrumbs.tsx import { useMatches } from "@remix-run/react"; export default function Breadcrumbs() { const matches = useMatches(); return ( <nav> {matches .filter((m) => m.handle?.breadcrumb) .map((m) => ( <span key={m.id}>{m.handle.breadcrumb}</span> ))} </nav> ); }useMatches返回的是从根路由到当前路由的完整匹配链,每一段都带有id、pathname、handle和data。这个 API 非常适合做那些"由多个层级共同决定"的 UI,比如面包屑、标签页、步骤条。相比把状态提升到全局 Context 或者中心化状态管理,handle的优势是它天然跟着 URL 走:导航到哪里,匹配链就是什么,不需要手动清理状态。
4. 嵌套场景下的边界处理:404 策略、错误气泡与加载状态
4.1 URL 存在但 UI 无着落:索引路由与 catch-all 的关系
嵌套路由下最容易出现的边界问题,是"URL 在概念上存在,但路由树里没有对应的叶子节点"。比如你有blog.tsx,却没有blog._index.tsx,访问/blog时 Remix 会直接返回 404。原因很简单:blog.tsx这层父路由虽然有默认导出,但它的存在意义是为子路由提供 Outlet,它本身不匹配任何 URL 的最终页面。
这种"缺少叶子"的情况在动态段嵌套里更隐蔽。如果你创建了blog.$slug.comments.tsx,却没有创建blog.$slug._index.tsx,那么/blog/hello/comments能正常访问,但/blog/hello本身反而会 404。想想看,评论列表有页面,文章本体却没有,这显然违背直觉。所以每写一个嵌套布局,都要回头检查这三样东西:这个布局占哪个 URL 段、这个段对应的索引路由是否存在、意外路径该丢给哪个 catch-all。
定制 404 页面的正确姿势,是使用 catch-all 路由。根级$.tsx负责所有没有匹配路由的 URL;如果只想兜住某个区域,就把它写在父级下面。比如博客模块内部可以这样处理未知文章:
// app/routes/blog.$.tsx import { useRouteError, isRouteErrorResponse } from "@remix-run/react"; export default function BlogNotFound() { return ( <div> <h2>这篇文章不存在</h2> <p>可能它已经被作者删除,或者链接拼错了。</p> </div> ); }注意,blog.$.tsx会被渲染到blog.tsx的 Outlet 里,所以博客导航、侧边栏这些共享 UI 依然保留。这比整个页面直接白屏再配一个根级 404 要友好得多。
4.2 错误边界的"冒泡"机制:为什么父页面可以保留
嵌套路由的 ErrorBoundary 和 React 的 ErrorBoundary 类似,但有一个显著区别:它的边界范围是沿着路由树划分的。某个子路由的 loader 或组件抛出错误后,Remix 会沿着路由树向上寻找最近的 ErrorBoundary,而不是直接交给 root。
假设我在blog.$slug._index.tsx的 loader 里查询一篇不存在的文章,并主动 throw 了一个 404 Response。blog.$slug._index没有 ErrorBoundary,错误会冒到blog.$slug.tsx层。如果$slug.tsx也没有,继续冒到blog.tsx,最后到root.tsx。关键在于:错误冒泡到哪一层,哪一层以下的子树 UI 会被替换,而以上层级保持正常渲染。
所以合理的边界设计是:在可能出错的叶子节点附近放一个 ErrorBoundary,让错误只影响小范围 UI。比如在blog.$slug.tsx里放 ErrorBoundary,文章正文和评论区出错时,博客导航和侧边栏还是完好的;如果只在root.tsx放边界,一个正文页的小错误就会把整个页面的外壳都变成错误提示,这对用户来说非常不友好。
Remix 2.x 处理错误的方式也更新了,旧版的CatchBoundary已经不再推荐,统一切到 ErrorBoundary 加isRouteErrorResponse:
// app/routes/blog.$slug.tsx import { useRouteError, isRouteErrorResponse } from "@remix-run/react"; export function ErrorBoundary() { const error = useRouteError(); if (isRouteErrorResponse(error)) { if (error.status === 404) { return <div>这篇文章不存在,请检查链接。</div>; } return <div>请求出错了:{error.status}</div>; } return <div>页面发生了未预期的错误,请稍后重试。</div>; }这样的好处是,404、401 这类可预期的错误和真正的代码异常可以分开展示。404 走的是业务路径,提示"资源不存在";代码异常走的是兜底提示,不把堆栈直接抛给用户。
4.3 全局与局部 Pending UI:useNavigation 的 location 判断
嵌套路由还有一个日常体验相关的问题:怎么知道一次导航正在改变哪一层的内容。Remix 提供了useNavigation(),返回state、location和formData等信息。state只有三种取值:idle、loading、submitting。你可以在父布局里监听它,决定是否展示全局进度条。
但全局进度条不区分层级。从/blog/a跳到/blog/b时,全局进度条转一下也许合适;从/blog跳到/blog/a时,博客布局的导航要等文章数据返回后才更新,导航栏本身却已经准备好复用了,这时候全局进度条就会显得很啰嗦。
更精准的做法是结合navigation.location判断"这次导航是否发生在我这个布局内部":
// app/routes/blog.tsx import { useNavigation, Outlet } from "@remix-run/react"; export default function BlogLayout() { const navigation = useNavigation(); const isLoadingInside = navigation.state === "loading" && navigation.location?.pathname.startsWith("/blog"); return ( <section> <BlogNav /> {isLoadingInside ? <div className="blog-loading" /> : null} <Outlet /> </section> ); }当用户从/blog/a切换到/blog/b,blog.tsx知道自己这一层被复用了,需要等待新文章数据,所以显示一个局部加载条;当用户从/blog跳到文章详情页,blog.tsx同样知道自己没变,但深层内容变了,照样可以给个提示。这个能力的判断依据正是 URL 嵌套的路径关系。
4.4 全局 404 与局部 404 的分工建议
结合 4.1 和 4.2,我现在的项目里对 404 做了明确的层级分工。全局的$.tsx负责那些完全不在站点结构里的 URL,比如/random-garbage,它渲染最外层 404,页面上只有导航和一句"路径不存在"。博客模块内部的blog.$.tsx负责/blog/任意无效路径,它渲染在博客布局内部,侧边栏仍然展示文章列表。
这两个 catch-all 在嵌套路由里不是竞争关系,而是不同层级的兜底。Remix 在做路由匹配时,会优先选择更具体的匹配,/blog/not-exist会先看blog.$slug.tsx能否匹配,再看blog.$.tsx,最后才轮到根级$.tsx。如果你把根级 404 写成"页面不存在"这种通用文案,用户在博客模块里撞上一个不存在的文章链接时,看到的提示会和全局 404 一样,这对体验不算坏事,但利用嵌套路由做局部 404 的收益就浪费了。
5. 真实项目里的三个关键决策与踩坑记录
5.1 目录设计:什么时候该用布局路由,什么时候不该
嵌套路由给了你很大的自由,但自由也容易带来过度设计。我见过一个同事把每一个一级模块都改成了隐形布局,/_home_、/_shop_、/_user_一层套一层,最后useMatches()一打印,链路上六七个外壳,每个壳都在做布局判断,代码根本没法读。
我现在的目录设计准则是:必须有两个以上子页面共享受限范围的 UI 时,才新增布局层。普通页面一律平铺在routes里,哪怕它 URL 的层级看起来很深。比如一个网站有/about、/about/team、/about/contact,如果这三个页面共享顶部页头但页头内容与全局导航完全相同,我就不会为了它们专门建about.tsx布局,因为全局导航已经由root.tsx提供了,再套一层等于重复渲染。
反过来,如果about模块内部有自己的 Tab 导航,比如"团队"/"联系"/"创始人",那它的确是一个值得独立的布局。判断标准始终是"共享 UI 的范围",不是"URL 的层级"。布局路由是手段,不是目的,不要为了演示嵌套路由而层层嵌套。
5.2 踩坑记录:Outlet 缺失导致的白屏
这是嵌套路由里最典型的低级错误:子路由数据加载正常、路由匹配正常,但页面就是白屏。排查半天,发现父布局的 JSX 里压根没有<Outlet />。root.tsx做得很规范,但二级的blog.tsx忘记渲染 Outlet,导致所有博客子路由都没有安放位置。
这里的诡异之处在于,页面不会报错,因为在父组件里渲染一个没有 Outlet 的布局是完全合法的 React 代码。你看到的现象只是"URL 变了,但页面内容没变",甚至如果父级之前渲染过一个固定的静态内容,你还会误以为站点就是这样。
我现在写完一个父路由,会下意识检查三件事:有没有渲染<Outlet />;这个 Outlet 在 JSX 中的位置是不是子页面真正希望出现的位置;如果同一层有多个 Outlet 需求,是否应该拆成多个嵌套布局。这三个检查花不了十秒,但能省掉后面一小时的定位时间。
5.3 踩坑记录:useLoaderData 与 useRouteLoaderData 的错位
第二个高频坑是数据拿错。在blog.$slug.comments.tsx里直接写useLoaderData(),期望拿到文章标题,结果拿到的却是评论列表自己的数据。因为useLoaderData永远指向"当前路由模块自己的 loader",不会自己去祖先链上找数据。
这个错位的隐蔽性在于,如果评论区的 loader 恰好也返回了一个包含post字段的对象,代码跑起来看起来一切正常,直到某天评论 loader 改了字段结构,文章标题才会突然消失。为了规避这类问题,我现在对嵌套层级超过两层的页面,会明确标注数据的来源:当前路由数据用useLoaderData,祖先数据一律用useRouteLoaderData,并把 route id 定义在一个常量文件里,避免字符串散落各处。
5.4 我的心智模型:把嵌套路由看成 URL 的投影仪
写了几个真实项目之后再回头看,嵌套路由最核心的认知就是把"URL 段位"当成一组嵌套的容器。每一个路径段对应一层容器,容器里有自己的数据、自己的 UI、自己的错误边界;子路径就是对更内层容器的填充。_index是"这个容器本身的内容",_marketing是"不挂在路径上的共享容器",$.tsx是"这个容器的意外分支"。
带着这个模型去读 Remix 的文档,很多设计都会变得顺理成章:为什么parent.tsx要渲染 Outlet?因为一个 URL 段只是容器,容器的价值在于给子段提供位置。为什么 loader 并发执行?因为每层容器的数据是独立的,独立的东西就应该并行获取。为什么 ErrorBoundary 向上冒泡?因为嵌套层级天然决定了"上"就是离用户更近的外壳,外壳不能轻易被一个深层错误击穿。
最后分享一个我现在实际在用的技巧:每当项目里新增一条路由,我会先在纸上把这条 URL 的每一段写出来,然后在每一段下面标注对应的文件、loader 数据范围、是否需要索引路由、出错时应该由哪个边界捕获。这一步不需要任何工具,一支笔一张纸就够,但它能让你在动手写代码之前,就把嵌套路由里最容易出问题的三个位置全部过一遍:Outlets、索引路由和错误边界。这个习惯帮我减少的返工量,远比一开始想的多。