Skip to content
This page has been auto-translated and may contain errors.View in English

嵌套路由与布局

路由介绍了最基础的用法:一个 Route 对应一个页面,各自有自己的 pathelement。真实的应用很快就会遇到更复杂的情况,因为 URL 本身就是嵌套的。比如 /host/income 这样的路径有两个层级,往往深层路径对应的页面会保留浅层路径页面的大部分内容:相同的导航栏、相同的标签页,只是下面的内容换成了新的。嵌套路由就是 React Router 用来表达这种结构的方式。你把 Route 元素嵌套在彼此里面,路由器就会一次性渲染整个匹配链,让父元素包裹子元素。

所以"嵌套路由"涉及两个相关的概念。URL 的段嵌套了,UI 也随之嵌套:页面的某些部分在你深入导航时保持不变,只有内层部分会改变。第二点才是真正的收益。

用 Outlet 嵌套路由并渲染子元素

Route 既可以作为自闭合元素使用,也可以包含子元素。子元素必须是其他 Route 元素。当子路由匹配时,React Router 先渲染父路由的 element,父元素通过渲染 Outlet 来决定子元素在哪里显示。

jsx
import { BrowserRouter, Routes, Route } from 'react-router-dom'

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route element={<Layout />}>
          <Route path="/" element={<Home />} />
          <Route path="about" element={<About />} />
          <Route path="vans" element={<Vans />} />
        </Route>
      </Routes>
    </BrowserRouter>
  )
}
jsx
import { Outlet } from 'react-router-dom'
import Header from './Header'

export default function Layout() {
  return (
    <>
      <Header />
      <Outlet />
    </>
  )
}

Outlet 在父元素中挖出一个洞,让匹配的子元素在那里渲染。它的作用类似于组件组合中的 children prop:父元素渲染共享的结构,并标记出可变部分的位置。区别在于 React Router 来填充这个洞,根据当前 URL 选择匹配的子路由。如果没有 Outlet,父元素会占据整个页面,而被匹配的子元素则无处可渲染,这是调试嵌套页面空白的第一个检查点。

上面那个外层路由是一个布局路由:它有 element 但没有 path。无路径的路由会匹配其所有子路由匹配的 URL,所以 Layout 会包裹每一个页面,这正是放置网站级别的公共元素(如页头和页脚)的地方。把页头写在 Routes 旁边也能达到同样效果,但前提是应用只有一个布局;当像 /host 这样的部分需要用不同的公共元素而不改动下层页面时,布局路由就派上用场了。你应该避免的做法是在每个页面组件内部重复写页头。

带路径的布局

布局路由也可以带有 path,这样共享 UI 就只会限制在应用的某一部分。这个课程的 VanLife 项目有一个 /host 部分,带有自己的二级导航,所以宿主页面在第一个布局内又嵌套了第二个布局:

jsx
<Route element={<Layout />}>
  <Route path="/" element={<Home />} />
  <Route path="about" element={<About />} />
  <Route path="host" element={<HostLayout />}>
    <Route path="income" element={<Income />} />
    <Route path="reviews" element={<Reviews />} />
  </Route>
</Route>

HostLayout 渲染宿主导航以及一个 Outlet,所以访问 /host/income 时,路由器会依次渲染 Layout、然后在它的 Outlet 中渲染 HostLayout、再在 HostLayout 的 Outlet 中渲染 Income。三个路由同时匹配,每个父元素都框住下一个。布局可以嵌套得跟设计要求一样深。

索引路由

上面这个设置有一个缺陷:HostLayout/host 处渲染,但它的 Outlet 那里没有内容可显示,因为每个子路由都会在路径上添加一个段。解决办法是索引路由:用 index prop 代替 path,这个子路由就会在父路由的 Outlet 中、父路由自己的路径处渲染。

jsx
<Route index element={<Dashboard />} />

现在访问 /host 会显示宿主导航,Dashboard 在其中,而 /host/income 则会把 Dashboard 替换为收入页面。把 index 看作"默认子元素":在添加更深段之前,应该在 Outlet 中显示的内容。在顶层也适用,<Route index element={<Home />} /> 就是根布局内的主页,和开头例子中写的 path="/" 是同一个路由。

相对路径和相对链接

注意上面的子路由路径:income,没有开头的斜杠。以 / 开头的路径是绝对路径,从网站根目录开始计算。没有斜杠的路径相对于其父路由,所以嵌套的 income 意味着 /host/income。这样可以让深层路由树更容易维护:在一个地方把 host 改成 admin,所有后代路径都会跟着改。

链接也是一样。在路由元素内渲染的 Link 会根据渲染它的路由来解析相对路径,所以在 HostLayout 内的导航可以完全去掉 /host 前缀:

jsx
import { Link, Outlet } from 'react-router-dom'

export default function HostLayout() {
  return (
    <>
      <nav>
        <Link to=".">Dashboard</Link>
        <Link to="income">Income</Link>
        <Link to="reviews">Reviews</Link>
      </nav>
      <Outlet />
    </>
  )
}

有两个特殊值借用了文件系统路径的含义。to="." 链接到当前路由,这样 Dashboard 链接就能指向 /host 本身,不需要硬编码。to=".." 向上爬升,对于"回到列表"这样的链接很有用;不过有个细微之处需要注意,它是爬升一个路由而非一个 URL 段,当一个路由拥有多个 URL 段时就开始重要,路由参数和位置会在真实的返回按钮中展示这个用法。

NavLink 和祖先匹配

把这些 Link 元素换成 NavLink 来给当前页面添加样式,课程在宿主导航中就是这样做的,祖先匹配就会出现:路由器会把当前 URL 的所有祖先都视为已匹配,所以 to="." 的 Dashboard 链接在 /host/income/host/reviews 上也会显示为活跃状态。给那个链接加上 end 属性,它就只在 /host 处显示为活跃,在下面的任何路径上都不会。

用 useOutletContext 向下传递数据

布局常常拥有其子元素需要的数据。课程中的 HostVanDetail 获取一个车型数据,保存在 state 中,然后渲染一个标签导航和一个 Outlet,信息、定价或照片页面会在那里出现。Props 无法通过 Outlet 传递,因为布局从不直接渲染那些组件。React Router 的答案是在 Outlet 上使用 context prop,在另一端用 useOutletContext 读取:

jsx
<Outlet context={{ currentVan }} />
jsx
import { useOutletContext } from 'react-router-dom'

export default function HostVanInfo() {
  const { currentVan } = useOutletContext()

  return <h4>Name: {currentVan.name}</h4>
}

无论你传什么值给 contextuseOutletContext 在当前填充 Outlet 的任何子元素中都会返回那个值。传一个对象,在接收端进行解构,这是常见的模式,这样以后还有空间添加更多值。如果你熟悉 Context,这会很熟悉:Outlet 充当一个作用域仅限于页面中一个洞的 provider。

嵌套还是不嵌套

嵌套在路由共享 UI 时才值得。这是判断的标准。光看 URL 可能会迷惑你:/vans/vans/:id 共享一个段,为什么不共享一个路由呢?但在 VanLife 中,这两个页面除了网站级别的公共部分外没有任何共享的可见内容,嵌套它们会强制一个尴尬的结构:一个父路由有 path 但没有 element,只能回退到渲染一个裸露的 Outlet,加上一个 index 子路由来渲染列表。这样做结构更复杂,却没有任何收益。

当有共享 UI 需要保持在屏幕上时,嵌套路由并给父路由添加一个包含 Outlet 的布局元素。当唯一的动机就是避免重复一个路径段时,两个平级的路由是更简洁、更清晰的选择。

版本说明

这里的例子从 react-router-dom 导入,在 v6 和 v7 中都能用;路由介绍了 v7 在包上做的改动。嵌套路由的概念在两个版本中是一样的。

匹配是一个树形操作。React Router 把路由树的每个分支与 URL 进行比对,然后渲染整个获胜的分支,从最外层的元素开始,每个 Outlet 接收下一层的元素。无路径的布局路由对匹配没有贡献;它只是把一个元素注入到链中,这就是为什么它可以包裹所有内容。这也解释了一个失败情况:父元素没有 Outlet 会静默地吞掉子元素,匹配成功了,分支有三个路由,但渲染链中没有洞来继续。

相对解析遵循的是路由树而非 URL 字符串,这个区别在动态段上会显现。从一个由 <Route path="vans/:id"> 渲染的组件出发,该路由嵌套在 host 下,to=".." 解析为 /host,跳过了 :idvans,因为 .. 爬升一个路由,而那个单独的路由同时拥有这两个段。当你想要 URL 段行为时,可以用 <Link to=".." relative="path"> 来选择。两个默认都没问题;路由相对链接在路径重命名时保持不变,而路径相对链接更符合用户对返回按钮的直觉。

useOutletContext 本质上就是幕后的 context:Outlet 把匹配的子元素包裹在一个 provider 中。这给了它 context 的重渲染行为,所以传给 context prop 的新对象字面量意味着每次布局重渲染时,所有读取 useOutletContext 的组件都会重渲染。在页面规模上这很少是个问题,因为导航无论如何会替换整个子树。

根据作用域在三种传递机制之间做选择:在你自己渲染子元素的地方用 props,对于一个布局向它的任何填充孔的页面传递值用 Outlet context,对于路由子树外的组件也需要这个值时用完整的 context。结构上有一个警告:Outlet context 会让子元素与它们的父布局形状耦合。一个读取 useOutletContext 的组件只有在挂载在提供那个形状的布局下才能工作,所以保持 context 值的体积小,把它当作布局公开 API 的一部分。

Juno嵌套路由来保持共享 UI 在页面上 当页面共享屏幕的某些部分时,比如导航栏应该保持不动而下方内容改变,你可以把一个路由嵌套在另一个里面。父路由渲染共享的部分,并在 Outlet 处放置子页面。

没有路径但有 element 的路由是包裹所有内容的布局,index 路由是在父路由自己的地址处显示的默认子元素。

如果两个页面在屏幕上没有共享任何内容,就没有必要嵌套它们。

Juno嵌套路由来保持共享 UI 在页面上 把子路由包裹在父 Route 中,给父路由一个包含 Outlet 的布局元素,用 index 标记默认子元素。删除开头的斜杠使路径和链接相对于它们的父路由解析,这样重命名就是一行改动,用 to="." 来链接到当前路由。

当布局拥有它的 Outlet 子元素需要的数据时,用 Outletcontext prop 传递,用 useOutletContext 读取。

为共享 UI 而嵌套;当唯一的好处就是路径字符串更短时就跳过它。

Juno嵌套路由来保持共享 UI 在页面上 路由器匹配路由树的一个分支并从最外层开始渲染,每个 Outlet 接收下一层的元素,无路径布局注入元素而不影响匹配。相对链接针对路由树解析,所以 .. 爬升一个路由,即使那个路由拥有多个 URL 段;用 relative="path" 选择进入段爬升。

Outlet context 是一个作用域 provider,对于布局到页面的数据很方便,但也是一个耦合点,所以保持它的形状小而稳定。

嵌套只为共享 UI;一个有路径但没有 element 的父路由是信号,说明平级版本其实没问题。

接下来:路由参数和位置,一个路由服务多个页面。