
如果你是从 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.tsxURL 的斜杠路径和物理文件路径基本一一对应。这种模式简单、直觉、好上手但它有一个长期被忽视的问题URL 的层级只表达了页面之间的组织关系并没有表达页面 UI 之间的嵌套关系。拿一个稍微像样的博客系统举例。页面顶部的导航栏、侧边的作者卡片、正文底部的评论列表在文章列表页和文章详情页里几乎一模一样。传统做法的套路是抽象一个 Headless 的 Layout 组件然后手动在每一页外面包一层。问题在于谁来包、包几层、导航在哪个层级失效这些规则完全依赖项目内的口头约定和代码纪律。一旦页面多起来你会发现某些页面误用了全套外壳某些页面漏掉了侧边栏只能再补条件判断。在 Remix 的视角里URL 本身就是有层级的/blog/hello这个地址天然可以拆成博客模块和某篇文章两层。为什么不让文件系统和 URL 的层级自然决定 UI 的层级这就是嵌套路由切入的位置。它把页面独享区域和模块共享区域的边界变成了路由树上两个相邻节点的边界而不是组件树里需要手工维护的包裹关系。1.2 Remix 的核心主张URL 的层级就是 UI 的层级Remix 最关键的一个设计判断是路由模块不仅负责渲染页面还负责为下一级子路由提供容器。根目录的root.tsx是整个应用最外层的壳它渲染Outlet /子路由的组件会被填进这个 Outletblog.tsx又是博客区域的外壳它再渲染一个Outlet //blog/:slug的页面组件会填进这里。这样一层套一层最终形成的就是一棵与 URL 段落精确对齐的组件树// app/root.tsx export default function Root() { return ( html langzh-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/aboutrootaboutabout._index/blogrootblogblog._index/blog/hellorootblogblog.$slugblog.$slug._index/blog/hello/commentsrootblogblog.$slugblog.$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 } useLoaderDatatypeof 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 段但如果你直接访问/aboutRemix 还需要知道该把什么内容填进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/blogblog.$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 UIuseNavigation 的 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 classNameblog-loading / : null} Outlet / /section ); }当用户从/blog/a切换到/blog/bblog.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、索引路由和错误边界。这个习惯帮我减少的返工量远比一开始想的多。