Skip to content

动态路由,路由组,平行路由,拦截路由

前言

实际项目开发的时候,有的路由场景会比较复杂,比如数据库里的文章有很多,我们不可能一一去定义路由,此时该怎么办?组织代码的时候,有的路由是用于移动端,有的路由是用于 PC 端,该如何组织代码?如何有条件的渲染页面,比如未授权的时候显示登录页?如何让同一个路由根据不同的场景展示不同的内容?

本篇我们会一一解决这些问题,在此篇,你将会感受到 App Router 强大的路由功能。

动态路由(Dynamic Routes)

有的时候,你并不能提前知道路由的地址,就比如根据 URL 中的 id 参数展示该 id 对应的文章内容,文章那么多,我们不可能一一定义路由,这个时候就需要用到动态路由。

[folderName]

使用动态路由,你需要将文件夹的名字用方括号括住,比如 [id][slug]。这个路由的名字会作为 params prop 传给布局、 页面、 路由处理程序 以及 generateMetadata 函数。

举个例子,我们在 app/blog 目录下新建一个名为 [slug] 的文件夹,在该文件夹新建一个 page.js 文件,代码如下:

js
// app/blog/[slug]/page.js
export default function Page({ params }) {
  return <div>My Post: {params.slug}</div>;
}

当你访问 /blog/a 的时候,params 的值为 { slug: 'a' }

当你访问 /blog/yayu的时候,params 的值为 { slug: 'yayu' }

以此类推

[...folderName]

在命名文件夹的时候,如果你在方括号内添加省略号,比如 [...folderName],这表示捕获所有后面所有的路由片段。

也就是说,app/shop/[...slug]/page.js 会匹配 /shop/clothes,也会匹配 /shop/clothes/tops/shop/clothes/tops/t-shirts 等等。

举个例子app/dash/[...slug]/page.js

ts
// app/shop/[...slug]/page.js
async function Page({ params }) {
  const { slug } = (await params) || {};
  return <div> MyPost:{JSON.stringify(slug)}</div>;
}

export default Page;

当你访问 /shop/a的时候,params 的值为 { slug: ['a'] }

当你访问 /shop/a/b的时候,params 的值为 { slug: ['a', 'b'] }

当你访问 /shop/a/b/c的时候,params 的值为 { slug: ['a', 'b', 'c'] }

  • 注意

注意

  1. [...slug] 要是同级 你又创建了一个文件夹test相当于多了一个test路由,[...slug]不包含 test

[[...folderName]] (用处不大,了解即可)

在命名文件夹的时候,如果你在双方括号内添加省略号,比如 [[...folderName]],这表示可选的捕获所有后面所有的路由片段。

也就是说,app/shop/[[...slug]]/page.js 会匹配 /shop,也会匹配 /shop/clothes/shop/clothes/tops/shop/clothes/tops/t-shirts 等等。

它与上一种的区别就在于,不带参数的路由也会被匹配(就比如 /shop

举个例子 app/shop/[[...slug]]/page.js 的代码如下:

ts
// app/shop/[[...slug]]/page.js
async function Page({ params }) {
  const { slug } = (await params) || {};
  if (slug) {
    return <div> MyPost:{JSON.stringify(slug)}</div>;
  } else {
    return <div> MyPost:{JSON.stringify({})}</div>;
  }
}

export default Page;

当你访问 /shop的时候,params 的值为 {}

当你访问 /shop/a 的时候,params 的值为 { slug: ['a'] }

当你访问 /shop/a/b 的时候,params 的值为 { slug: ['a', 'b'] }

当你访问 /shop/a/b/c 的时候,params 的值为 { slug: ['a', 'b', 'c'] }

总结

总结

  1. [slug]和[id]同级别只能出现一次,否则会报错

  2. [...slug] 是个数组 可以用 slug[0],slug[1]...之类的

  3. [...slug] 下一个层级再写[id] 毫无意义 因为它会找[...slug]那个 page.js 而不会找[id]那个 page.js 而且还会报错

路由组

app 目录下,文件夹名称通常会被映射到 URL 中,但你可以将文件夹标记为路由组,阻止文件夹名称被映射到 URL 中。

使用路由组,你可以将路由和项目文件按照逻辑进行分组,但不会影响 URL 路径结构。路由组可用于比如:

  1. 按站点、意图、团队等将路由分组

  2. 在同一层级中创建多个布局,甚至是创建多个根布局

那么该如何标记呢?把文件夹用括号括住就可以了,就比如 (dashboard)

按照逻辑分组

将路由按逻辑分组,但不影响 URL 路径:

你会发现,最终的 URL 中省略了带括号的文件夹(上图中的(marketing)(shop))。

创建不同布局

借助路由组,即便在同一层级,也可以创建不同的布局:

在这个例子中,/account/cart/checkout 都在同一层级。但是 /account/cart 使用的是 /app/(shop)/layout.js 布局和 app/layout.js 布局,/checkout 使用的是 app/layout.js

创建多个根布局

创建多个根布局

创建多个根布局,你需要删除掉 app/layout.js 文件,然后在每组都创建一个 layout.js 文件。

创建的时候要注意,因为是根布局,所以要有 <html><body> 标签。

这个功能很实用,比如你将前台购买页面和后台管理页面都放在一个项目里,一个 C 端,一个 B 端,两个项目的布局肯定不一样,借助路由组,就可以轻松实现区分。

  • 注意

注意

  1. 路由组的命名除了用于组织之外并无特殊意义。它们不会影响 URL 路径。

  2. 注意不要解析为相同的 URL 路径。举个例子,因为路由组不影响 URL 路径,所以 (marketing)/about/page.js(shop)/about/page.js 都会解析为 /about,这会导致报错。

  3. 创建多个根布局的时候,因为删除了顶层的 app/layout.js 文件,访问 /会报错,所以 app/page.js 需要定义在其中一个路由组中。

  4. 跨根布局导航会导致页面完全重新加载,就比如使用 app/(shop)/layout.js 根布局的 /cart 跳转到使用 app/(marketing)/layout.js 根布局的 /blog 会导致页面重新加载(full page load)。

  5. 当定义多个根布局的时候,使用 app/not-found.js 会出现问题。

平行路由

平行路由可以使你在同一个布局中同时或者有条件的渲染一个或者多个页面(类似于 Vue 的插槽功能)。

条件渲染

举个例子,在后台管理页面,需要同时展示团队(team)和数据分析(analytics)页面:

平行路由的使用方式是将文件夹以 @作为开头进行命名,比如在上图中就定义了两个插槽 @team@analytics

插槽会作为 props 传给共享的父布局。在上图中,app/layout.jsprops 中获取了 @team@analytics 两个插槽的内容,并将其与 children 并行渲染:

ts
// app/layout.js
// 这里我们用了 ES6 的解构,写法更简洁一点
export default function Layout({ children, team, analytics }) {
  return (
    <>
      {children}
      {team}
      {analytics}
    </>
  );
}

注:从这张图也可以看出,children prop 其实就是一个隐式的插槽,/app/page.js 相当于 app/@children/page.js

除了让它们同时展示,你也可以根据条件判断展示:

在这个例子中,先在布局中获取用户的登录状态,如果登录,显示 dashboard 页面,没有登录,显示 login 页面。这样做的一大好处就在于代码完全分离。

独立路由处理

平行路由可以让你为每个路由定义独立的错误处理和加载界面:

子导航

注意我们描述 teamanalytics 时依然用的是“页面”这个说法,因为它们就像书写正常的页面一样使用 page.js。除此之外,它们也能像正常的页面一样,添加子页面,比如我们在 @analytics 下添加两个子页面:/page-views and /visitors

平行路由跟路由组一样,不会影响 URL,所以 /@analytics/page-views/page.js 对应的地址是 /page-views/@analytics/visitors/page.js 对应的地址是 /visitors,你可以导航至这些路由:

ts
// app/layout.js
import Link from "next/link";

export default function RootLayout({ children, analytics }) {
  return (
    <html>
      <body>
        <nav>
          <Link href="/">Home</Link>
          <br />
          <Link href="/page-views">Page Views</Link>
          <br />
          <Link href="/visitors">Visitors</Link>
        </nav>
        <h1>root layout</h1>
        {analytics}
        {children}
      </body>
    </html>
  );
}

当导航至这些子页面的时候,子页面的内容会取代 /@analytics/page.js 以 props 的形式注入到布局中

这也就是说,每个插槽都可以有自己独立的导航和状态管理,就像一个小型应用一样。这种特性适合于构建复杂的应用如 dashboard

总结

  • 使用平行路由可以将单个布局拆分为多个插槽,使代码更易于管理,尤其适用于团队协作的时候

  • 每个插槽都可以定义自己的加载界面和错误状态,比如某个插槽加载速度比较慢,那就可以加一个加载效果,加载期间,也不会影响其他插槽的渲染和交互。当出现错误的时候,也只会在具体的插槽上出现错误提示,而不会影响页面其他部分,有效改善用户体验

  • 每个插槽都可以有自己独立的导航和状态管理,这使得插槽的功能更加丰富,比如在上面的例子中,我们在 @analytics 插槽下又建了查看页面 PV 的 /page-views、查看访客的 /visitors,使得同一个插槽区域可以根据路由显示不同的内容

那你可能要问了,我就不使用平行路由,我就完全使用拆分组件的形式,加载状态和错误状态全都自己处理,子路由也统统自己处理,可不可以?

当然是可以的,只要不嫌麻烦的话……

  • 注意

注意

注意:使用平行路由的时候,热加载有可能会出现错误。如果出现了让你匪夷所思的情况,重新运行 npm run dev 或者构建生产版本查看效果

示例 default.js

为了让大家更好的理解平行路由,我们写一个示例代码。项目结构如下:

bash
app
├─ @analytics
   └─ page-views
    └─ page.js
   └─ visitors
     └─ page.js
   └─ page.js
├─ @team
  └─ page.js
├─ layout.js
└─ page.js
  • 其中app/layout.js代码如下:
ts
import Link from "next/link";
import "./globals.css";

export default function RootLayout({ children, team, analytics }) {
  return (
    <html>
      <body>
        <div>Parallel Routes Examples</div>
        <nav>
          <Link href="/">Home</Link>
          <Link href="/page-views">Page Views</Link>
          <Link href="/visitors">Visitors</Link>
        </nav>
        <div>
          {team}
          {analytics}
        </div>
        {children}
      </body>
    </html>
  );
}
  • app/page.js代码如下
ts
export default function Page() {
  return <div>Hello, App!</div>;
}
  • app/@analytics/page.js 代码如下
ts
export default function Page() {
  return <div>Hello, Analytics!</div>;
}
  • app/@analytics/page-views/page.js 代码如下
ts
export default function Page() {
  return <div>Hello, Analytics Page Views!</div>;
}
  • app/@analytics/visitors/page.js代码如下
ts
export default function Page() {
  return <div>Hello, Analytics Visitors!</div>;
}
  • app/@team/page.js代码如下
ts
export default function Page() {
  return <div>Hello, Team!</div>;
}
  • 这个时候运行 就能看出结果了.跳转什么都可以 但是刷新不行

这是为什么呢?为什么我们从首页导航至 /visitors 的时候可以正常显示?而直接进入 /visitors 就会出现 404 错误呢?

因为 当你访问 /visitors 的时候,读取的不仅仅是 app/@analytics/visitors/page.js,还有 app/@team/visitors/page.jsapp/visitors/page.js

所以只有如下操作:

  • 在每个@xxxx 都必须有两个文件 一个 default.js 一个 page.js

page.js 是默认的页面软跳转

defualt.js是硬跳转(页面刷新)

目录如下

bash
app
├─ @analytics
   └─ page-views
    ├─ default.js
    └─ page.js
   └─ visitors
    ├─ default.js
    └─ page.js
   └─ page.js
├─ @team
  ├─ default.js
  └─ page.js
├─ layout.js
├─ default.js
└─ page.js

这个时候就可以正常访问了

拦截路由

拦截路由允许你在当前路由拦截其他路由地址并在当前路由中展示内容。

简单来说 就是

注意

同样一个路由地址,却展示了不同的内容。这就是拦截路由的效果。如果你在 dribbble.com 想要访问 dribbble.com/shots/xxxxx,此时会拦截 dribbble.com/shots/xxxxx 这个路由地址,以 Modal 的形式展现。而当直接访问 dribbble.com/shots/xxxxx 时,则是原本的样式。

  • 第二种

简单的来说,就是希望用户继续停留在重要的页面上。比如上述例子中的图片流页面,开发者肯定是希望用户能够持续在图片流页面浏览,如果点击一张图片就跳转出去,会打断用户的浏览体验,如果点击只展示一个 Modal,分享操作又会变得麻烦一点。拦截路由正好可以实现这样一种平衡。又比如任务列表页面,点击其中一项任务,弹出 Modal 让你能够编辑此任务,同时又可以方便的分享任务内容。

实现方式

那么这个效果该如何实现呢?在 Next.js 中,实现拦截路由需要你在命名文件夹的时候以 (..) 开头,其中:

  • (.) 表示匹配同一层级
  • (..) 表示匹配上一层级
  • (..)(..) 表示匹配上上层级。
  • (...) 表示匹配根目录

注意

但是要注意的是,这个匹配的是路由的层级而不是文件夹路径的层级,就比如路由组、平行路由这些不会影响 URL 的文件夹就不会被计算层级。

  • 举例

/feed/(..)photo对应的路由是 /feed/photo,要拦截的路由是 /photo,两者只差了一个层级,所以使用 (..)

示例

我们写个 demo 来实现这个效果,目录结构如下:

ts
app
├─ layout.js
├─ page.js
├─ data.js
├─ default.js
├─ @modal
│  ├─ default.js
│  └─ (.)photo
│     └─ [id]
│        └─ page.js
└─ photo
   └─ [id]
      └─ page.js

虽然涉及的文件很多,但每个文件的代码都很简单。

先 Mock 一下图片的数据,app/data.js 代码如下:

ts
export const photos = [
  { id: "1", src: "https://placedog.net/200/200" },
  { id: "2", src: "https://placebear.com/200/200" },
  {
    id: "3",
    src: "https://file.jsopy.com/JSOPY/QianDuan/KuangJia/React/KuangJia/Next/Next_Basic01_250827_01.png",
  },
  {
    id: "4",
    src: "https://file.jsopy.com/JSOPY/QianDuan/KuangJia/React/KuangJia/Next/Next_Basic01_250827_03.png",
  },
  {
    id: "5",
    src: "https://file.jsopy.com/JSOPY/QianDuan/KuangJia/React/KuangJia/Next/Next_Basic01_250827_02.png",
  },
  {
    id: "6",
    src: "https://file.jsopy.com/JSOPY/QianDuan/KuangJia/React/KuangJia/Next/Next_Basic01_250827_04.png",
  },
  {
    id: "7",
    src: "https://file.jsopy.com/JSOPY/QianDuan/KuangJia/React/KuangJia/Next/Next_Basic01_250827_05.png",
  },
];

app/page.js代码如下:

ts
import Link from "next/link";
import { photos } from "./data";

export default function Home() {
  return (
    <main className="flex flex-row flex-wrap">
      {photos.map(({ id, src }) => (
        <Link key={id} href={`/photo/${id}`}>
          <img width="200" src={src} className="m-1" />
        </Link>
      ))}
    </main>
  );
}

app/layout.js 代码如下

ts
import "./globals.css";

export default function Layout({ children, modal }) {
  return (
    <html>
      <body>
        {children}
        {modal}
      </body>
    </html>
  );
}

我们再来实现下单独访问图片地址时的效果,新建 app/photo/[id]/page.js,代码如下

ts
import { photos } from "../../data";

export default function PhotoPage({ params: { id } }) {
  const photo = photos.find((p) => p.id === id);
  return <img className="block w-1/4 mx-auto mt-10" src={photo.src} />;
}

现在我们开始实现拦截路由,为了和单独访问图片地址时的样式区分,我们声明另一种样式效果。app/@modal/(.)photo/[id]/page.js 代码如下:

ts
import { photos } from "../../../data";

export default function PhotoModal({ params: { id } }) {
  const photo = photos.find((p) => p.id === id);
  return (
    <div className="flex h-60 justify-center items-center fixed bottom-0 bg-slate-300 w-full">
      <img className="w-52" src={photo.src} />
    </div>
  );
}

因为用到了平行路由,所以我们需要设置 default.jsapp/default.jsapp/@modal/default.js 的代码都是:

ts
export default function Default() {
  return null;
}

你可以看到,在 /路由下,访问 /photo/5,路由会被拦截,并使用 @modal/(.)photo/[id]/page.js 的样式

总结

这一节我们介绍了动态路由、路由组、平行路由、拦截路由,它们的共同特点就需要对文件名进行修饰。 其中动态路由用来处理动态的链接,路由组用来组织代码,平行路由和拦截路由则是处理实际开发中会遇到的场景问题。 平行路由和拦截路由初次理解的时候可能会有些难度,但只要你跟着文章中的 demo 手敲一遍,相信你一定能够快速理解和掌握!